Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ce9a5d517 |
@@ -7,7 +7,6 @@ on:
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||
if: startsWith(github.event.release.tag_name, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -39,7 +38,7 @@ jobs:
|
||||
# packages_dir: dist/
|
||||
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages_dir: dist/
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Publish @mem0/cli 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/cli 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'cli-node-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli/node
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
@@ -1,100 +0,0 @@
|
||||
name: CLI Node CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/node/**'
|
||||
- '.github/workflows/cli-node-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/node/**'
|
||||
- '.github/workflows/cli-node-ci.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cli/node
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
working-directory: cli/node
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Type check
|
||||
working-directory: cli/node
|
||||
run: pnpm run typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cli/node
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
working-directory: cli/node
|
||||
run: pnpm run test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cli/node
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: cli/node
|
||||
run: pnpm run build
|
||||
|
||||
- name: Verify dist output
|
||||
run: |
|
||||
test -f cli/node/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Publish mem0-cli 🐍 distributions 📦 to PyPI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish mem0-cli 📦 to PyPI
|
||||
if: startsWith(github.event.release.tag_name, 'cli-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli/python
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: hatch build --clean
|
||||
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: cli/python/dist/
|
||||
@@ -1,79 +0,0 @@
|
||||
name: CLI Python CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/python/**'
|
||||
- '.github/workflows/cli-python-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/python/**'
|
||||
- '.github/workflows/cli-python-ci.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dev dependencies
|
||||
working-directory: cli/python
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Lint with ruff
|
||||
working-directory: cli/python
|
||||
run: ruff check .
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: cli/python
|
||||
run: ruff format --check .
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dev dependencies
|
||||
working-directory: cli/python
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
working-directory: cli/python
|
||||
run: pytest
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
|
||||
- name: Build
|
||||
working-directory: cli/python
|
||||
run: hatch build --clean
|
||||
|
||||
- name: Verify dist output
|
||||
run: |
|
||||
ls cli/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
|
||||
ls cli/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Publish @mem0/openclaw-mem0 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/openclaw-mem0 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'openclaw-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: openclaw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Publish mem0ai 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish mem0ai 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'ts-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mem0-ts
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: mem0-ts/pnpm-lock.yaml
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Publish @mem0/vercel-ai-provider 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/vercel-ai-provider 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'vercel-ai-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: vercel-ai-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: vercel-ai-sdk/pnpm-lock.yaml
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
@@ -61,31 +61,3 @@ make test # After activating a shell with hatch shell test_XX
|
||||
Make sure that all tests pass across all supported Python versions before submitting a pull request.
|
||||
|
||||
We look forward to your pull requests and can't wait to see your contributions!
|
||||
|
||||
### 🚀 Releasing
|
||||
|
||||
All packages are published automatically via GitHub Actions when a GitHub Release is created with the correct tag prefix.
|
||||
|
||||
#### Tag Prefixes
|
||||
|
||||
| Package | Registry | Tag Prefix | Example |
|
||||
|---------|----------|------------|---------|
|
||||
| `mem0ai` (Python SDK) | PyPI | `v*` | `v0.1.31` |
|
||||
| `mem0-cli` (Python CLI) | PyPI | `cli-v*` | `cli-v0.2.1` |
|
||||
| `mem0ai` (TypeScript SDK) | npm | `ts-v*` | `ts-v2.4.6` |
|
||||
| `@mem0/cli` (Node CLI) | npm | `cli-node-v*` | `cli-node-v0.1.2` |
|
||||
| `@mem0/vercel-ai-provider` | npm | `vercel-ai-v*` | `vercel-ai-v2.0.6` |
|
||||
| `@mem0/openclaw-mem0` | npm | `openclaw-v*` | `openclaw-v1.0.1` |
|
||||
|
||||
#### How to Release
|
||||
|
||||
1. Bump the version in `pyproject.toml` (Python) or `package.json` (Node)
|
||||
2. Create a [GitHub Release](https://github.com/mem0ai/mem0/releases/new) with the matching tag prefix
|
||||
3. The correct workflow will trigger automatically — verify in the [Actions tab](https://github.com/mem0ai/mem0/actions)
|
||||
|
||||
#### Publishing Details
|
||||
|
||||
- **PyPI packages** use OIDC trusted publishing via `pypa/gh-action-pypi-publish`
|
||||
- **npm packages** use OIDC trusted publishing via npm CLI (>= 11.5.1) — no tokens or secrets required
|
||||
- All workflows require `permissions: id-token: write` for OIDC authentication
|
||||
- First publish of a new npm package must be done manually; OIDC works for subsequent versions
|
||||
|
||||
@@ -162,7 +162,7 @@ For detailed integration steps, see the [Quickstart](https://docs.mem0.ai/quicks
|
||||
## 📚 Documentation & Support
|
||||
|
||||
- Full docs: https://docs.mem0.ai
|
||||
- Community: [Discord](https://mem0.dev/DiG) · [X (formerly Twitter)](https://x.com/mem0ai)
|
||||
- Community: [Discord](https://mem0.dev/DiG) · [Twitter](https://x.com/mem0ai)
|
||||
- Contact: founders@mem0.ai
|
||||
|
||||
## Citation
|
||||
@@ -180,4 +180,4 @@ We now have a paper you can cite:
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
|
||||
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
|
||||
+15
-188
@@ -28,7 +28,7 @@ mem0 CLI is the official command-line interface for [mem0](https://mem0.ai) -- t
|
||||
### Who is it for?
|
||||
|
||||
- Developers integrating mem0 into their workflows
|
||||
- AI agents that need persistent memory (the CLI is designed with `--json`/`--agent` global flags and `help --json` specifically for machine consumption)
|
||||
- AI agents that need persistent memory (the CLI is designed with `--output json` and `help --json` specifically for machine consumption)
|
||||
- DevOps/CI pipelines that need to manage memories programmatically
|
||||
|
||||
### Project Structure
|
||||
@@ -79,7 +79,6 @@ Apache-2.0
|
||||
│ ├── init_cmd.py # run_init (interactive wizard)
|
||||
│ ├── config_cmd.py # cmd_config_show, cmd_config_get, cmd_config_set
|
||||
│ ├── entities.py # cmd_entities_list, cmd_entities_delete
|
||||
│ ├── events_cmd.py # cmd_event_list, cmd_event_status
|
||||
│ └── utils.py # cmd_status, cmd_version, cmd_import
|
||||
└── node/
|
||||
├── package.json # Node package config (tsup build)
|
||||
@@ -89,7 +88,6 @@ Apache-2.0
|
||||
├── config.ts # Config loading/saving, env var overrides
|
||||
├── branding.ts # Colors, icons, banner, timedStatus, print helpers
|
||||
├── output.ts # Output formatting (text, json, table, quiet)
|
||||
├── state.ts # Agent mode flag (setAgentMode, isAgentMode)
|
||||
├── help.ts # Rich-style help formatter (panels, command ordering)
|
||||
├── backend/
|
||||
│ ├── index.ts # Re-exports
|
||||
@@ -100,7 +98,6 @@ Apache-2.0
|
||||
├── init.ts # runInit (interactive wizard)
|
||||
├── config.ts # cmdConfigShow, cmdConfigGet, cmdConfigSet
|
||||
├── entities.ts # cmdEntitiesList, cmdEntitiesDelete
|
||||
├── events.ts # cmdEventList, cmdEventStatus
|
||||
└── utils.ts # cmdStatus, cmdVersion, cmdImport
|
||||
```
|
||||
|
||||
@@ -156,25 +153,8 @@ Interactive setup wizard for mem0 CLI.
|
||||
|-----------------|--------|----------|---------|------|
|
||||
| `--api-key` | string | No | - | API key (skip prompt). |
|
||||
| `-u, --user-id` | string | No | - | Default user ID (skip prompt). |
|
||||
| `--email` | string | No | - | Login via email verification code. |
|
||||
| `--code` | string | No | - | Verification code (use with --email for non-interactive login). |
|
||||
| `--force` | bool | No | false | Overwrite existing config without confirmation. |
|
||||
|
||||
**Behavior:**
|
||||
|
||||
*Existing config protection:*
|
||||
- If `~/.mem0/config.json` exists with an API key, the CLI warns and asks for confirmation before overwriting.
|
||||
- In non-TTY mode, this is a hard error unless `--force` is passed.
|
||||
- `--force` skips the confirmation in both TTY and non-TTY modes.
|
||||
|
||||
*Email login flow (when `--email` is provided):*
|
||||
- Sends a 6-digit verification code to the email via `POST /api/v1/auth/email_code/`.
|
||||
- If `--code` is also provided, verifies immediately (fully non-interactive).
|
||||
- If `--code` is not provided, prompts for the code interactively.
|
||||
- On success: receives API key, org_id, project_id. Saves to config. Creates account if email is new.
|
||||
- Cannot be combined with `--api-key`.
|
||||
|
||||
*API key flow (existing behavior):*
|
||||
- If both `--api-key` and `--user-id` are provided, runs non-interactively (no prompts).
|
||||
- If running in a non-TTY without both flags, prints an error with usage hint and exits.
|
||||
- Interactive mode: prints banner, prompts for API key (masked with `*`), prompts for default user ID (default: `mem0-cli`), validates connection, saves config.
|
||||
@@ -184,9 +164,6 @@ Interactive setup wizard for mem0 CLI.
|
||||
```bash
|
||||
mem0 init
|
||||
mem0 init --api-key m0-xxx --user-id alice
|
||||
mem0 init --api-key m0-xxx --user-id alice --force
|
||||
mem0 init --email alice@company.com
|
||||
mem0 init --email alice@company.com --code 482901
|
||||
```
|
||||
|
||||
---
|
||||
@@ -710,114 +687,7 @@ mem0 entity delete --user-id alice --dry-run
|
||||
|
||||
---
|
||||
|
||||
### 3.14 `event list`
|
||||
|
||||
List recent background processing events.
|
||||
|
||||
| Property | Value |
|
||||
|------------------|-------|
|
||||
| Usage | `mem0 event list [OPTIONS]` |
|
||||
| needsBackend | Yes |
|
||||
| needsConfig | Yes |
|
||||
| resolveIds | No |
|
||||
| resolveGraph | No |
|
||||
| confirmDangerous | No |
|
||||
| Output formats | text (table), json |
|
||||
| Default output | table |
|
||||
| API endpoint | `GET /v1/events/` |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Type | Default | Panel | Help |
|
||||
|----------------|--------|---------|------------|------|
|
||||
| `-o, --output` | string | "table" | Output | Output: text, json. |
|
||||
| `--api-key` | string | - | Connection | Override API key. |
|
||||
| `--base-url` | string | - | Connection | Override API base URL. |
|
||||
|
||||
**Behavior:** Fetches all background events for the project. Displays as a table with columns: Event ID (first 8 chars), Type, Status (color-coded), Latency, Created. Status values: `PENDING` (accent), `SUCCEEDED` (green), `FAILED` (red), `PROCESSING` (yellow).
|
||||
|
||||
**JSON output envelope:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"command": "event list",
|
||||
"count": 3,
|
||||
"duration_ms": 87,
|
||||
"data": [
|
||||
{ "id": "evt-abc", "event_type": "ADD", "status": "SUCCEEDED", "latency": 412.0, "created_at": "2026-01-01T10:00:00Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
mem0 event list
|
||||
mem0 event list --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.15 `event status`
|
||||
|
||||
Get the status and results of a specific background event.
|
||||
|
||||
| Property | Value |
|
||||
|------------------|-------|
|
||||
| Usage | `mem0 event status <event_id> [OPTIONS]` |
|
||||
| needsBackend | Yes |
|
||||
| needsConfig | Yes |
|
||||
| resolveIds | No |
|
||||
| resolveGraph | No |
|
||||
| confirmDangerous | No |
|
||||
| Output formats | text, json |
|
||||
| Default output | text |
|
||||
| API endpoint | `GET /v1/events/{event_id}/` |
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Required | Help |
|
||||
|------------|--------|----------|------|
|
||||
| `event_id` | string | Yes | Event ID to inspect. |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Type | Default | Panel | Help |
|
||||
|----------------|--------|---------|------------|------|
|
||||
| `-o, --output` | string | "text" | Output | Output: text, json. |
|
||||
| `--api-key` | string | - | Connection | Override API key. |
|
||||
| `--base-url` | string | - | Connection | Override API base URL. |
|
||||
|
||||
**Behavior:** Fetches the event by ID and displays: Event ID, Type, Status (color-coded), Latency, Created, Updated, and a numbered list of result memories (event type, memory text, user_id, truncated memory ID). Displayed in a boxed panel (text) or JSON envelope.
|
||||
|
||||
**JSON output envelope:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"command": "event status",
|
||||
"duration_ms": 65,
|
||||
"data": {
|
||||
"id": "evt-abc",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"latency": 412.0,
|
||||
"created_at": "2026-01-01T10:00:00Z",
|
||||
"updated_at": "2026-01-01T10:00:01Z",
|
||||
"results": [
|
||||
{ "id": "mem-xyz", "event": "ADD", "user_id": "alice", "memory": "User prefers dark mode" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
mem0 event status evt-abc-123
|
||||
mem0 event status evt-abc-123 --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.16 `status`
|
||||
### 3.14 `status`
|
||||
|
||||
Check connectivity and authentication.
|
||||
|
||||
@@ -838,19 +708,20 @@ Check connectivity and authentication.
|
||||
| `--api-key` | string | - | Connection | Override API key. |
|
||||
| `--base-url` | string | - | Connection | Override API base URL. |
|
||||
|
||||
**Behavior:** Validates connectivity by calling `GET /v1/ping/`. Displays connection status in a boxed panel (text) or JSON envelope. The ping endpoint is lightweight and does not require any entity scope.
|
||||
**Behavior:** If config has a default `user_id` or `agent_id`, validates by making a minimal `POST /v2/memories/` with `page=1&page_size=1`. Otherwise validates via `GET /v1/entities/`. Displays connection status in a boxed panel (text) or JSON envelope.
|
||||
|
||||
**JSON output:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"command": "status",
|
||||
"duration_ms": 112,
|
||||
"data": {
|
||||
"connected": true,
|
||||
"backend": "platform",
|
||||
"base_url": "https://api.mem0.ai"
|
||||
}
|
||||
"base_url": "https://api.mem0.ai",
|
||||
"latency_ms": 245
|
||||
},
|
||||
"duration_ms": 245
|
||||
}
|
||||
```
|
||||
|
||||
@@ -862,7 +733,7 @@ mem0 status -o json
|
||||
|
||||
---
|
||||
|
||||
### 3.17 `help`
|
||||
### 3.15 `help`
|
||||
|
||||
Show help. Use `--json` for machine-readable output (for LLM agents).
|
||||
|
||||
@@ -922,9 +793,6 @@ The auth header name is `Authorization` and the scheme is `Token` (not Bearer).
|
||||
| Delete all | `DELETE` | `/v1/memories/` | - | entity ID params |
|
||||
| List entities | `GET` | `/v1/entities/` | - | - |
|
||||
| Delete entities | `DELETE` | `/v1/entities/` | - | entity ID params |
|
||||
| List events | `GET` | `/v1/events/` | - | - |
|
||||
| Get event | `GET` | `/v1/events/{event_id}/` | - | - |
|
||||
| Ping (status) | `GET` | `/v1/ping/` | - | - |
|
||||
|
||||
### How Filters Are Built (`_buildFilters` / `_build_filters`)
|
||||
|
||||
@@ -1227,8 +1095,6 @@ For `PENDING` events, displays "Processing in background" with the event ID.
|
||||
|
||||
### 7.1 Supported Modes Per Command
|
||||
|
||||
All commands also support `agent` mode via the global `--json`/`--agent` flag, which wraps output in a structured JSON envelope with sanitized fields.
|
||||
|
||||
| Command | text | json | table | quiet |
|
||||
|----------------|------|------|-------|-------|
|
||||
| add | Y | Y | - | Y |
|
||||
@@ -1243,16 +1109,12 @@ All commands also support `agent` mode via the global `--json`/`--agent` flag, w
|
||||
| config set | (success msg) | - | - | - |
|
||||
| entity list | - | Y | Y (default) | - |
|
||||
| entity delete | Y | Y | - | Y |
|
||||
| event list | Y (table) | Y | - | - |
|
||||
| event status | Y | Y | - | - |
|
||||
| status | Y | Y | - | - |
|
||||
| help | Y | Y (--json) | - | - |
|
||||
|
||||
### 7.2 JSON Envelope Format
|
||||
### 7.2 JSON Envelope Format (`formatJsonEnvelope`)
|
||||
|
||||
There are two related envelope formats:
|
||||
|
||||
**`formatJsonEnvelope`** — used by `config show`, `status`, and `import` for `--output json`:
|
||||
Used by `config show`, `status`, and `import` for structured JSON output:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1266,39 +1128,14 @@ There are two related envelope formats:
|
||||
}
|
||||
```
|
||||
|
||||
**`formatAgentEnvelope`** — used by all commands in agent mode (`--json`/`--agent`). Same structure, but `data` is passed through `sanitizeAgentData(command, data)` to project only the most relevant fields:
|
||||
|
||||
| Command | Fields in `data` |
|
||||
|---------------|-----------------|
|
||||
| add | `[{id, memory, event}]` or `[{status, event_id}]` for PENDING |
|
||||
| search | `[{id, memory, score, created_at, categories}]` |
|
||||
| list | `[{id, memory, created_at, categories}]` |
|
||||
| get | `{id, memory, created_at, updated_at, categories, metadata}` |
|
||||
| update | `{id, memory}` |
|
||||
| delete | (raw API response) |
|
||||
| entity list | `[{name, type, count}]` |
|
||||
| event list | `[{id, event_type, status, latency, created_at}]` |
|
||||
| event status | `{id, event_type, status, latency, created_at, updated_at, results: [{id, event, user_id, memory}]}` |
|
||||
| status/config/import | (pass-through) |
|
||||
|
||||
Error envelopes (on non-zero exit):
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"command": "<command_name>",
|
||||
"error": "Authentication failed. Your API key may be invalid or expired.",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `status`: `"success"` or `"error"`.
|
||||
- `command`: The command name.
|
||||
- `status`: Always `"success"` (errors go to stderr before exit).
|
||||
- `command`: The command name (e.g. `"status"`, `"config show"`, `"import"`).
|
||||
- `duration_ms`: Optional, elapsed time in milliseconds.
|
||||
- `scope`: Optional, active entity scope (omitted if empty).
|
||||
- `scope`: Optional, active entity scope.
|
||||
- `count`: Optional, result count.
|
||||
- `error`: Only present when `status` is `"error"`.
|
||||
- `data`: The primary payload (sanitized in agent mode).
|
||||
- `error`: Optional, error message string.
|
||||
- `data`: The primary payload.
|
||||
|
||||
### 7.3 Text Output
|
||||
|
||||
@@ -1369,16 +1206,6 @@ Destructive commands (`delete --all`, `delete --entity`, `entity delete`) requir
|
||||
- CI/CD pipelines
|
||||
- Scripting
|
||||
|
||||
### Why `--json`/`--agent` global flags exist
|
||||
|
||||
The `--json` and `--agent` flags (aliases of each other) activate agent mode globally. When set:
|
||||
1. All output becomes a structured JSON envelope (`{status, command, duration_ms, scope, count, data}`).
|
||||
2. The `data` field is sanitized via `sanitizeAgentData` — only the most relevant fields are included per command, reducing noise for agents parsing the output.
|
||||
3. All human-readable output (spinners, colors, banners, timing lines) is suppressed.
|
||||
4. Errors are emitted as JSON to stdout with a non-zero exit code, not to stderr as text.
|
||||
|
||||
This is distinct from `--output json`, which returns the raw API response without sanitization.
|
||||
|
||||
### Why `--output json` is on every command
|
||||
|
||||
Every data-returning command supports `--output json` (or `--json` for `help`). This enables machine consumption by AI agents and scripts. JSON output goes to stdout while human-readable spinners/timing go to stderr, so piping `mem0 list -o json | jq .` works cleanly.
|
||||
|
||||
+8
-46
@@ -2,8 +2,6 @@
|
||||
|
||||
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. Works with the Mem0 Platform API. Available in Python and Node.js.
|
||||
|
||||
> **For AI agents:** pass `--agent` (or `--json`) on any command for structured JSON output purpose-built for tool loops — sanitized fields, no colors or spinners, errors as JSON. See [Agent mode](#agent-mode) below.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -19,15 +17,9 @@ Both packages install a `mem0` binary with identical behavior.
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Interactive setup wizard
|
||||
# Authenticate and save config
|
||||
mem0 init
|
||||
|
||||
# Or login via email (get a new API key)
|
||||
mem0 init --email alice@company.com
|
||||
|
||||
# Or authenticate with an existing API key
|
||||
mem0 init --api-key m0-xxx
|
||||
|
||||
# Add a memory
|
||||
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
|
||||
|
||||
@@ -48,7 +40,7 @@ mem0 delete <memory-id>
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `mem0 init` | Setup wizard — login via email or configure API key manually |
|
||||
| `mem0 init` | Interactive setup wizard — configures API key and default user ID |
|
||||
| `mem0 add` | Add a memory from text, JSON messages, a file, or stdin |
|
||||
| `mem0 search` | Search memories using natural language |
|
||||
| `mem0 list` | List memories with optional filters and pagination |
|
||||
@@ -57,45 +49,12 @@ mem0 delete <memory-id>
|
||||
| `mem0 delete` | Delete a memory, all memories for a scope, or an entity |
|
||||
| `mem0 import` | Bulk import memories from a JSON file |
|
||||
| `mem0 config` | View or modify CLI configuration |
|
||||
| `mem0 entity` | List or delete entities (users, agents, apps, runs) |
|
||||
| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) |
|
||||
| `mem0 entities` | List or delete entities (users, agents, apps) |
|
||||
| `mem0 status` | Verify API connection and display current project |
|
||||
| `mem0 version` | Print the CLI version |
|
||||
|
||||
Run `mem0 <command> --help` for detailed usage on any command.
|
||||
|
||||
## Agent mode
|
||||
|
||||
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
|
||||
|
||||
```bash
|
||||
mem0 --agent search "user preferences" --user-id alice
|
||||
mem0 --agent add "User prefers dark mode" --user-id alice
|
||||
mem0 --agent list --user-id alice
|
||||
```
|
||||
|
||||
Every command returns the same envelope shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"command": "search",
|
||||
"duration_ms": 134,
|
||||
"scope": { "user_id": "alice" },
|
||||
"count": 2,
|
||||
"data": [
|
||||
{ "id": "abc-123", "memory": "User prefers dark mode", "score": 0.97, "created_at": "2026-01-15", "categories": ["preferences"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
What agent mode does differently from `--output json`:
|
||||
- **Sanitized `data`**: only the fields an agent needs (id, memory, score, etc.) — no internal API noise
|
||||
- **No human output**: spinners, colors, and banners are suppressed entirely
|
||||
- **Errors as JSON**: errors go to stdout as `{"status": "error", "command": "...", "error": "..."}` with a non-zero exit code
|
||||
|
||||
Use `mem0 help --json` to get the full command tree as JSON — useful for agents that need to self-discover available commands.
|
||||
|
||||
## Output formats
|
||||
|
||||
Control how results are displayed with `--output`:
|
||||
@@ -103,10 +62,13 @@ Control how results are displayed with `--output`:
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `text` | Human-readable with colors and formatting (default) |
|
||||
| `json` | Structured JSON for piping to `jq` (raw API response) |
|
||||
| `json` | Structured JSON for piping to `jq` or agent consumption |
|
||||
| `table` | Tabular format (default for `list`) |
|
||||
| `quiet` | Minimal — just IDs or status codes |
|
||||
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
|
||||
|
||||
```bash
|
||||
mem0 search "preferences" --user-id alice --output json | jq '.data.results[].memory'
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
||||
+5
-11
@@ -10,8 +10,8 @@
|
||||
"logoMini": "\u25c6 mem0",
|
||||
"tagline": "The Memory Layer for AI Agents",
|
||||
"colors": {
|
||||
"brand": "#F1C96C",
|
||||
"accent": "#F5D78E",
|
||||
"brand": "#8b5cf6",
|
||||
"accent": "#a78bfa",
|
||||
"success": "#22c55e",
|
||||
"error": "#ef4444",
|
||||
"warning": "#f59e0b",
|
||||
@@ -503,21 +503,15 @@
|
||||
},
|
||||
{
|
||||
"name": "init",
|
||||
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
|
||||
"usage": "mem0 init [OPTIONS]",
|
||||
"description": "Interactive setup wizard for mem0 CLI.",
|
||||
"usage": "mem0 init",
|
||||
"needsBackend": false,
|
||||
"needsConfig": false,
|
||||
"resolveIds": false,
|
||||
"resolveGraph": false,
|
||||
"confirmDangerous": false,
|
||||
"arguments": [],
|
||||
"options": [
|
||||
{ "name": "api-key", "flags": ["--api-key"], "type": "string", "default": null, "help": "API key (skip prompt)." },
|
||||
{ "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." },
|
||||
{ "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." },
|
||||
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." },
|
||||
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }
|
||||
]
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mem0": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -20,11 +20,6 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "mem0.ai <founders@mem0.ai>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "cli/node"
|
||||
},
|
||||
"keywords": ["mem0", "memory", "ai", "agents", "cli"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -6,123 +6,110 @@ import type { Mem0Config } from "../config.js";
|
||||
import { PlatformBackend } from "./platform.js";
|
||||
|
||||
export interface AddOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
immutable?: boolean;
|
||||
infer?: boolean;
|
||||
expires?: string;
|
||||
categories?: string[];
|
||||
enableGraph?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
immutable?: boolean;
|
||||
infer?: boolean;
|
||||
expires?: string;
|
||||
categories?: string[];
|
||||
enableGraph?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
topK?: number;
|
||||
threshold?: number;
|
||||
rerank?: boolean;
|
||||
keyword?: boolean;
|
||||
filters?: Record<string, unknown>;
|
||||
fields?: string[];
|
||||
enableGraph?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
topK?: number;
|
||||
threshold?: number;
|
||||
rerank?: boolean;
|
||||
keyword?: boolean;
|
||||
filters?: Record<string, unknown>;
|
||||
fields?: string[];
|
||||
enableGraph?: boolean;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
enableGraph?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
enableGraph?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteOptions {
|
||||
all?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
all?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface EntityIds {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts?: AddOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts?: AddOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
search(
|
||||
query: string,
|
||||
opts?: SearchOptions,
|
||||
): Promise<Record<string, unknown>[]>;
|
||||
search(query: string, opts?: SearchOptions): Promise<Record<string, unknown>[]>;
|
||||
|
||||
get(memoryId: string): Promise<Record<string, unknown>>;
|
||||
get(memoryId: string): Promise<Record<string, unknown>>;
|
||||
|
||||
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
|
||||
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
|
||||
|
||||
update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
delete(
|
||||
memoryId?: string,
|
||||
opts?: DeleteOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
delete(memoryId?: string, opts?: DeleteOptions): Promise<Record<string, unknown>>;
|
||||
|
||||
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
||||
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
||||
|
||||
status(opts?: { userId?: string; agentId?: string }): Promise<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
status(opts?: { userId?: string; agentId?: string }): Promise<Record<string, unknown>>;
|
||||
|
||||
entities(entityType: string): Promise<Record<string, unknown>[]>;
|
||||
|
||||
listEvents(): Promise<Record<string, unknown>[]>;
|
||||
|
||||
getEvent(eventId: string): Promise<Record<string, unknown>>;
|
||||
entities(entityType: string): Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
message = "Authentication failed. Your API key may be invalid or expired.",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
constructor(message = "Authentication failed. Your API key may be invalid or expired.") {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Resource not found: ${path}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
constructor(path: string) {
|
||||
super(`Resource not found: ${path}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(path: string, detail: string) {
|
||||
super(`Bad request to ${path}: ${detail}`);
|
||||
this.name = "APIError";
|
||||
}
|
||||
constructor(path: string, detail: string) {
|
||||
super(`Bad request to ${path}: ${detail}`);
|
||||
this.name = "APIError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackend(config: Mem0Config): Backend {
|
||||
return new PlatformBackend(config.platform);
|
||||
return new PlatformBackend(config.platform);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,5 @@
|
||||
*/
|
||||
|
||||
export { getBackend } from "./base.js";
|
||||
export type {
|
||||
Backend,
|
||||
AddOptions,
|
||||
SearchOptions,
|
||||
ListOptions,
|
||||
DeleteOptions,
|
||||
EntityIds,
|
||||
} from "./base.js";
|
||||
export type { Backend, AddOptions, SearchOptions, ListOptions, DeleteOptions, EntityIds } from "./base.js";
|
||||
export { AuthError, NotFoundError, APIError } from "./base.js";
|
||||
|
||||
+266
-309
@@ -4,343 +4,300 @@
|
||||
|
||||
import type { PlatformConfig } from "../config.js";
|
||||
import {
|
||||
APIError,
|
||||
type AddOptions,
|
||||
AuthError,
|
||||
type Backend,
|
||||
type DeleteOptions,
|
||||
type EntityIds,
|
||||
type ListOptions,
|
||||
NotFoundError,
|
||||
type SearchOptions,
|
||||
type AddOptions,
|
||||
APIError,
|
||||
AuthError,
|
||||
type Backend,
|
||||
type DeleteOptions,
|
||||
type EntityIds,
|
||||
type ListOptions,
|
||||
NotFoundError,
|
||||
type SearchOptions,
|
||||
} from "./base.js";
|
||||
|
||||
export class PlatformBackend implements Backend {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(config: PlatformConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.headers = {
|
||||
Authorization: `Token ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
constructor(config: PlatformConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.headers = {
|
||||
Authorization: `Token ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
private async _request(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { json?: unknown; params?: Record<string, string> },
|
||||
): Promise<unknown> {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (opts?.params) {
|
||||
const qs = new URLSearchParams(opts.params).toString();
|
||||
url += `?${qs}`;
|
||||
}
|
||||
private async _request(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { json?: unknown; params?: Record<string, string> },
|
||||
): Promise<unknown> {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (opts?.params) {
|
||||
const qs = new URLSearchParams(opts.params).toString();
|
||||
url += `?${qs}`;
|
||||
}
|
||||
|
||||
const fetchOpts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
};
|
||||
if (opts?.json) {
|
||||
fetchOpts.body = JSON.stringify(opts.json);
|
||||
}
|
||||
const fetchOpts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
};
|
||||
if (opts?.json) {
|
||||
fetchOpts.body = JSON.stringify(opts.json);
|
||||
}
|
||||
|
||||
const resp = await fetch(url, fetchOpts);
|
||||
const resp = await fetch(url, fetchOpts);
|
||||
|
||||
if (resp.status === 401) {
|
||||
throw new AuthError();
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
throw new NotFoundError(path);
|
||||
}
|
||||
if (resp.status === 400) {
|
||||
let detail: string;
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
detail =
|
||||
((body.detail ?? body.message ?? JSON.stringify(body)) as string) ??
|
||||
resp.statusText;
|
||||
} catch {
|
||||
detail = resp.statusText;
|
||||
}
|
||||
throw new APIError(path, detail);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail: string = resp.statusText;
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
detail = (body.detail ?? body.message ?? resp.statusText) as string;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`HTTP ${resp.status}: ${detail}`);
|
||||
}
|
||||
if (resp.status === 204) {
|
||||
return {};
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
throw new AuthError();
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
throw new NotFoundError(path);
|
||||
}
|
||||
if (resp.status === 400) {
|
||||
let detail: string;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
detail = (body as Record<string, string>).detail ?? resp.statusText;
|
||||
} catch {
|
||||
detail = resp.statusText;
|
||||
}
|
||||
throw new APIError(path, detail);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
if (resp.status === 204) {
|
||||
return {};
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts: AddOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
async add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts: AddOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (messages) {
|
||||
payload.messages = messages;
|
||||
} else if (content) {
|
||||
payload.messages = [{ role: "user", content }];
|
||||
}
|
||||
if (messages) {
|
||||
payload.messages = messages;
|
||||
} else if (content) {
|
||||
payload.messages = [{ role: "user", content }];
|
||||
}
|
||||
|
||||
if (opts.userId) payload.user_id = opts.userId;
|
||||
if (opts.agentId) payload.agent_id = opts.agentId;
|
||||
if (opts.appId) payload.app_id = opts.appId;
|
||||
if (opts.runId) payload.run_id = opts.runId;
|
||||
if (opts.metadata) payload.metadata = opts.metadata;
|
||||
if (opts.immutable) payload.immutable = true;
|
||||
if (opts.infer === false) payload.infer = false;
|
||||
if (opts.expires) payload.expiration_date = opts.expires;
|
||||
if (opts.categories) payload.categories = opts.categories;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
if (opts.userId) payload.user_id = opts.userId;
|
||||
if (opts.agentId) payload.agent_id = opts.agentId;
|
||||
if (opts.appId) payload.app_id = opts.appId;
|
||||
if (opts.runId) payload.run_id = opts.runId;
|
||||
if (opts.metadata) payload.metadata = opts.metadata;
|
||||
if (opts.immutable) payload.immutable = true;
|
||||
if (opts.infer === false) payload.infer = false;
|
||||
if (opts.expires) payload.expiration_date = opts.expires;
|
||||
if (opts.categories) payload.categories = opts.categories;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
|
||||
return (await this._request("POST", "/v1/memories/", {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
return (await this._request("POST", "/v1/memories/", { json: payload })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
private _buildFilters(opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
extraFilters?: Record<string, unknown>;
|
||||
}): Record<string, unknown> | undefined {
|
||||
// If caller passed a pre-built filter structure, use it directly
|
||||
if (
|
||||
opts.extraFilters &&
|
||||
("AND" in opts.extraFilters || "OR" in opts.extraFilters)
|
||||
) {
|
||||
return opts.extraFilters;
|
||||
}
|
||||
private _buildFilters(opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
extraFilters?: Record<string, unknown>;
|
||||
}): Record<string, unknown> | undefined {
|
||||
// If caller passed a pre-built filter structure, use it directly
|
||||
if (opts.extraFilters && ("AND" in opts.extraFilters || "OR" in opts.extraFilters)) {
|
||||
return opts.extraFilters;
|
||||
}
|
||||
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (opts.userId) andConditions.push({ user_id: opts.userId });
|
||||
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
|
||||
if (opts.appId) andConditions.push({ app_id: opts.appId });
|
||||
if (opts.runId) andConditions.push({ run_id: opts.runId });
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (opts.userId) andConditions.push({ user_id: opts.userId });
|
||||
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
|
||||
if (opts.appId) andConditions.push({ app_id: opts.appId });
|
||||
if (opts.runId) andConditions.push({ run_id: opts.runId });
|
||||
|
||||
if (opts.extraFilters) {
|
||||
for (const [k, v] of Object.entries(opts.extraFilters)) {
|
||||
andConditions.push({ [k]: v });
|
||||
}
|
||||
}
|
||||
if (opts.extraFilters) {
|
||||
for (const [k, v] of Object.entries(opts.extraFilters)) {
|
||||
andConditions.push({ [k]: v });
|
||||
}
|
||||
}
|
||||
|
||||
if (andConditions.length === 1) return andConditions[0];
|
||||
if (andConditions.length > 1) return { AND: andConditions };
|
||||
return undefined;
|
||||
}
|
||||
if (andConditions.length === 1) return andConditions[0];
|
||||
if (andConditions.length > 1) return { AND: andConditions };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts: SearchOptions = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {
|
||||
query,
|
||||
top_k: opts.topK ?? 10,
|
||||
threshold: opts.threshold ?? 0.3,
|
||||
};
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {
|
||||
query,
|
||||
top_k: opts.topK ?? 10,
|
||||
threshold: opts.threshold ?? 0.3,
|
||||
};
|
||||
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: opts.filters,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.rerank) payload.rerank = true;
|
||||
if (opts.keyword) payload.keyword_search = true;
|
||||
if (opts.fields) payload.fields = opts.fields;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: opts.filters,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.rerank) payload.rerank = true;
|
||||
if (opts.keyword) payload.keyword_search = true;
|
||||
if (opts.fields) payload.fields = opts.fields;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
|
||||
const result = (await this._request("POST", "/v2/memories/search/", {
|
||||
json: payload,
|
||||
})) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
const result = (await this._request("POST", "/v2/memories/search/", {
|
||||
json: payload,
|
||||
})) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async listMemories(
|
||||
opts: ListOptions = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const params: Record<string, string> = {
|
||||
page: String(opts.page ?? 1),
|
||||
page_size: String(opts.pageSize ?? 100),
|
||||
};
|
||||
async listMemories(opts: ListOptions = {}): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const params: Record<string, string> = {
|
||||
page: String(opts.page ?? 1),
|
||||
page_size: String(opts.pageSize ?? 100),
|
||||
};
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (opts.category) {
|
||||
extra.categories = { contains: opts.category };
|
||||
}
|
||||
if (opts.after) {
|
||||
extra.created_at = {
|
||||
...(extra.created_at as Record<string, unknown> | undefined),
|
||||
gte: opts.after,
|
||||
};
|
||||
}
|
||||
if (opts.before) {
|
||||
extra.created_at = {
|
||||
...(extra.created_at as Record<string, unknown> | undefined),
|
||||
lte: opts.before,
|
||||
};
|
||||
}
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (opts.category) {
|
||||
extra.categories = { contains: opts.category };
|
||||
}
|
||||
if (opts.after) {
|
||||
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), gte: opts.after };
|
||||
}
|
||||
if (opts.before) {
|
||||
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), lte: opts.before };
|
||||
}
|
||||
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
|
||||
const result = (await this._request("POST", "/v2/memories/", {
|
||||
json: payload,
|
||||
params,
|
||||
})) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
const result = (await this._request("POST", "/v2/memories/", { json: payload, params })) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (content) payload.text = content;
|
||||
if (metadata) payload.metadata = metadata;
|
||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
async update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (content) payload.text = content;
|
||||
if (metadata) payload.metadata = metadata;
|
||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async delete(
|
||||
memoryId?: string,
|
||||
opts: DeleteOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (opts.all) {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.userId) params.user_id = opts.userId;
|
||||
if (opts.agentId) params.agent_id = opts.agentId;
|
||||
if (opts.appId) params.app_id = opts.appId;
|
||||
if (opts.runId) params.run_id = opts.runId;
|
||||
return (await this._request("DELETE", "/v1/memories/", {
|
||||
params,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
if (memoryId) {
|
||||
return (await this._request(
|
||||
"DELETE",
|
||||
`/v1/memories/${memoryId}/`,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
throw new Error("Either memoryId or --all is required");
|
||||
}
|
||||
async delete(
|
||||
memoryId?: string,
|
||||
opts: DeleteOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (opts.all) {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.userId) params.user_id = opts.userId;
|
||||
if (opts.agentId) params.agent_id = opts.agentId;
|
||||
if (opts.appId) params.app_id = opts.appId;
|
||||
if (opts.runId) params.run_id = opts.runId;
|
||||
return (await this._request("DELETE", "/v1/memories/", { params })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
if (memoryId) {
|
||||
return (await this._request("DELETE", `/v1/memories/${memoryId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
throw new Error("Either memoryId or --all is required");
|
||||
}
|
||||
|
||||
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
|
||||
// v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
|
||||
const typeMap: [string, string | undefined][] = [
|
||||
["user", opts.userId],
|
||||
["agent", opts.agentId],
|
||||
["app", opts.appId],
|
||||
["run", opts.runId],
|
||||
];
|
||||
const entities = typeMap.filter(([, v]) => v) as [string, string][];
|
||||
if (entities.length === 0) {
|
||||
throw new Error("At least one entity ID is required for deleteEntities.");
|
||||
}
|
||||
// Delete each provided entity via the v2 path-based endpoint
|
||||
let result: Record<string, unknown> = {};
|
||||
for (const [entityType, entityId] of entities) {
|
||||
result = (await this._request(
|
||||
"DELETE",
|
||||
`/v2/entities/${entityType}/${entityId}/`,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.userId) params.user_id = opts.userId;
|
||||
if (opts.agentId) params.agent_id = opts.agentId;
|
||||
if (opts.appId) params.app_id = opts.appId;
|
||||
if (opts.runId) params.run_id = opts.runId;
|
||||
if (Object.keys(params).length === 0) {
|
||||
throw new Error("At least one entity ID is required for deleteEntities.");
|
||||
}
|
||||
return (await this._request("DELETE", "/v1/entities/", { params })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
async status(
|
||||
opts: { userId?: string; agentId?: string } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this._request("GET", "/v1/ping/");
|
||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
connected: false,
|
||||
backend: "platform",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
async status(
|
||||
opts: { userId?: string; agentId?: string } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
if (opts.userId || opts.agentId) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const statusParams: Record<string, string> = { page: "1", page_size: "1" };
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
await this._request("POST", "/v2/memories/", { json: payload, params: statusParams });
|
||||
} else {
|
||||
await this._request("GET", "/v1/entities/");
|
||||
}
|
||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
connected: false,
|
||||
backend: "platform",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async entities(entityType: string): Promise<Record<string, unknown>[]> {
|
||||
const result = (await this._request("GET", "/v1/entities/")) as unknown;
|
||||
let items: Record<string, unknown>[];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else {
|
||||
items = ((result as Record<string, unknown>).results ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
}
|
||||
async entities(entityType: string): Promise<Record<string, unknown>[]> {
|
||||
const result = (await this._request("GET", "/v1/entities/")) as unknown;
|
||||
let items: Record<string, unknown>[];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else {
|
||||
items = ((result as Record<string, unknown>).results ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
users: "user",
|
||||
agents: "agent",
|
||||
apps: "app",
|
||||
runs: "run",
|
||||
};
|
||||
const targetType = typeMap[entityType];
|
||||
if (targetType) {
|
||||
items = items.filter(
|
||||
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async listEvents(): Promise<Record<string, unknown>[]> {
|
||||
const result = (await this._request("GET", "/v1/events/")) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
return ((result as Record<string, unknown>).results ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
}
|
||||
|
||||
async getEvent(eventId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/event/${eventId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
const typeMap: Record<string, string> = {
|
||||
users: "user",
|
||||
agents: "agent",
|
||||
apps: "app",
|
||||
runs: "run",
|
||||
};
|
||||
const targetType = typeMap[entityType];
|
||||
if (targetType) {
|
||||
items = items.filter(
|
||||
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
+71
-98
@@ -4,8 +4,10 @@
|
||||
|
||||
import chalk from "chalk";
|
||||
import ora, { type Ora } from "ora";
|
||||
import { getCurrentCommand, isAgentMode } from "./state.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const PKG_VERSION: string = _require("../package.json").version;
|
||||
|
||||
export const LOGO = `
|
||||
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
|
||||
@@ -19,8 +21,8 @@ export const LOGO = `
|
||||
export const LOGO_MINI = "◆ mem0";
|
||||
export const TAGLINE = "The Memory Layer for AI Agents";
|
||||
|
||||
export const BRAND_COLOR = "#F1C96C";
|
||||
export const ACCENT_COLOR = "#F5D78E";
|
||||
export const BRAND_COLOR = "#8b5cf6";
|
||||
export const ACCENT_COLOR = "#a78bfa";
|
||||
export const SUCCESS_COLOR = "#22c55e";
|
||||
export const ERROR_COLOR = "#ef4444";
|
||||
export const WARNING_COLOR = "#f59e0b";
|
||||
@@ -38,96 +40,75 @@ const dim = chalk.hex(DIM_COLOR);
|
||||
* plain-text for piped/non-TTY or NO_COLOR environments.
|
||||
*/
|
||||
export function sym(fancy: string, plain: string): string {
|
||||
if (!process.stdout.isTTY || process.env.NO_COLOR) return plain;
|
||||
return fancy;
|
||||
if (!process.stdout.isTTY || process.env.NO_COLOR) return plain;
|
||||
return fancy;
|
||||
}
|
||||
|
||||
export function printBanner(): void {
|
||||
if (isAgentMode()) return;
|
||||
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
|
||||
const logoLines = LOGO.trimEnd().split("\n");
|
||||
const tagline = ` ${TAGLINE}`;
|
||||
const subtitle = `Node.js SDK · v${CLI_VERSION}`;
|
||||
const contentLines = ["", ...logoLines, "", tagline, ""];
|
||||
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
|
||||
const logoLines = LOGO.trimEnd().split("\n");
|
||||
const tagline = ` ${TAGLINE}`;
|
||||
const subtitle = `Node.js SDK · v${PKG_VERSION}`;
|
||||
const contentLines = ["", ...logoLines, "", tagline, ""];
|
||||
|
||||
// Compute inner width from longest content line + padding both sides
|
||||
const maxContent = Math.max(...contentLines.map((l) => l.length));
|
||||
const innerWidth = maxContent + pad * 2;
|
||||
const totalWidth = innerWidth + 2; // + 2 for │ borders
|
||||
// Compute inner width from longest content line + padding both sides
|
||||
const maxContent = Math.max(...contentLines.map((l) => l.length));
|
||||
const innerWidth = maxContent + pad * 2;
|
||||
const totalWidth = innerWidth + 2; // + 2 for │ borders
|
||||
|
||||
const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`);
|
||||
const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after
|
||||
const bottomBorder = brand(
|
||||
`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`,
|
||||
);
|
||||
const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`);
|
||||
const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after
|
||||
const bottomBorder = brand(`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`);
|
||||
|
||||
const body = contentLines.map((line) => {
|
||||
const rightPad = innerWidth - pad - line.length;
|
||||
return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`;
|
||||
});
|
||||
// Re-color tagline line with accent instead of brand.bold
|
||||
const taglineIdx = body.length - 2; // second-to-last (before trailing empty line)
|
||||
const taglineRightPad = innerWidth - pad - tagline.length;
|
||||
body[taglineIdx] =
|
||||
`${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
|
||||
const body = contentLines.map((line) => {
|
||||
const rightPad = innerWidth - pad - line.length;
|
||||
return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`;
|
||||
});
|
||||
// Re-color tagline line with accent instead of brand.bold
|
||||
const taglineIdx = body.length - 2; // second-to-last (before trailing empty line)
|
||||
const taglineRightPad = innerWidth - pad - tagline.length;
|
||||
body[taglineIdx] = `${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
|
||||
|
||||
console.log(topBorder);
|
||||
for (const line of body) console.log(line);
|
||||
console.log(bottomBorder);
|
||||
console.log(topBorder);
|
||||
for (const line of body) console.log(line);
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
export function printSuccess(message: string): void {
|
||||
if (isAgentMode()) return;
|
||||
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
|
||||
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
|
||||
}
|
||||
|
||||
export function printError(message: string, hint?: string): void {
|
||||
if (isAgentMode()) {
|
||||
const envelope = {
|
||||
status: "error",
|
||||
command: getCurrentCommand(),
|
||||
error: message,
|
||||
data: null,
|
||||
};
|
||||
console.log(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
console.error(`${error(`${sym("✗", "[error]")} Error:`)} ${message}`);
|
||||
const resolvedHint =
|
||||
hint ??
|
||||
(message.includes("Authentication failed")
|
||||
? `Run ${brand("mem0 init")} to reconfigure your API key · https://app.mem0.ai/dashboard/api-keys`
|
||||
: undefined);
|
||||
if (resolvedHint) {
|
||||
console.error(` ${dim(resolvedHint)}`);
|
||||
}
|
||||
console.error(`${error(sym("✗", "[error]") + " Error:")} ${message}`);
|
||||
if (hint) {
|
||||
console.error(` ${dim(hint)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printWarning(message: string): void {
|
||||
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
|
||||
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
|
||||
}
|
||||
|
||||
export function printInfo(message: string): void {
|
||||
if (isAgentMode()) return;
|
||||
console.error(`${brand(sym("◆", "*"))} ${message}`);
|
||||
console.log(`${brand(sym("◆", "*"))} ${message}`);
|
||||
}
|
||||
|
||||
export function printScope(ids: Record<string, string | undefined>): void {
|
||||
if (isAgentMode()) return;
|
||||
const parts: string[] = [];
|
||||
for (const [key, val] of Object.entries(ids)) {
|
||||
if (val) {
|
||||
parts.push(`${key}=${val}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
console.error(` ${dim(`Scope: ${parts.join(", ")}`)}`);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const [key, val] of Object.entries(ids)) {
|
||||
if (val) {
|
||||
const label = key.replace(/_/g, " ").replace("id", "ID").trim();
|
||||
parts.push(`${label}=${val}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
console.log(` ${dim(`Scope: ${parts.join(", ")}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimedStatusContext {
|
||||
successMsg: string;
|
||||
errorMsg: string;
|
||||
successMsg: string;
|
||||
errorMsg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,37 +116,29 @@ export interface TimedStatusContext {
|
||||
* Equivalent to Python's timed_status context manager.
|
||||
*/
|
||||
export async function timedStatus<T>(
|
||||
message: string,
|
||||
fn: (ctx: TimedStatusContext) => Promise<T>,
|
||||
message: string,
|
||||
fn: (ctx: TimedStatusContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (isAgentMode()) {
|
||||
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
||||
return fn(ctx);
|
||||
}
|
||||
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
||||
const spinner = ora({
|
||||
text: dim(message),
|
||||
color: "yellow",
|
||||
stream: process.stderr,
|
||||
}).start();
|
||||
const start = performance.now();
|
||||
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
||||
const spinner = ora({ text: dim(message), color: "magenta", stream: process.stderr }).start();
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const result = await fn(ctx);
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
spinner.stop();
|
||||
if (ctx.successMsg) {
|
||||
console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
spinner.stop();
|
||||
if (ctx.errorMsg) {
|
||||
printError(`${ctx.errorMsg} (${elapsed}s)`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const result = await fn(ctx);
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
spinner.stop();
|
||||
if (ctx.successMsg) {
|
||||
console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
spinner.stop();
|
||||
if (ctx.errorMsg) {
|
||||
console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format helpers using brand colors for external use. */
|
||||
|
||||
@@ -3,109 +3,88 @@
|
||||
*/
|
||||
|
||||
import Table from "cli-table3";
|
||||
import { colors, printError, printSuccess } from "../branding.js";
|
||||
import { printError, printSuccess, colors } from "../branding.js";
|
||||
import {
|
||||
getNestedValue,
|
||||
loadConfig,
|
||||
redactKey,
|
||||
saveConfig,
|
||||
setNestedValue,
|
||||
getNestedValue,
|
||||
loadConfig,
|
||||
redactKey,
|
||||
saveConfig,
|
||||
setNestedValue,
|
||||
} from "../config.js";
|
||||
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
|
||||
import { isAgentMode, setCurrentCommand } from "../state.js";
|
||||
import { formatJsonEnvelope } from "../output.js";
|
||||
|
||||
const { brand, accent, dim } = colors;
|
||||
|
||||
export function cmdConfigShow(opts: { output?: string } = {}): void {
|
||||
setCurrentCommand("config show");
|
||||
const config = loadConfig();
|
||||
const config = loadConfig();
|
||||
|
||||
if (opts.output === "agent" || opts.output === "json") {
|
||||
formatAgentEnvelope({
|
||||
command: "config show",
|
||||
data: {
|
||||
defaults: {
|
||||
user_id: config.defaults.userId || null,
|
||||
agent_id: config.defaults.agentId || null,
|
||||
app_id: config.defaults.appId || null,
|
||||
run_id: config.defaults.runId || null,
|
||||
enable_graph: config.defaults.enableGraph,
|
||||
},
|
||||
platform: {
|
||||
api_key: redactKey(config.platform.apiKey),
|
||||
base_url: config.platform.baseUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (opts.output === "json") {
|
||||
formatJsonEnvelope({
|
||||
command: "config show",
|
||||
data: {
|
||||
defaults: {
|
||||
user_id: config.defaults.userId || null,
|
||||
agent_id: config.defaults.agentId || null,
|
||||
app_id: config.defaults.appId || null,
|
||||
run_id: config.defaults.runId || null,
|
||||
enable_graph: config.defaults.enableGraph,
|
||||
},
|
||||
platform: {
|
||||
api_key: redactKey(config.platform.apiKey),
|
||||
base_url: config.platform.baseUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${brand("◆ mem0 Configuration")}\n`);
|
||||
console.log();
|
||||
console.log(` ${brand("◆ mem0 Configuration")}\n`);
|
||||
|
||||
const table = new Table({
|
||||
head: [accent("Key"), accent("Value")],
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
const table = new Table({
|
||||
head: [accent("Key"), accent("Value")],
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
// Defaults
|
||||
table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]);
|
||||
table.push([
|
||||
"defaults.agent_id",
|
||||
config.defaults.agentId || dim("(not set)"),
|
||||
]);
|
||||
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
|
||||
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
|
||||
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]);
|
||||
table.push(["", ""]);
|
||||
// Defaults
|
||||
table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]);
|
||||
table.push(["defaults.agent_id", config.defaults.agentId || dim("(not set)")]);
|
||||
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
|
||||
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
|
||||
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]);
|
||||
table.push(["", ""]);
|
||||
|
||||
// Platform
|
||||
table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
|
||||
table.push(["platform.base_url", config.platform.baseUrl]);
|
||||
// Platform
|
||||
table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
|
||||
table.push(["platform.base_url", config.platform.baseUrl]);
|
||||
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function cmdConfigGet(key: string): void {
|
||||
setCurrentCommand("config get");
|
||||
const config = loadConfig();
|
||||
const value = getNestedValue(config, key);
|
||||
const config = loadConfig();
|
||||
const value = getNestedValue(config, key);
|
||||
|
||||
if (value === undefined) {
|
||||
printError(`Unknown config key: ${key}`);
|
||||
} else {
|
||||
// Redact secrets
|
||||
const displayValue =
|
||||
key.includes("api_key") || key.split(".").pop() === "key"
|
||||
? redactKey(String(value))
|
||||
: String(value);
|
||||
if (isAgentMode()) {
|
||||
formatAgentEnvelope({
|
||||
command: "config get",
|
||||
data: { key, value: displayValue },
|
||||
});
|
||||
} else {
|
||||
console.log(displayValue);
|
||||
}
|
||||
}
|
||||
if (value === undefined) {
|
||||
printError(`Unknown config key: ${key}`);
|
||||
} else {
|
||||
// Redact secrets
|
||||
if (key.includes("api_key") || key.split(".").pop() === "key") {
|
||||
console.log(redactKey(String(value)));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cmdConfigSet(key: string, value: string): void {
|
||||
setCurrentCommand("config set");
|
||||
const config = loadConfig();
|
||||
if (setNestedValue(config, key, value)) {
|
||||
saveConfig(config);
|
||||
const display = key.includes("key") ? redactKey(value) : value;
|
||||
if (isAgentMode()) {
|
||||
formatAgentEnvelope({
|
||||
command: "config set",
|
||||
data: { key, value: display },
|
||||
});
|
||||
} else {
|
||||
printSuccess(`${key} = ${display}`);
|
||||
}
|
||||
} else {
|
||||
printError(`Unknown config key: ${key}`);
|
||||
}
|
||||
const config = loadConfig();
|
||||
if (setNestedValue(config, key, value)) {
|
||||
saveConfig(config);
|
||||
const display = key.includes("key") ? redactKey(value) : value;
|
||||
printSuccess(`${key} = ${display}`);
|
||||
} else {
|
||||
printError(`Unknown config key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
+109
-138
@@ -4,165 +4,136 @@
|
||||
|
||||
import readline from "node:readline";
|
||||
import Table from "cli-table3";
|
||||
import { printError, printInfo, printSuccess, timedStatus, colors } from "../branding.js";
|
||||
import type { Backend } from "../backend/base.js";
|
||||
import {
|
||||
colors,
|
||||
printError,
|
||||
printInfo,
|
||||
printSuccess,
|
||||
timedStatus,
|
||||
} from "../branding.js";
|
||||
import { formatAgentEnvelope, formatJson } from "../output.js";
|
||||
import { setCurrentCommand } from "../state.js";
|
||||
import { formatJson } from "../output.js";
|
||||
|
||||
const { brand, accent, dim } = colors;
|
||||
|
||||
const VALID_TYPES = new Set(["users", "agents", "apps", "runs"]);
|
||||
|
||||
export async function cmdEntitiesList(
|
||||
backend: Backend,
|
||||
entityType: string,
|
||||
opts: { output: string },
|
||||
backend: Backend,
|
||||
entityType: string,
|
||||
opts: { output: string },
|
||||
): Promise<void> {
|
||||
setCurrentCommand("entity list");
|
||||
if (!VALID_TYPES.has(entityType)) {
|
||||
printError(
|
||||
`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!VALID_TYPES.has(entityType)) {
|
||||
printError(`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let results: Record<string, unknown>[];
|
||||
try {
|
||||
results = await timedStatus(`Fetching ${entityType}...`, async () => {
|
||||
return backend.entities(entityType);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"This feature may require the mem0 Platform.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
const start = performance.now();
|
||||
let results: Record<string, unknown>[];
|
||||
try {
|
||||
results = await timedStatus(`Fetching ${entityType}...`, async () => {
|
||||
return backend.entities(entityType);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"This feature may require the mem0 Platform.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "agent" || opts.output === "json") {
|
||||
formatAgentEnvelope({
|
||||
command: "entity list",
|
||||
data: results,
|
||||
count: results.length,
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (opts.output === "json") {
|
||||
formatJson(results);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
printInfo(`No ${entityType} found.`);
|
||||
return;
|
||||
}
|
||||
if (!results.length) {
|
||||
printInfo(`No ${entityType} found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [accent("Name / ID"), accent("Created")],
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
const table = new Table({
|
||||
head: [accent("Name / ID"), accent("Created")],
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
for (const entity of results) {
|
||||
const name = String(entity.name ?? entity.id ?? "—");
|
||||
const created = String(entity.created_at ?? "—").slice(0, 10);
|
||||
table.push([name, created]);
|
||||
}
|
||||
for (const entity of results) {
|
||||
const name = String(entity.name ?? entity.id ?? "—");
|
||||
const created = String(entity.created_at ?? "—").slice(0, 10);
|
||||
table.push([name, created]);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log(
|
||||
` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`,
|
||||
);
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log(` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function cmdEntitiesDelete(
|
||||
backend: Backend,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
dryRun?: boolean;
|
||||
force: boolean;
|
||||
output: string;
|
||||
},
|
||||
backend: Backend,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
dryRun?: boolean;
|
||||
force: boolean;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
setCurrentCommand("entity delete");
|
||||
const { isAgentMode } = await import("../state.js");
|
||||
if (isAgentMode() && !opts.force) {
|
||||
printError("Destructive operation requires --force in agent mode.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) {
|
||||
printError(
|
||||
"Provide at least one of --user-id, --agent-id, --app-id, --run-id.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) {
|
||||
printError("Provide at least one of --user-id, --agent-id, --app-id, --run-id.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scopeParts: string[] = [];
|
||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
||||
const scope = scopeParts.join(", ");
|
||||
if (opts.dryRun) {
|
||||
const scopeParts: string[] = [];
|
||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
||||
printInfo(`Would delete entity ${scopeParts.join(", ")} and all its memories.`);
|
||||
printInfo("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
printInfo(`Would delete entity ${scope} and all its memories.`);
|
||||
printInfo("No changes made.");
|
||||
return;
|
||||
}
|
||||
if (!opts.force) {
|
||||
const scopeParts: string[] = [];
|
||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
||||
const scope = scopeParts.join(", ");
|
||||
|
||||
if (!opts.force) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
printInfo("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
printInfo("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Deleting entity...", async () => {
|
||||
return backend.deleteEntities({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Deleting entity...", async () => {
|
||||
return backend.deleteEntities({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "agent") {
|
||||
formatAgentEnvelope({
|
||||
command: "entity delete",
|
||||
data: { deleted: true },
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
} else if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Event commands: list and status.
|
||||
*/
|
||||
|
||||
import boxen from "boxen";
|
||||
import Table from "cli-table3";
|
||||
import type { Backend } from "../backend/base.js";
|
||||
import { colors, printError, printInfo, timedStatus } from "../branding.js";
|
||||
import { formatAgentEnvelope, formatJson } from "../output.js";
|
||||
import { setCurrentCommand } from "../state.js";
|
||||
|
||||
const { brand, accent, success, error: errorColor, warning, dim } = colors;
|
||||
|
||||
function statusStyled(status: string): string {
|
||||
switch (status.toUpperCase()) {
|
||||
case "SUCCEEDED":
|
||||
return success("SUCCEEDED");
|
||||
case "PENDING":
|
||||
return accent("PENDING");
|
||||
case "FAILED":
|
||||
return errorColor("FAILED");
|
||||
case "PROCESSING":
|
||||
return warning("PROCESSING");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdEventList(
|
||||
backend: Backend,
|
||||
opts: { output: string },
|
||||
): Promise<void> {
|
||||
setCurrentCommand("event list");
|
||||
const start = performance.now();
|
||||
let results: Record<string, unknown>[];
|
||||
try {
|
||||
results = await timedStatus("Fetching events...", async () => {
|
||||
return backend.listEvents();
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "agent" || opts.output === "json") {
|
||||
formatAgentEnvelope({
|
||||
command: "event list",
|
||||
data: results,
|
||||
count: results.length,
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log();
|
||||
printInfo("No events found.");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [
|
||||
accent("Event ID"),
|
||||
accent("Type"),
|
||||
accent("Status"),
|
||||
accent("Latency"),
|
||||
accent("Created"),
|
||||
],
|
||||
colWidths: [12, 14, 14, 10, 22],
|
||||
wordWrap: true,
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
for (const ev of results) {
|
||||
const evId = String(ev.id ?? "").slice(0, 8);
|
||||
const evType = String(ev.event_type ?? "—");
|
||||
const status = String(ev.status ?? "—");
|
||||
const latency = ev.latency as number | undefined;
|
||||
const latencyStr = latency !== undefined ? `${Math.round(latency)}ms` : "—";
|
||||
const created = String(ev.created_at ?? "—")
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
table.push([dim(evId), evType, statusStyled(status), latencyStr, created]);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log(
|
||||
` ${dim(`${results.length} event${results.length !== 1 ? "s" : ""}`)}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function cmdEventStatus(
|
||||
backend: Backend,
|
||||
eventId: string,
|
||||
opts: { output: string },
|
||||
): Promise<void> {
|
||||
setCurrentCommand("event status");
|
||||
const start = performance.now();
|
||||
let ev: Record<string, unknown>;
|
||||
try {
|
||||
ev = await timedStatus("Fetching event...", async () => {
|
||||
return backend.getEvent(eventId);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "agent" || opts.output === "json") {
|
||||
formatAgentEnvelope({
|
||||
command: "event status",
|
||||
data: ev,
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const status = String(ev.status ?? "—");
|
||||
const evType = String(ev.event_type ?? "—");
|
||||
const latency = ev.latency as number | undefined;
|
||||
const latencyStr = latency !== undefined ? `${Math.round(latency)}ms` : "—";
|
||||
const created = String(ev.created_at ?? "—")
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
const updated = String(ev.updated_at ?? "—")
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
const results = ev.results as Record<string, unknown>[] | undefined;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${dim("Event ID:")} ${eventId}`);
|
||||
lines.push(` ${dim("Type:")} ${evType}`);
|
||||
lines.push(` ${dim("Status:")} ${statusStyled(status)}`);
|
||||
lines.push(` ${dim("Latency:")} ${latencyStr}`);
|
||||
lines.push(` ${dim("Created:")} ${created}`);
|
||||
lines.push(` ${dim("Updated:")} ${updated}`);
|
||||
|
||||
if (results && results.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(` ${dim(`Results (${results.length}):`)}`);
|
||||
for (const r of results) {
|
||||
const memId = String(r.id ?? "").slice(0, 8);
|
||||
const data = r.data as Record<string, unknown> | undefined;
|
||||
const memory = data?.memory ? String(data.memory) : "";
|
||||
const evName = String(r.event ?? "");
|
||||
const user = String(r.user_id ?? "");
|
||||
let detail = `${evName} ${memory}`;
|
||||
if (user) detail += ` ${dim(`(user_id=${user})`)}`;
|
||||
lines.push(` ${success("·")} ${detail} ${dim(`(${memId})`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Event Status"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
+142
-388
@@ -2,427 +2,181 @@
|
||||
* mem0 init — interactive setup wizard.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import readline from "node:readline";
|
||||
import { PlatformBackend } from "../backend/platform.js";
|
||||
import {
|
||||
colors,
|
||||
printBanner,
|
||||
printError,
|
||||
printInfo,
|
||||
printSuccess,
|
||||
printBanner,
|
||||
printError,
|
||||
printInfo,
|
||||
printSuccess,
|
||||
colors,
|
||||
} from "../branding.js";
|
||||
import {
|
||||
CONFIG_FILE,
|
||||
DEFAULT_BASE_URL,
|
||||
type Mem0Config,
|
||||
createDefaultConfig,
|
||||
loadConfig,
|
||||
redactKey,
|
||||
saveConfig,
|
||||
} from "../config.js";
|
||||
import { type Mem0Config, createDefaultConfig, saveConfig } from "../config.js";
|
||||
import { PlatformBackend } from "../backend/platform.js";
|
||||
|
||||
const { brand, dim } = colors;
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
function validateEmail(email: string): void {
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
printError(`Invalid email address: ${JSON.stringify(email)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailLogin(
|
||||
email: string,
|
||||
code: string | undefined,
|
||||
baseUrl: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = baseUrl.replace(/\/+$/, "");
|
||||
let codeValue = code;
|
||||
|
||||
if (!codeValue) {
|
||||
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (resp.status === 429) {
|
||||
printError("Too many attempts. Try again in a few minutes.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail: string;
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
detail = (body.error ?? body.detail ?? resp.statusText) as string;
|
||||
} catch {
|
||||
detail = resp.statusText;
|
||||
}
|
||||
printError(`Failed to send code: ${detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
printSuccess("Verification code sent! Check your email.");
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
printError(
|
||||
"No --code provided and terminal is non-interactive.",
|
||||
"Run: mem0 init --email <email> --code <code>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log();
|
||||
const entered = await promptLine(` ${brand("Verification Code")}`);
|
||||
if (!entered) {
|
||||
printError("Code is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
codeValue = entered;
|
||||
}
|
||||
|
||||
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code: codeValue.trim() }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (verifyResp.status === 429) {
|
||||
printError("Too many attempts. Try again in a few minutes.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!verifyResp.ok) {
|
||||
let detail: string;
|
||||
try {
|
||||
const body = (await verifyResp.json()) as Record<string, unknown>;
|
||||
detail = (body.error ?? body.detail ?? verifyResp.statusText) as string;
|
||||
} catch {
|
||||
detail = verifyResp.statusText;
|
||||
}
|
||||
printError(`Verification failed: ${detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return verifyResp.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function promptSecret(label: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(label);
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(label);
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf-8");
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf-8");
|
||||
|
||||
const chars: string[] = [];
|
||||
const chars: string[] = [];
|
||||
|
||||
const onData = (key: string) => {
|
||||
for (const ch of key) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
cleanup();
|
||||
process.stdout.write("\n");
|
||||
resolve(chars.join(""));
|
||||
return;
|
||||
}
|
||||
if (ch === "\x03") {
|
||||
cleanup();
|
||||
reject(new Error("Interrupted"));
|
||||
return;
|
||||
}
|
||||
if (ch === "\x7f" || ch === "\x08") {
|
||||
// backspace
|
||||
if (chars.length > 0) {
|
||||
chars.pop();
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U — clear line
|
||||
process.stdout.write("\b \b".repeat(chars.length));
|
||||
chars.length = 0;
|
||||
} else if (ch >= " ") {
|
||||
chars.push(ch);
|
||||
process.stdout.write("*");
|
||||
}
|
||||
}
|
||||
};
|
||||
const onData = (key: string) => {
|
||||
for (const ch of key) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
cleanup();
|
||||
process.stdout.write("\n");
|
||||
resolve(chars.join(""));
|
||||
return;
|
||||
}
|
||||
if (ch === "\x03") {
|
||||
cleanup();
|
||||
reject(new Error("Interrupted"));
|
||||
return;
|
||||
}
|
||||
if (ch === "\x7f" || ch === "\x08") {
|
||||
// backspace
|
||||
if (chars.length > 0) {
|
||||
chars.pop();
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U — clear line
|
||||
process.stdout.write("\b \b".repeat(chars.length));
|
||||
chars.length = 0;
|
||||
} else if (ch >= " ") {
|
||||
chars.push(ch);
|
||||
process.stdout.write("*");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.pause();
|
||||
};
|
||||
const cleanup = () => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.pause();
|
||||
};
|
||||
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
function promptLine(label: string, defaultValue?: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim() || defaultValue || "");
|
||||
});
|
||||
});
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim() || defaultValue || "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function setupPlatform(config: Mem0Config): Promise<void> {
|
||||
console.log();
|
||||
console.log(
|
||||
` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`,
|
||||
);
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`);
|
||||
console.log();
|
||||
|
||||
process.stdout.write(` ${brand("API Key")}: `);
|
||||
const apiKey = await promptSecret("");
|
||||
if (!apiKey) {
|
||||
printError("API key is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
config.platform.apiKey = apiKey;
|
||||
process.stdout.write(` ${brand("API Key")}: `);
|
||||
const apiKey = await promptSecret("");
|
||||
if (!apiKey) {
|
||||
printError("API key is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
config.platform.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async function setupDefaults(config: Mem0Config): Promise<void> {
|
||||
console.log();
|
||||
printInfo("Set default entity IDs (press Enter to skip).\n");
|
||||
console.log();
|
||||
printInfo("Set default entity IDs (press Enter to skip).\n");
|
||||
|
||||
const _systemUser = process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
const userId = await promptLine(
|
||||
` ${brand("Default User ID")} ${dim("(recommended)")}`,
|
||||
_systemUser,
|
||||
);
|
||||
if (userId) config.defaults.userId = userId;
|
||||
const userId = await promptLine(` ${brand("Default User ID")} ${dim("(recommended)")}`, "mem0-cli");
|
||||
if (userId) config.defaults.userId = userId;
|
||||
}
|
||||
|
||||
async function validatePlatform(config: Mem0Config): Promise<void> {
|
||||
console.log();
|
||||
printInfo("Validating connection...");
|
||||
try {
|
||||
const backend = new PlatformBackend(config.platform);
|
||||
const status = await backend.status({
|
||||
userId: config.defaults.userId || undefined,
|
||||
agentId: config.defaults.agentId || undefined,
|
||||
});
|
||||
if (status.connected) {
|
||||
printSuccess("Connected to mem0 Platform!");
|
||||
} else {
|
||||
printError(
|
||||
`Could not connect: ${status.error ?? "Unknown error"}`,
|
||||
"Visit https://app.mem0.ai/dashboard/api-keys to get a new key, or run mem0 init again.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
printError(`Connection test failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
console.log();
|
||||
printInfo("Validating connection...");
|
||||
try {
|
||||
const backend = new PlatformBackend(config.platform);
|
||||
const status = await backend.status({
|
||||
userId: config.defaults.userId || undefined,
|
||||
agentId: config.defaults.agentId || undefined,
|
||||
});
|
||||
if (status.connected) {
|
||||
printSuccess("Connected to mem0 Platform!");
|
||||
} else {
|
||||
printError(
|
||||
`Could not connect: ${status.error ?? "Unknown error"}`,
|
||||
"Check your API key and try again.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
printError(`Connection test failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInit(
|
||||
opts: {
|
||||
apiKey?: string;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
code?: string;
|
||||
force?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const config = createDefaultConfig();
|
||||
const savedConfig = loadConfig();
|
||||
const baseUrl =
|
||||
process.env.MEM0_BASE_URL ||
|
||||
savedConfig.platform.baseUrl ||
|
||||
DEFAULT_BASE_URL;
|
||||
export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise<void> {
|
||||
const config = createDefaultConfig();
|
||||
|
||||
// Guards
|
||||
if (opts.code && !opts.email) {
|
||||
printError("--code requires --email.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (opts.email && opts.apiKey) {
|
||||
printError("Cannot use both --api-key and --email.");
|
||||
process.exit(1);
|
||||
}
|
||||
// Non-interactive: both flags provided
|
||||
if (opts.apiKey && opts.userId) {
|
||||
config.platform.apiKey = opts.apiKey;
|
||||
config.defaults.userId = opts.userId;
|
||||
await validatePlatform(config);
|
||||
saveConfig(config);
|
||||
printSuccess("Configuration saved to ~/.mem0/config.json");
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if an existing config with an API key would be overwritten
|
||||
if (
|
||||
!opts.force &&
|
||||
fs.existsSync(CONFIG_FILE) &&
|
||||
savedConfig.platform.apiKey
|
||||
) {
|
||||
console.log(
|
||||
`\n ${brand("Existing configuration found")} ${dim(`(API key: ${redactKey(savedConfig.platform.apiKey)})`)}`,
|
||||
);
|
||||
if (process.stdin.isTTY) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
" Overwrite existing config? This cannot be undone. [y/N] ",
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
printInfo("Cancelled. Use --force to skip this check.");
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
printError(
|
||||
"Existing config would be overwritten.",
|
||||
"Use --force to overwrite.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// Non-TTY without full flags: error with usage hint
|
||||
if (!process.stdin.isTTY && (!opts.apiKey || !opts.userId)) {
|
||||
printError(
|
||||
"Non-interactive terminal detected and missing required flags.",
|
||||
"Usage: mem0 init --api-key <key> --user-id <id>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Email login flow ──────────────────────────────────────────────────────
|
||||
if (opts.email) {
|
||||
const email = opts.email.trim().toLowerCase();
|
||||
validateEmail(email);
|
||||
printBanner();
|
||||
console.log();
|
||||
printInfo("Welcome! Let's set up your mem0 CLI.\n");
|
||||
|
||||
printBanner();
|
||||
console.log();
|
||||
printInfo(`Logging in as ${email}...\n`);
|
||||
// Use provided API key or prompt
|
||||
if (opts.apiKey) {
|
||||
config.platform.apiKey = opts.apiKey;
|
||||
} else {
|
||||
await setupPlatform(config);
|
||||
}
|
||||
|
||||
const result = await emailLogin(email, opts.code, baseUrl);
|
||||
// Use provided user ID or prompt
|
||||
if (opts.userId) {
|
||||
config.defaults.userId = opts.userId;
|
||||
} else {
|
||||
await setupDefaults(config);
|
||||
}
|
||||
|
||||
const apiKeyVal = result.api_key as string | undefined;
|
||||
if (!apiKeyVal) {
|
||||
printError(
|
||||
"Auth succeeded but no API key was returned. Contact support.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await validatePlatform(config);
|
||||
|
||||
config.platform.apiKey = apiKeyVal;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.defaults.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
|
||||
saveConfig(config);
|
||||
console.log();
|
||||
printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
|
||||
console.log();
|
||||
console.log(` ${dim("Get started:")}`);
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences"')}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── API key flow ──────────────────────────────────────────────────────────
|
||||
|
||||
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||
if (!process.stdin.isTTY) {
|
||||
if (!opts.apiKey) {
|
||||
printError(
|
||||
"Non-interactive terminal detected and --api-key is required.",
|
||||
"Usage: mem0 init --api-key <key> [--user-id <id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
opts.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
}
|
||||
|
||||
// Non-interactive: both flags provided
|
||||
if (opts.apiKey && opts.userId) {
|
||||
config.platform.apiKey = opts.apiKey;
|
||||
config.defaults.userId = opts.userId;
|
||||
await validatePlatform(config);
|
||||
saveConfig(config);
|
||||
printSuccess("Configuration saved to ~/.mem0/config.json");
|
||||
return;
|
||||
}
|
||||
|
||||
printBanner();
|
||||
console.log();
|
||||
printInfo("Welcome! Let's set up your mem0 CLI.\n");
|
||||
|
||||
// Use provided API key or prompt
|
||||
if (opts.apiKey) {
|
||||
config.platform.apiKey = opts.apiKey;
|
||||
} else {
|
||||
console.log(` ${brand("How would you like to authenticate?")}`);
|
||||
console.log(` ${dim("1.")} Login with email ${dim("(recommended)")}`);
|
||||
console.log(` ${dim("2.")} Enter API key manually`);
|
||||
console.log();
|
||||
|
||||
const choice = await promptLine(` ${brand("Choose")} [1/2]`, "1");
|
||||
|
||||
if (choice === "1") {
|
||||
console.log();
|
||||
const emailAddr = await promptLine(` ${brand("Email")}`);
|
||||
if (!emailAddr) {
|
||||
printError("Email is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const email = emailAddr.trim().toLowerCase();
|
||||
validateEmail(email);
|
||||
printInfo(`Logging in as ${email}...\n`);
|
||||
|
||||
const result = await emailLogin(email, undefined, baseUrl);
|
||||
|
||||
const apiKeyVal = result.api_key as string | undefined;
|
||||
if (!apiKeyVal) {
|
||||
printError(
|
||||
"Auth succeeded but no API key was returned. Contact support.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config.platform.apiKey = apiKeyVal;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.defaults.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
|
||||
saveConfig(config);
|
||||
console.log();
|
||||
printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
|
||||
console.log();
|
||||
console.log(` ${dim("Get started:")}`);
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences"')}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
// choice === "2": fall through to API key prompt
|
||||
await setupPlatform(config);
|
||||
}
|
||||
|
||||
// Use provided user ID or prompt
|
||||
if (opts.userId) {
|
||||
config.defaults.userId = opts.userId;
|
||||
} else {
|
||||
await setupDefaults(config);
|
||||
}
|
||||
|
||||
await validatePlatform(config);
|
||||
|
||||
saveConfig(config);
|
||||
console.log();
|
||||
printSuccess("Configuration saved to ~/.mem0/config.json");
|
||||
console.log();
|
||||
console.log(` ${dim("Get started:")}`);
|
||||
if (config.defaults.userId) {
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences"')}`);
|
||||
} else {
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`);
|
||||
}
|
||||
console.log();
|
||||
saveConfig(config);
|
||||
console.log();
|
||||
printSuccess("Configuration saved to ~/.mem0/config.json");
|
||||
console.log();
|
||||
console.log(` ${dim("Get started:")}`);
|
||||
if (config.defaults.userId) {
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences"')}`);
|
||||
} else {
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
+425
-647
File diff suppressed because it is too large
Load Diff
+109
-125
@@ -3,153 +3,137 @@
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import boxen from "boxen";
|
||||
import { createRequire } from "node:module";
|
||||
import { printError, printSuccess, timedStatus, colors } from "../branding.js";
|
||||
import type { Backend } from "../backend/base.js";
|
||||
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
|
||||
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
|
||||
import { setCurrentCommand } from "../state.js";
|
||||
import { CLI_VERSION } from "../version.js";
|
||||
import { formatJsonEnvelope } from "../output.js";
|
||||
import boxen from "boxen";
|
||||
|
||||
const { brand, dim, success, error: errorColor } = colors;
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const VERSION: string = _require("../../package.json").version;
|
||||
|
||||
export async function cmdStatus(
|
||||
backend: Backend,
|
||||
opts: { userId?: string; agentId?: string; output?: string } = {},
|
||||
backend: Backend,
|
||||
opts: { userId?: string; agentId?: string; output?: string } = {},
|
||||
): Promise<void> {
|
||||
setCurrentCommand("status");
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Checking connection...", async () => {
|
||||
return backend.status({ userId: opts.userId, agentId: opts.agentId });
|
||||
});
|
||||
} catch (e) {
|
||||
result = {
|
||||
connected: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Checking connection...", async () => {
|
||||
return backend.status({ userId: opts.userId, agentId: opts.agentId });
|
||||
});
|
||||
} catch (e) {
|
||||
result = { connected: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "agent" || opts.output === "json") {
|
||||
formatAgentEnvelope({
|
||||
command: "status",
|
||||
data: {
|
||||
connected: result.connected,
|
||||
backend: result.backend ?? null,
|
||||
base_url: result.base_url ?? null,
|
||||
},
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (opts.output === "json") {
|
||||
formatJsonEnvelope({
|
||||
command: "status",
|
||||
data: {
|
||||
connected: result.connected,
|
||||
backend: result.backend ?? null,
|
||||
base_url: result.base_url ?? null,
|
||||
latency_ms: Math.round(elapsed * 1000),
|
||||
},
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (result.connected) {
|
||||
lines.push(` ${success("\u25cf")} Connected`);
|
||||
} else {
|
||||
lines.push(` ${errorColor("\u25cf")} Disconnected`);
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (result.connected) {
|
||||
lines.push(` ${success("\u25cf")} Connected`);
|
||||
} else {
|
||||
lines.push(` ${errorColor("\u25cf")} Disconnected`);
|
||||
}
|
||||
|
||||
lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`);
|
||||
if (result.base_url) {
|
||||
lines.push(` ${dim("API URL:")} ${result.base_url}`);
|
||||
}
|
||||
if (result.error) {
|
||||
lines.push(` ${errorColor("Error:")} ${result.error}`);
|
||||
if (String(result.error).includes("Authentication failed")) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` ${dim("Run")} ${brand("mem0 init")} ${dim("to reconfigure your API key")}`,
|
||||
);
|
||||
lines.push(
|
||||
` ${dim("Get a key at")} ${brand("https://app.mem0.ai/dashboard/api-keys")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`);
|
||||
lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`);
|
||||
if (result.base_url) {
|
||||
lines.push(` ${dim("API URL:")} ${result.base_url}`);
|
||||
}
|
||||
if (result.error) {
|
||||
lines.push(` ${errorColor("Error:")} ${result.error}`);
|
||||
}
|
||||
lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`);
|
||||
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Connection Status"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Connection Status"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function cmdVersion(): void {
|
||||
console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
||||
console.log(` ${brand("◆ Mem0")} CLI v${VERSION}`);
|
||||
}
|
||||
|
||||
export async function cmdImport(
|
||||
backend: Backend,
|
||||
filePath: string,
|
||||
opts: { userId?: string; agentId?: string; output?: string },
|
||||
backend: Backend,
|
||||
filePath: string,
|
||||
opts: { userId?: string; agentId?: string; output?: string },
|
||||
): Promise<void> {
|
||||
setCurrentCommand("import");
|
||||
let data: Record<string, unknown>[];
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
data = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (e) {
|
||||
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
let data: Record<string, unknown>[];
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
data = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (e) {
|
||||
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let failed = 0;
|
||||
const start = performance.now();
|
||||
let added = 0;
|
||||
let failed = 0;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
const content = (item.memory ?? item.text ?? item.content ?? "") as string;
|
||||
if (!content) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
const content = (item.memory ?? item.text ?? item.content ?? "") as string;
|
||||
if (!content) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await backend.add(content, undefined, {
|
||||
userId: opts.userId ?? (item.user_id as string | undefined),
|
||||
agentId: opts.agentId ?? (item.agent_id as string | undefined),
|
||||
metadata: item.metadata as Record<string, unknown> | undefined,
|
||||
});
|
||||
added++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
try {
|
||||
await backend.add(content, undefined, {
|
||||
userId: opts.userId ?? (item.user_id as string | undefined),
|
||||
agentId: opts.agentId ?? (item.agent_id as string | undefined),
|
||||
metadata: item.metadata as Record<string, unknown> | undefined,
|
||||
});
|
||||
added++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Simple progress indicator
|
||||
if ((i + 1) % 10 === 0 || i === data.length - 1) {
|
||||
process.stdout.write(
|
||||
`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Simple progress indicator
|
||||
if ((i + 1) % 10 === 0 || i === data.length - 1) {
|
||||
process.stdout.write(`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
console.log(); // Clear progress line
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
console.log(); // Clear progress line
|
||||
|
||||
if (opts.output === "agent" || opts.output === "json") {
|
||||
formatAgentEnvelope({
|
||||
command: "import",
|
||||
data: {
|
||||
added,
|
||||
failed,
|
||||
},
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (opts.output === "json") {
|
||||
formatJsonEnvelope({
|
||||
command: "import",
|
||||
data: { added, failed, duration_s: parseFloat(elapsed.toFixed(2)) },
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
|
||||
if (failed > 0) {
|
||||
printError(`${failed} memories failed to import.`);
|
||||
}
|
||||
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
|
||||
if (failed > 0) {
|
||||
printError(`${failed} memories failed to import.`);
|
||||
}
|
||||
}
|
||||
|
||||
+99
-115
@@ -18,158 +18,142 @@ export const DEFAULT_BASE_URL = "https://api.mem0.ai";
|
||||
export const CONFIG_VERSION = 1;
|
||||
|
||||
export interface PlatformConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
userId: string;
|
||||
agentId: string;
|
||||
appId: string;
|
||||
runId: string;
|
||||
enableGraph: boolean;
|
||||
userId: string;
|
||||
agentId: string;
|
||||
appId: string;
|
||||
runId: string;
|
||||
enableGraph: boolean;
|
||||
}
|
||||
|
||||
export interface Mem0Config {
|
||||
version: number;
|
||||
defaults: DefaultsConfig;
|
||||
platform: PlatformConfig;
|
||||
version: number;
|
||||
defaults: DefaultsConfig;
|
||||
platform: PlatformConfig;
|
||||
}
|
||||
|
||||
export function createDefaultConfig(): Mem0Config {
|
||||
return {
|
||||
version: CONFIG_VERSION,
|
||||
defaults: {
|
||||
userId: "",
|
||||
agentId: "",
|
||||
appId: "",
|
||||
runId: "",
|
||||
enableGraph: false,
|
||||
},
|
||||
platform: {
|
||||
apiKey: "",
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
},
|
||||
};
|
||||
return {
|
||||
version: CONFIG_VERSION,
|
||||
defaults: {
|
||||
userId: "",
|
||||
agentId: "",
|
||||
appId: "",
|
||||
runId: "",
|
||||
enableGraph: false,
|
||||
},
|
||||
platform: {
|
||||
apiKey: "",
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureConfigDir(): string {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
return CONFIG_DIR;
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
|
||||
export function loadConfig(): Mem0Config {
|
||||
const config = createDefaultConfig();
|
||||
const config = createDefaultConfig();
|
||||
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
config.version = data.version ?? CONFIG_VERSION;
|
||||
config.version = data.version ?? CONFIG_VERSION;
|
||||
|
||||
const plat = data.platform ?? {};
|
||||
config.platform.apiKey = plat.api_key ?? "";
|
||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||
const plat = data.platform ?? {};
|
||||
config.platform.apiKey = plat.api_key ?? "";
|
||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||
|
||||
const defaults = data.defaults ?? {};
|
||||
config.defaults.userId = defaults.user_id ?? "";
|
||||
config.defaults.agentId = defaults.agent_id ?? "";
|
||||
config.defaults.appId = defaults.app_id ?? "";
|
||||
config.defaults.runId = defaults.run_id ?? "";
|
||||
config.defaults.enableGraph = defaults.enable_graph ?? false;
|
||||
}
|
||||
const defaults = data.defaults ?? {};
|
||||
config.defaults.userId = defaults.user_id ?? "";
|
||||
config.defaults.agentId = defaults.agent_id ?? "";
|
||||
config.defaults.appId = defaults.app_id ?? "";
|
||||
config.defaults.runId = defaults.run_id ?? "";
|
||||
config.defaults.enableGraph = defaults.enable_graph ?? false;
|
||||
}
|
||||
|
||||
// Environment variable overrides
|
||||
if (process.env.MEM0_API_KEY)
|
||||
config.platform.apiKey = process.env.MEM0_API_KEY;
|
||||
if (process.env.MEM0_BASE_URL)
|
||||
config.platform.baseUrl = process.env.MEM0_BASE_URL;
|
||||
if (process.env.MEM0_USER_ID)
|
||||
config.defaults.userId = process.env.MEM0_USER_ID;
|
||||
if (process.env.MEM0_AGENT_ID)
|
||||
config.defaults.agentId = process.env.MEM0_AGENT_ID;
|
||||
if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID;
|
||||
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
|
||||
if (process.env.MEM0_ENABLE_GRAPH) {
|
||||
config.defaults.enableGraph = ["true", "1", "yes"].includes(
|
||||
process.env.MEM0_ENABLE_GRAPH.toLowerCase(),
|
||||
);
|
||||
}
|
||||
// Environment variable overrides
|
||||
if (process.env.MEM0_API_KEY) config.platform.apiKey = process.env.MEM0_API_KEY;
|
||||
if (process.env.MEM0_BASE_URL) config.platform.baseUrl = process.env.MEM0_BASE_URL;
|
||||
if (process.env.MEM0_USER_ID) config.defaults.userId = process.env.MEM0_USER_ID;
|
||||
if (process.env.MEM0_AGENT_ID) config.defaults.agentId = process.env.MEM0_AGENT_ID;
|
||||
if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID;
|
||||
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
|
||||
if (process.env.MEM0_ENABLE_GRAPH) {
|
||||
config.defaults.enableGraph = ["true", "1", "yes"].includes(
|
||||
process.env.MEM0_ENABLE_GRAPH.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
return config;
|
||||
}
|
||||
|
||||
export function saveConfig(config: Mem0Config): void {
|
||||
ensureConfigDir();
|
||||
ensureConfigDir();
|
||||
|
||||
const data = {
|
||||
version: config.version,
|
||||
defaults: {
|
||||
user_id: config.defaults.userId,
|
||||
agent_id: config.defaults.agentId,
|
||||
app_id: config.defaults.appId,
|
||||
run_id: config.defaults.runId,
|
||||
enable_graph: config.defaults.enableGraph,
|
||||
},
|
||||
platform: {
|
||||
api_key: config.platform.apiKey,
|
||||
base_url: config.platform.baseUrl,
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
version: config.version,
|
||||
defaults: {
|
||||
user_id: config.defaults.userId,
|
||||
agent_id: config.defaults.agentId,
|
||||
app_id: config.defaults.appId,
|
||||
run_id: config.defaults.runId,
|
||||
enable_graph: config.defaults.enableGraph,
|
||||
},
|
||||
platform: {
|
||||
api_key: config.platform.apiKey,
|
||||
base_url: config.platform.baseUrl,
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
}
|
||||
|
||||
export function redactKey(key: string): string {
|
||||
if (!key) return "(not set)";
|
||||
if (key.length <= 8) return `${key.slice(0, 2)}***`;
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
||||
if (!key) return "(not set)";
|
||||
if (key.length <= 8) return key.slice(0, 2) + "***";
|
||||
return key.slice(0, 4) + "..." + key.slice(-4);
|
||||
}
|
||||
|
||||
/** Key map from dotted config path to the config object fields. */
|
||||
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
||||
"platform.api_key": ["platform", "apiKey"],
|
||||
"platform.base_url": ["platform", "baseUrl"],
|
||||
"defaults.user_id": ["defaults", "userId"],
|
||||
"defaults.agent_id": ["defaults", "agentId"],
|
||||
"defaults.app_id": ["defaults", "appId"],
|
||||
"defaults.run_id": ["defaults", "runId"],
|
||||
"defaults.enable_graph": ["defaults", "enableGraph"],
|
||||
// Short-form aliases
|
||||
api_key: ["platform", "apiKey"],
|
||||
base_url: ["platform", "baseUrl"],
|
||||
user_id: ["defaults", "userId"],
|
||||
agent_id: ["defaults", "agentId"],
|
||||
app_id: ["defaults", "appId"],
|
||||
run_id: ["defaults", "runId"],
|
||||
enable_graph: ["defaults", "enableGraph"],
|
||||
"platform.api_key": ["platform", "apiKey"],
|
||||
"platform.base_url": ["platform", "baseUrl"],
|
||||
"defaults.user_id": ["defaults", "userId"],
|
||||
"defaults.agent_id": ["defaults", "agentId"],
|
||||
"defaults.app_id": ["defaults", "appId"],
|
||||
"defaults.run_id": ["defaults", "runId"],
|
||||
"defaults.enable_graph": ["defaults", "enableGraph"],
|
||||
};
|
||||
|
||||
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
|
||||
const mapping = KEY_MAP[dottedKey];
|
||||
if (!mapping) return undefined;
|
||||
const [section, field] = mapping;
|
||||
return (config[section] as unknown as Record<string, unknown>)[field];
|
||||
const mapping = KEY_MAP[dottedKey];
|
||||
if (!mapping) return undefined;
|
||||
const [section, field] = mapping;
|
||||
return (config[section] as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
export function setNestedValue(
|
||||
config: Mem0Config,
|
||||
dottedKey: string,
|
||||
value: string,
|
||||
): boolean {
|
||||
const mapping = KEY_MAP[dottedKey];
|
||||
if (!mapping) return false;
|
||||
const [section, field] = mapping;
|
||||
const obj = config[section] as unknown as Record<string, unknown>;
|
||||
export function setNestedValue(config: Mem0Config, dottedKey: string, value: string): boolean {
|
||||
const mapping = KEY_MAP[dottedKey];
|
||||
if (!mapping) return false;
|
||||
const [section, field] = mapping;
|
||||
const obj = config[section] as unknown as Record<string, unknown>;
|
||||
|
||||
const current = obj[field];
|
||||
if (typeof current === "boolean") {
|
||||
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
|
||||
} else if (typeof current === "number") {
|
||||
obj[field] = Number.parseInt(value, 10);
|
||||
} else {
|
||||
obj[field] = value;
|
||||
}
|
||||
return true;
|
||||
const current = obj[field];
|
||||
if (typeof current === "boolean") {
|
||||
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
|
||||
} else if (typeof current === "number") {
|
||||
obj[field] = parseInt(value, 10);
|
||||
} else {
|
||||
obj[field] = value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
/** Injected by tsup at build time from package.json version field. Undefined in dev/test. */
|
||||
declare const __CLI_VERSION__: string | undefined;
|
||||
+280
-284
@@ -4,40 +4,40 @@
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import type { Argument, Command, Help, Option } from "commander";
|
||||
import type { Command, Help, Option, Argument } from "commander";
|
||||
// Colors imported from chalk directly to match Typer/Rich defaults
|
||||
|
||||
// ── Colors (matching Typer/Rich defaults) ────────────────────────────────
|
||||
|
||||
const cyanBold = chalk.cyan.bold; // option flags, command names
|
||||
const greenBold = chalk.green.bold; // switch flags (boolean --force etc)
|
||||
const yellowBold = chalk.yellow.bold; // metavar <value>
|
||||
const yellow = chalk.yellow; // "Usage:" label
|
||||
const bold = chalk.bold; // command name in usage
|
||||
const dim = chalk.dim; // defaults, descriptions
|
||||
const dimBorder = chalk.dim; // panel borders
|
||||
const cyanBold = chalk.cyan.bold; // option flags, command names
|
||||
const greenBold = chalk.green.bold; // switch flags (boolean --force etc)
|
||||
const yellowBold = chalk.yellow.bold; // metavar <value>
|
||||
const yellow = chalk.yellow; // "Usage:" label
|
||||
const bold = chalk.bold; // command name in usage
|
||||
const dim = chalk.dim; // defaults, descriptions
|
||||
const dimBorder = chalk.dim; // panel borders
|
||||
|
||||
// ── Strip ANSI ───────────────────────────────────────────────────────────
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence is intentional
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
function stripAnsi(str: string): number {
|
||||
return str.replace(ANSI_RE, "").length;
|
||||
return str.replace(ANSI_RE, "").length;
|
||||
}
|
||||
|
||||
// ── Command display order (matches Python CLI) ──────────────────────────
|
||||
|
||||
/** Commands grouped into panels, matching Python CLI's rich_help_panel. */
|
||||
const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [
|
||||
{
|
||||
panel: "Memory",
|
||||
commands: ["add", "search", "get", "list", "update", "delete"],
|
||||
},
|
||||
{
|
||||
panel: "Management",
|
||||
commands: ["init", "status", "import", "help", "entity", "event", "config"],
|
||||
},
|
||||
{
|
||||
panel: "Memory",
|
||||
commands: ["add", "search", "get", "list", "update", "delete"],
|
||||
},
|
||||
{
|
||||
panel: "Management",
|
||||
commands: ["init", "status", "import", "help", "entity", "config"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat order derived from COMMAND_GROUPS. */
|
||||
@@ -46,88 +46,88 @@ const COMMAND_ORDER: string[] = COMMAND_GROUPS.flatMap((g) => g.commands);
|
||||
// ── Option-to-panel mapping (derived from Python's rich_help_panel) ─────
|
||||
|
||||
const OPTION_PANELS: Record<string, Record<string, string>> = {
|
||||
add: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
search: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--top-k": "Search",
|
||||
"--threshold": "Search",
|
||||
"--rerank": "Search",
|
||||
"--keyword": "Search",
|
||||
"--filter": "Search",
|
||||
"--fields": "Search",
|
||||
"--graph": "Search",
|
||||
"--no-graph": "Search",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
get: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
list: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--page": "Pagination",
|
||||
"--page-size": "Pagination",
|
||||
"--category": "Filters",
|
||||
"--after": "Filters",
|
||||
"--before": "Filters",
|
||||
"--graph": "Filters",
|
||||
"--no-graph": "Filters",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
update: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
delete: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
status: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
import: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
add: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
search: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--top-k": "Search",
|
||||
"--threshold": "Search",
|
||||
"--rerank": "Search",
|
||||
"--keyword": "Search",
|
||||
"--filter": "Search",
|
||||
"--fields": "Search",
|
||||
"--graph": "Search",
|
||||
"--no-graph": "Search",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
get: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
list: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--page": "Pagination",
|
||||
"--page-size": "Pagination",
|
||||
"--category": "Filters",
|
||||
"--after": "Filters",
|
||||
"--before": "Filters",
|
||||
"--graph": "Filters",
|
||||
"--no-graph": "Filters",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
update: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
delete: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
status: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
import: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
};
|
||||
|
||||
const PANEL_ORDER: string[] = [
|
||||
"Scope",
|
||||
"Search",
|
||||
"Pagination",
|
||||
"Filters",
|
||||
"Output",
|
||||
"Connection",
|
||||
"Scope",
|
||||
"Search",
|
||||
"Pagination",
|
||||
"Filters",
|
||||
"Output",
|
||||
"Connection",
|
||||
];
|
||||
|
||||
// ── Panel rendering ─────────────────────────────────────────────────────
|
||||
@@ -141,238 +141,234 @@ const PANEL_ORDER: string[] = [
|
||||
* ╰────────────────────────────────╯
|
||||
* ```
|
||||
*/
|
||||
function renderPanel(title: string, rows: string[], width: number): string {
|
||||
if (rows.length === 0) return "";
|
||||
function renderPanel(
|
||||
title: string,
|
||||
rows: string[],
|
||||
width: number,
|
||||
): string {
|
||||
if (rows.length === 0) return "";
|
||||
|
||||
// Inner width is total width minus the two border chars
|
||||
const inner = width - 2;
|
||||
// Inner width is total width minus the two border chars
|
||||
const inner = width - 2;
|
||||
|
||||
// Top border: ╭─ Title ─...─╮
|
||||
const titleStr = ` ${title} `;
|
||||
const fillLen = Math.max(0, inner - 1 - titleStr.length);
|
||||
const topLine =
|
||||
dimBorder("╭─") +
|
||||
dimBorder(titleStr) +
|
||||
dimBorder("─".repeat(fillLen)) +
|
||||
dimBorder("╮");
|
||||
// Top border: ╭─ Title ─...─╮
|
||||
const titleStr = ` ${title} `;
|
||||
const fillLen = Math.max(0, inner - 1 - titleStr.length);
|
||||
const topLine =
|
||||
dimBorder("╭─") +
|
||||
dimBorder(titleStr) +
|
||||
dimBorder("─".repeat(fillLen)) +
|
||||
dimBorder("╮");
|
||||
|
||||
// Bottom border: ╰─...─╯
|
||||
const bottomLine =
|
||||
dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
|
||||
// Bottom border: ╰─...─╯
|
||||
const bottomLine = dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
|
||||
|
||||
// Content rows
|
||||
const contentLines = rows.map((row) => {
|
||||
const visLen = stripAnsi(row);
|
||||
const pad = Math.max(0, inner - 1 - visLen);
|
||||
return `${dimBorder("│")} ${row}${" ".repeat(pad)}${dimBorder("│")}`;
|
||||
});
|
||||
// Content rows
|
||||
const contentLines = rows.map((row) => {
|
||||
const visLen = stripAnsi(row);
|
||||
const pad = Math.max(0, inner - 1 - visLen);
|
||||
return dimBorder("│") + " " + row + " ".repeat(pad) + dimBorder("│");
|
||||
});
|
||||
|
||||
return [topLine, ...contentLines, bottomLine].join("\n");
|
||||
return [topLine, ...contentLines, bottomLine].join("\n");
|
||||
}
|
||||
|
||||
// ── Format an option term (short + long) ────────────────────────────────
|
||||
|
||||
function formatOptionTerm(opt: Option): string {
|
||||
const parts: string[] = [];
|
||||
if (opt.short) parts.push(opt.short);
|
||||
if (opt.long) parts.push(opt.long);
|
||||
let term = parts.join(", ");
|
||||
const parts: string[] = [];
|
||||
if (opt.short) parts.push(opt.short);
|
||||
if (opt.long) parts.push(opt.long);
|
||||
let term = parts.join(", ");
|
||||
|
||||
// Append value placeholder for non-boolean options
|
||||
if (opt.flags) {
|
||||
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
|
||||
if (match) {
|
||||
term += ` ${match[0]}`;
|
||||
}
|
||||
}
|
||||
return term;
|
||||
// Append value placeholder for non-boolean options
|
||||
if (opt.flags) {
|
||||
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
|
||||
if (match) {
|
||||
term += " " + match[0];
|
||||
}
|
||||
}
|
||||
return term;
|
||||
}
|
||||
|
||||
// ── Get the long flag name for panel lookup ─────────────────────────────
|
||||
|
||||
function getLongFlag(opt: Option): string {
|
||||
if (opt.long) return opt.long;
|
||||
return opt.short || "";
|
||||
if (opt.long) return opt.long;
|
||||
return opt.short || "";
|
||||
}
|
||||
|
||||
// ── Format a default value ──────────────────────────────────────────────
|
||||
|
||||
function formatDefault(opt: Option): string {
|
||||
if (opt.defaultValue !== undefined && opt.defaultValue !== false) {
|
||||
return dim(` [default: ${opt.defaultValue}]`);
|
||||
}
|
||||
return "";
|
||||
if (opt.defaultValue !== undefined && opt.defaultValue !== false) {
|
||||
return dim(` [default: ${opt.defaultValue}]`);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── The main help formatter ─────────────────────────────────────────────
|
||||
|
||||
export function richFormatHelp(cmd: Command, helper: Help): string {
|
||||
const width = process.stdout.columns || 80;
|
||||
const lines: string[] = [];
|
||||
const width = process.stdout.columns || 80;
|
||||
const lines: string[] = [];
|
||||
|
||||
const isRoot = !cmd.parent;
|
||||
const isRoot = !cmd.parent;
|
||||
|
||||
// ── Usage line ──
|
||||
const usage = helper.commandUsage(cmd);
|
||||
lines.push("");
|
||||
if (isRoot) {
|
||||
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
|
||||
lines.push(
|
||||
` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`,
|
||||
);
|
||||
} else {
|
||||
// Subcommands: split into command path (bold) and args (yellow)
|
||||
const usageParts = usage.split(" ");
|
||||
const cmdPath: string[] = [];
|
||||
const argParts: string[] = [];
|
||||
let pastCmd = false;
|
||||
for (const part of usageParts) {
|
||||
if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) {
|
||||
cmdPath.push(part);
|
||||
} else {
|
||||
pastCmd = true;
|
||||
argParts.push(part);
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
// ── Usage line ──
|
||||
const usage = helper.commandUsage(cmd);
|
||||
lines.push("");
|
||||
if (isRoot) {
|
||||
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
|
||||
lines.push(` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`);
|
||||
} else {
|
||||
// Subcommands: split into command path (bold) and args (yellow)
|
||||
const usageParts = usage.split(" ");
|
||||
const cmdPath: string[] = [];
|
||||
const argParts: string[] = [];
|
||||
let pastCmd = false;
|
||||
for (const part of usageParts) {
|
||||
if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) {
|
||||
cmdPath.push(part);
|
||||
} else {
|
||||
pastCmd = true;
|
||||
argParts.push(part);
|
||||
}
|
||||
}
|
||||
lines.push(` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// ── Description ──
|
||||
const desc = helper.commandDescription(cmd);
|
||||
if (desc) {
|
||||
// Split multi-line descriptions (e.g., title + tagline)
|
||||
const descLines = desc.split("\n");
|
||||
for (let i = 0; i < descLines.length; i++) {
|
||||
const dLine = descLines[i];
|
||||
// First line is the title, subsequent non-empty lines are tagline (dimmed)
|
||||
if (i === 0 || dLine.trim() === "") {
|
||||
lines.push(` ${dLine}`);
|
||||
} else {
|
||||
lines.push(` ${dim(dLine)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
// ── Description ──
|
||||
const desc = helper.commandDescription(cmd);
|
||||
if (desc) {
|
||||
// Split multi-line descriptions (e.g., title + tagline)
|
||||
const descLines = desc.split("\n");
|
||||
for (let i = 0; i < descLines.length; i++) {
|
||||
const dLine = descLines[i];
|
||||
// First line is the title, subsequent non-empty lines are tagline (dimmed)
|
||||
if (i === 0 || dLine.trim() === "") {
|
||||
lines.push(` ${dLine}`);
|
||||
} else {
|
||||
lines.push(` ${dim(dLine)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Arguments panel (subcommands only) ──
|
||||
if (!isRoot) {
|
||||
const visibleArgs = helper.visibleArguments(cmd);
|
||||
if (visibleArgs.length > 0) {
|
||||
const maxLen = Math.max(
|
||||
...visibleArgs.map((a: Argument) => a.name().length),
|
||||
);
|
||||
const argRows = visibleArgs.map((a: Argument) => {
|
||||
const name = cyanBold(a.name().padEnd(maxLen));
|
||||
const description = helper.argumentDescription(a);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel("Arguments", argRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
// ── Arguments panel (subcommands only) ──
|
||||
if (!isRoot) {
|
||||
const visibleArgs = helper.visibleArguments(cmd);
|
||||
if (visibleArgs.length > 0) {
|
||||
const maxLen = Math.max(...visibleArgs.map((a: Argument) => a.name().length));
|
||||
const argRows = visibleArgs.map((a: Argument) => {
|
||||
const name = cyanBold(a.name().padEnd(maxLen));
|
||||
const description = helper.argumentDescription(a);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel("Arguments", argRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect options (grouped into panels for subcommands) ──
|
||||
const visibleOpts = helper.visibleOptions(cmd);
|
||||
const cmdName = cmd.name();
|
||||
const panelMap =
|
||||
!isRoot && OPTION_PANELS[cmdName] ? OPTION_PANELS[cmdName] : {};
|
||||
// ── Collect options (grouped into panels for subcommands) ──
|
||||
const visibleOpts = helper.visibleOptions(cmd);
|
||||
const cmdName = cmd.name();
|
||||
const panelMap = (!isRoot && OPTION_PANELS[cmdName]) ? OPTION_PANELS[cmdName] : {};
|
||||
|
||||
const grouped: Record<string, Option[]> = { Options: [] };
|
||||
for (const panelName of PANEL_ORDER) {
|
||||
grouped[panelName] = [];
|
||||
}
|
||||
const grouped: Record<string, Option[]> = { Options: [] };
|
||||
for (const panelName of PANEL_ORDER) {
|
||||
grouped[panelName] = [];
|
||||
}
|
||||
|
||||
for (const opt of visibleOpts) {
|
||||
const flag = getLongFlag(opt);
|
||||
const panel = panelMap[flag];
|
||||
if (panel && PANEL_ORDER.includes(panel)) {
|
||||
grouped[panel].push(opt);
|
||||
} else {
|
||||
grouped.Options.push(opt);
|
||||
}
|
||||
}
|
||||
for (const opt of visibleOpts) {
|
||||
const flag = getLongFlag(opt);
|
||||
const panel = panelMap[flag];
|
||||
if (panel && PANEL_ORDER.includes(panel)) {
|
||||
grouped[panel].push(opt);
|
||||
} else {
|
||||
grouped["Options"].push(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect commands ──
|
||||
const visibleCmds = helper.visibleCommands(cmd);
|
||||
// ── Collect commands ──
|
||||
const visibleCmds = helper.visibleCommands(cmd);
|
||||
|
||||
if (isRoot) {
|
||||
// ROOT: Options first, then command groups (matches Python/Typer ordering)
|
||||
if (grouped.Options.length > 0) {
|
||||
const optRows = formatOptionRows(grouped.Options);
|
||||
const panel = renderPanel("Options", optRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
if (visibleCmds.length > 0) {
|
||||
const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c]));
|
||||
for (const group of COMMAND_GROUPS) {
|
||||
const groupCmds = group.commands
|
||||
.map((name) => cmdMap.get(name))
|
||||
.filter((c): c is Command => c !== undefined);
|
||||
if (groupCmds.length === 0) continue;
|
||||
const maxLen = Math.max(...groupCmds.map((c) => c.name().length));
|
||||
const cmdRows = groupCmds.map((c) => {
|
||||
const name = cyanBold(c.name().padEnd(maxLen));
|
||||
const description = helper.subcommandDescription(c);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel(group.panel, cmdRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// SUBCOMMANDS: Options/panels first, then sub-subcommands
|
||||
const panelSequence = ["Options", ...PANEL_ORDER];
|
||||
for (const panelName of panelSequence) {
|
||||
const opts = grouped[panelName];
|
||||
if (opts && opts.length > 0) {
|
||||
const optRows = formatOptionRows(opts);
|
||||
const panel = renderPanel(panelName, optRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
// Sub-subcommands (e.g., config show/get/set, entity list/delete)
|
||||
if (visibleCmds.length > 0) {
|
||||
const maxLen = Math.max(...visibleCmds.map((c) => c.name().length));
|
||||
const cmdRows = visibleCmds.map((c) => {
|
||||
const name = cyanBold(c.name().padEnd(maxLen));
|
||||
const description = helper.subcommandDescription(c);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel("Commands", cmdRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
if (isRoot) {
|
||||
// ROOT: Options first, then command groups (matches Python/Typer ordering)
|
||||
if (grouped["Options"].length > 0) {
|
||||
const optRows = formatOptionRows(grouped["Options"]);
|
||||
const panel = renderPanel("Options", optRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
if (visibleCmds.length > 0) {
|
||||
const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c]));
|
||||
for (const group of COMMAND_GROUPS) {
|
||||
const groupCmds = group.commands
|
||||
.map((name) => cmdMap.get(name))
|
||||
.filter((c): c is Command => c !== undefined);
|
||||
if (groupCmds.length === 0) continue;
|
||||
const maxLen = Math.max(...groupCmds.map((c) => c.name().length));
|
||||
const cmdRows = groupCmds.map((c) => {
|
||||
const name = cyanBold(c.name().padEnd(maxLen));
|
||||
const description = helper.subcommandDescription(c);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel(group.panel, cmdRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// SUBCOMMANDS: Options/panels first, then sub-subcommands
|
||||
const panelSequence = ["Options", ...PANEL_ORDER];
|
||||
for (const panelName of panelSequence) {
|
||||
const opts = grouped[panelName];
|
||||
if (opts && opts.length > 0) {
|
||||
const optRows = formatOptionRows(opts);
|
||||
const panel = renderPanel(panelName, optRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
// Sub-subcommands (e.g., config show/get/set, entity list/delete)
|
||||
if (visibleCmds.length > 0) {
|
||||
const maxLen = Math.max(...visibleCmds.map((c) => c.name().length));
|
||||
const cmdRows = visibleCmds.map((c) => {
|
||||
const name = cyanBold(c.name().padEnd(maxLen));
|
||||
const description = helper.subcommandDescription(c);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel("Commands", cmdRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Format option rows with aligned columns ─────────────────────────────
|
||||
|
||||
function formatOptionRows(opts: Option[]): string[] {
|
||||
const terms = opts.map((o) => formatOptionTerm(o));
|
||||
const maxTermLen = Math.max(...terms.map((t) => t.length));
|
||||
const terms = opts.map((o) => formatOptionTerm(o));
|
||||
const maxTermLen = Math.max(...terms.map((t) => t.length));
|
||||
|
||||
return opts.map((opt, i) => {
|
||||
const term = cyanBold(terms[i].padEnd(maxTermLen));
|
||||
const desc = opt.description || "";
|
||||
const def = formatDefault(opt);
|
||||
return ` ${term} ${desc}${def}`;
|
||||
});
|
||||
return opts.map((opt, i) => {
|
||||
const term = cyanBold(terms[i].padEnd(maxTermLen));
|
||||
const desc = opt.description || "";
|
||||
const def = formatDefault(opt);
|
||||
return ` ${term} ${desc}${def}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort commands by COMMAND_ORDER ──────────────────────────────────────
|
||||
|
||||
function sortCommands(cmds: Command[]): Command[] {
|
||||
return [...cmds].sort((a, b) => {
|
||||
const ai = COMMAND_ORDER.indexOf(a.name());
|
||||
const bi = COMMAND_ORDER.indexOf(b.name());
|
||||
// Unknown commands go to end, preserving original order
|
||||
const aIdx = ai === -1 ? COMMAND_ORDER.length : ai;
|
||||
const bIdx = bi === -1 ? COMMAND_ORDER.length : bi;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
return [...cmds].sort((a, b) => {
|
||||
const ai = COMMAND_ORDER.indexOf(a.name());
|
||||
const bi = COMMAND_ORDER.indexOf(b.name());
|
||||
// Unknown commands go to end, preserving original order
|
||||
const aIdx = ai === -1 ? COMMAND_ORDER.length : ai;
|
||||
const bIdx = bi === -1 ? COMMAND_ORDER.length : bi;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
}
|
||||
|
||||
+389
-599
File diff suppressed because it is too large
Load Diff
+177
-328
@@ -2,380 +2,229 @@
|
||||
* Output formatting for mem0 CLI — text, JSON, table, quiet modes.
|
||||
*/
|
||||
|
||||
import boxen from "boxen";
|
||||
import Table from "cli-table3";
|
||||
import boxen from "boxen";
|
||||
import { colors, sym } from "./branding.js";
|
||||
|
||||
const { brand, accent, success, error: errorColor, dim } = colors;
|
||||
|
||||
function formatDate(dtStr?: string): string | undefined {
|
||||
if (!dtStr) return undefined;
|
||||
try {
|
||||
const dt = new Date(dtStr.replace("Z", "+00:00"));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return dtStr?.slice(0, 10);
|
||||
}
|
||||
if (!dtStr) return undefined;
|
||||
try {
|
||||
const dt = new Date(dtStr.replace("Z", "+00:00"));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return dtStr?.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMemoriesText(
|
||||
memories: Record<string, unknown>[],
|
||||
title = "memories",
|
||||
memories: Record<string, unknown>[],
|
||||
title = "memories",
|
||||
): void {
|
||||
const count = memories.length;
|
||||
console.log(`\n${brand(`Found ${count} ${title}:`)}\n`);
|
||||
const count = memories.length;
|
||||
console.log(`\n${brand(`Found ${count} ${title}:`)}\n`);
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const mem = memories[i];
|
||||
const memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
const memId = ((mem.id as string) ?? "").slice(0, 8);
|
||||
const score = mem.score as number | undefined;
|
||||
const created = formatDate(mem.created_at as string | undefined);
|
||||
let category: string | undefined;
|
||||
const cats = mem.categories;
|
||||
if (Array.isArray(cats)) {
|
||||
category = cats[0] as string | undefined;
|
||||
}
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const mem = memories[i];
|
||||
const memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
const memId = ((mem.id as string) ?? "").slice(0, 8);
|
||||
const score = mem.score as number | undefined;
|
||||
const created = formatDate(mem.created_at as string | undefined);
|
||||
let category: string | undefined;
|
||||
const cats = mem.categories;
|
||||
if (Array.isArray(cats)) {
|
||||
category = cats[0] as string | undefined;
|
||||
}
|
||||
|
||||
console.log(` ${i + 1}. ${memoryText}`);
|
||||
console.log(` ${i + 1}. ${memoryText}`);
|
||||
|
||||
const details: string[] = [];
|
||||
if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`);
|
||||
if (memId) details.push(`ID: ${memId}`);
|
||||
if (created) details.push(`Created: ${created}`);
|
||||
if (category) details.push(`Category: ${category}`);
|
||||
const details: string[] = [];
|
||||
if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`);
|
||||
if (memId) details.push(`ID: ${memId}`);
|
||||
if (created) details.push(`Created: ${created}`);
|
||||
if (category) details.push(`Category: ${category}`);
|
||||
|
||||
if (details.length > 0) {
|
||||
console.log(` ${dim(details.join(" · "))}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
if (details.length > 0) {
|
||||
console.log(` ${dim(details.join(" · "))}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMemoriesTable(
|
||||
memories: Record<string, unknown>[],
|
||||
opts: { showScore?: boolean } = {},
|
||||
): void {
|
||||
const head = opts.showScore
|
||||
? [
|
||||
accent("ID"),
|
||||
accent("Score"),
|
||||
accent("Memory"),
|
||||
accent("Category"),
|
||||
accent("Created"),
|
||||
]
|
||||
: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")];
|
||||
const colWidths = opts.showScore ? [38, 8, 40, 16, 14] : [38, 40, 16, 14];
|
||||
const table = new Table({
|
||||
head,
|
||||
colWidths,
|
||||
wordWrap: true,
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
export function formatMemoriesTable(memories: Record<string, unknown>[]): void {
|
||||
const table = new Table({
|
||||
head: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")],
|
||||
colWidths: [12, 52, 16, 14],
|
||||
wordWrap: true,
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
for (const mem of memories) {
|
||||
const memId = (mem.id as string) ?? "";
|
||||
let memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
if (memoryText.length > 60) {
|
||||
memoryText = `${memoryText.slice(0, 57)}...`;
|
||||
}
|
||||
const categories = mem.categories;
|
||||
const cat =
|
||||
Array.isArray(categories) && categories.length > 0
|
||||
? categories.length > 1
|
||||
? `${categories[0]} (+${categories.length - 1})`
|
||||
: (categories[0] as string)
|
||||
: "—";
|
||||
const created = formatDate(mem.created_at as string | undefined) ?? "—";
|
||||
if (opts.showScore) {
|
||||
const score = mem.score as number | undefined;
|
||||
const scoreStr = score !== undefined ? score.toFixed(2) : "—";
|
||||
table.push([dim(memId), scoreStr, memoryText, cat, created]);
|
||||
} else {
|
||||
table.push([dim(memId), memoryText, cat, created]);
|
||||
}
|
||||
}
|
||||
for (const mem of memories) {
|
||||
const memId = ((mem.id as string) ?? "").slice(0, 8);
|
||||
let memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
if (memoryText.length > 60) {
|
||||
memoryText = memoryText.slice(0, 57) + "...";
|
||||
}
|
||||
const categories = mem.categories;
|
||||
const cat =
|
||||
Array.isArray(categories) && categories.length > 0
|
||||
? (categories[0] as string)
|
||||
: "—";
|
||||
const created = formatDate(mem.created_at as string | undefined) ?? "—";
|
||||
table.push([dim(memId), memoryText, cat, created]);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function formatJson(data: unknown): void {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function formatSingleMemory(
|
||||
mem: Record<string, unknown>,
|
||||
output = "text",
|
||||
mem: Record<string, unknown>,
|
||||
output = "text",
|
||||
): void {
|
||||
if (output === "json") {
|
||||
formatJson(mem);
|
||||
return;
|
||||
}
|
||||
if (output === "json") {
|
||||
formatJson(mem);
|
||||
return;
|
||||
}
|
||||
|
||||
const memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
const memId = (mem.id ?? "") as string;
|
||||
const memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
const memId = (mem.id ?? "") as string;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${memoryText}`);
|
||||
lines.push("");
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${memoryText}`);
|
||||
lines.push("");
|
||||
|
||||
if (memId) lines.push(` ${dim("ID:")} ${memId}`);
|
||||
const created = formatDate(mem.created_at as string | undefined);
|
||||
if (created) lines.push(` ${dim("Created:")} ${created}`);
|
||||
const updated = formatDate(mem.updated_at as string | undefined);
|
||||
if (updated) lines.push(` ${dim("Updated:")} ${updated}`);
|
||||
const meta = mem.metadata;
|
||||
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
|
||||
const categories = mem.categories;
|
||||
if (categories) {
|
||||
const catStr = Array.isArray(categories)
|
||||
? categories.join(", ")
|
||||
: String(categories);
|
||||
lines.push(` ${dim("Categories:")} ${catStr}`);
|
||||
}
|
||||
if (memId) lines.push(` ${dim("ID:")} ${memId}`);
|
||||
const created = formatDate(mem.created_at as string | undefined);
|
||||
if (created) lines.push(` ${dim("Created:")} ${created}`);
|
||||
const updated = formatDate(mem.updated_at as string | undefined);
|
||||
if (updated) lines.push(` ${dim("Updated:")} ${updated}`);
|
||||
const meta = mem.metadata;
|
||||
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
|
||||
const categories = mem.categories;
|
||||
if (categories) {
|
||||
const catStr = Array.isArray(categories) ? categories.join(", ") : String(categories);
|
||||
lines.push(` ${dim("Categories:")} ${catStr}`);
|
||||
}
|
||||
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Memory"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Memory"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function formatAddResult(
|
||||
result: Record<string, unknown> | Record<string, unknown>[],
|
||||
output = "text",
|
||||
result: Record<string, unknown> | Record<string, unknown>[],
|
||||
output = "text",
|
||||
): void {
|
||||
if (output === "json") {
|
||||
formatJson(result);
|
||||
return;
|
||||
}
|
||||
if (output === "quiet") return;
|
||||
if (output === "json") {
|
||||
formatJson(result);
|
||||
return;
|
||||
}
|
||||
if (output === "quiet") return;
|
||||
|
||||
const results: Record<string, unknown>[] = Array.isArray(result)
|
||||
? result
|
||||
: ((result.results as Record<string, unknown>[]) ?? [result]);
|
||||
const results: Record<string, unknown>[] = Array.isArray(result)
|
||||
? result
|
||||
: ((result.results as Record<string, unknown>[]) ?? [result]);
|
||||
|
||||
if (!results.length) {
|
||||
console.log(` ${dim("No memories extracted.")}`);
|
||||
return;
|
||||
}
|
||||
if (!results.length) {
|
||||
console.log(` ${dim("No memories extracted.")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
const seenPendingEvents = new Set<string>();
|
||||
for (const r of results) {
|
||||
// Detect async PENDING response
|
||||
if (r.status === "PENDING") {
|
||||
const eventId = (r.event_id as string) ?? "";
|
||||
// Deduplicate PENDING entries with the same event_id
|
||||
if (eventId && seenPendingEvents.has(eventId)) continue;
|
||||
if (eventId) seenPendingEvents.add(eventId);
|
||||
const icon = accent(sym("⧗", "..."));
|
||||
const parts = [
|
||||
` ${icon} ${dim("Queued".padEnd(10))}`,
|
||||
"Processing in background",
|
||||
];
|
||||
console.log(parts.join(" "));
|
||||
if (eventId) {
|
||||
console.log(` ${dim(` event_id: ${eventId}`)}`);
|
||||
console.log(
|
||||
` ${dim(` → Check status: mem0 event status ${eventId}`)}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
console.log();
|
||||
for (const r of results) {
|
||||
// Detect async PENDING response
|
||||
if (r.status === "PENDING") {
|
||||
const eventId = ((r.event_id as string) ?? "").slice(0, 8);
|
||||
const icon = accent(sym("⧗", "..."));
|
||||
const parts = [` ${icon} ${dim("Queued".padEnd(10))}`, "Processing in background"];
|
||||
if (eventId) parts.push(dim(`(event ${eventId})`));
|
||||
console.log(parts.join(" "));
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = (r.event ?? "ADD") as string;
|
||||
const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") as string;
|
||||
const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(
|
||||
0,
|
||||
8,
|
||||
);
|
||||
const event = (r.event ?? "ADD") as string;
|
||||
const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") as string;
|
||||
const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(0, 8);
|
||||
|
||||
let icon: string;
|
||||
let label: string;
|
||||
if (event === "ADD") {
|
||||
icon = success("+");
|
||||
label = "Added";
|
||||
} else if (event === "UPDATE") {
|
||||
icon = accent("~");
|
||||
label = "Updated";
|
||||
} else if (event === "DELETE") {
|
||||
icon = errorColor("-");
|
||||
label = "Deleted";
|
||||
} else if (event === "NOOP") {
|
||||
icon = dim("·");
|
||||
label = "No change";
|
||||
} else {
|
||||
icon = dim("?");
|
||||
label = event;
|
||||
}
|
||||
let icon: string;
|
||||
let label: string;
|
||||
if (event === "ADD") {
|
||||
icon = success("+");
|
||||
label = "Added";
|
||||
} else if (event === "UPDATE") {
|
||||
icon = accent("~");
|
||||
label = "Updated";
|
||||
} else if (event === "DELETE") {
|
||||
icon = errorColor("-");
|
||||
label = "Deleted";
|
||||
} else if (event === "NOOP") {
|
||||
icon = dim("·");
|
||||
label = "No change";
|
||||
} else {
|
||||
icon = dim("?");
|
||||
label = event;
|
||||
}
|
||||
|
||||
const parts = [` ${icon} ${dim(label.padEnd(10))}`];
|
||||
if (memory) parts.push(memory);
|
||||
if (memId) parts.push(dim(`(${memId})`));
|
||||
console.log(parts.join(" "));
|
||||
}
|
||||
console.log();
|
||||
const parts = [` ${icon} ${dim(label.padEnd(10))}`];
|
||||
if (memory) parts.push(memory);
|
||||
if (memId) parts.push(dim(`(${memId})`));
|
||||
console.log(parts.join(" "));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function formatJsonEnvelope(opts: {
|
||||
command: string;
|
||||
data: unknown;
|
||||
durationMs?: number;
|
||||
scope?: Record<string, string | undefined>;
|
||||
count?: number;
|
||||
status?: string;
|
||||
error?: string;
|
||||
command: string;
|
||||
data: unknown;
|
||||
durationMs?: number;
|
||||
scope?: Record<string, string | undefined>;
|
||||
count?: number;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}): void {
|
||||
const envelope: Record<string, unknown> = {
|
||||
status: opts.status ?? "success",
|
||||
command: opts.command,
|
||||
};
|
||||
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
|
||||
if (opts.scope !== undefined) envelope.scope = opts.scope;
|
||||
if (opts.count !== undefined) envelope.count = opts.count;
|
||||
if (opts.error) envelope.error = opts.error;
|
||||
envelope.data = opts.data;
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
}
|
||||
|
||||
function pick(
|
||||
obj: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (key in obj) result[key] = obj[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeAgentData(command: string, data: unknown): unknown {
|
||||
if (data === null || data === undefined) return data;
|
||||
|
||||
switch (command) {
|
||||
case "add": {
|
||||
const items = Array.isArray(data) ? data : [data];
|
||||
return items.map((item) => {
|
||||
const r = item as Record<string, unknown>;
|
||||
if (r.status === "PENDING") return pick(r, ["status", "event_id"]);
|
||||
return pick(r, ["id", "memory", "event"]);
|
||||
});
|
||||
}
|
||||
case "search":
|
||||
return (data as Record<string, unknown>[]).map((r) =>
|
||||
pick(r, ["id", "memory", "score", "created_at", "categories"]),
|
||||
);
|
||||
case "list":
|
||||
return (data as Record<string, unknown>[]).map((r) =>
|
||||
pick(r, ["id", "memory", "created_at", "categories"]),
|
||||
);
|
||||
case "get": {
|
||||
const r = data as Record<string, unknown>;
|
||||
return pick(r, [
|
||||
"id",
|
||||
"memory",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"categories",
|
||||
"metadata",
|
||||
]);
|
||||
}
|
||||
case "update": {
|
||||
const r = data as Record<string, unknown>;
|
||||
return pick(r, ["id", "memory"]);
|
||||
}
|
||||
case "delete":
|
||||
case "delete-all":
|
||||
case "entity delete":
|
||||
return data;
|
||||
case "entity list":
|
||||
return (data as Record<string, unknown>[]).map((r) => ({
|
||||
name: (r.name ?? r.id) as string,
|
||||
...pick(r, ["type", "count"]),
|
||||
}));
|
||||
case "event list":
|
||||
return (data as Record<string, unknown>[]).map((r) =>
|
||||
pick(r, ["id", "event_type", "status", "latency", "created_at"]),
|
||||
);
|
||||
case "event status": {
|
||||
const ev = data as Record<string, unknown>;
|
||||
const rawResults =
|
||||
(ev.results as Record<string, unknown>[] | undefined) ?? [];
|
||||
const sanitizedResults = rawResults.map((r) => {
|
||||
const nested = r.data as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: r.id,
|
||||
event: r.event,
|
||||
user_id: r.user_id,
|
||||
memory: nested?.memory ?? null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...pick(ev, [
|
||||
"id",
|
||||
"event_type",
|
||||
"status",
|
||||
"latency",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]),
|
||||
results: sanitizedResults,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAgentEnvelope(opts: {
|
||||
command: string;
|
||||
data: unknown;
|
||||
durationMs?: number;
|
||||
scope?: Record<string, string | undefined>;
|
||||
count?: number;
|
||||
}): void {
|
||||
const envelope: Record<string, unknown> = {
|
||||
status: "success",
|
||||
command: opts.command,
|
||||
};
|
||||
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
|
||||
if (opts.scope) {
|
||||
const filtered = Object.fromEntries(
|
||||
Object.entries(opts.scope).filter(([, v]) => v),
|
||||
);
|
||||
if (Object.keys(filtered).length > 0) envelope.scope = filtered;
|
||||
}
|
||||
if (opts.count !== undefined) envelope.count = opts.count;
|
||||
envelope.data = sanitizeAgentData(opts.command, opts.data);
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
const envelope: Record<string, unknown> = {
|
||||
status: opts.status ?? "success",
|
||||
command: opts.command,
|
||||
};
|
||||
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
|
||||
if (opts.scope !== undefined) envelope.scope = opts.scope;
|
||||
if (opts.count !== undefined) envelope.count = opts.count;
|
||||
if (opts.error) envelope.error = opts.error;
|
||||
envelope.data = opts.data;
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
}
|
||||
|
||||
export function printResultSummary(opts: {
|
||||
count: number;
|
||||
durationSecs?: number;
|
||||
page?: number;
|
||||
scopeIds?: Record<string, string | undefined>;
|
||||
count: number;
|
||||
durationSecs?: number;
|
||||
page?: number;
|
||||
scopeIds?: Record<string, string | undefined>;
|
||||
}): void {
|
||||
const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`];
|
||||
if (opts.page !== undefined) parts.push(`page ${opts.page}`);
|
||||
if (opts.scopeIds) {
|
||||
const scopeParts = Object.entries(opts.scopeIds)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k}=${v}`);
|
||||
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
|
||||
}
|
||||
if (opts.durationSecs !== undefined)
|
||||
parts.push(`${opts.durationSecs.toFixed(2)}s`);
|
||||
const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`];
|
||||
if (opts.page !== undefined) parts.push(`page ${opts.page}`);
|
||||
if (opts.scopeIds) {
|
||||
const scopeParts = Object.entries(opts.scopeIds)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`);
|
||||
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
|
||||
}
|
||||
if (opts.durationSecs !== undefined) parts.push(`${opts.durationSecs.toFixed(2)}s`);
|
||||
|
||||
console.log(` ${dim(parts.join(" · "))}`);
|
||||
console.log();
|
||||
console.log(` ${dim(parts.join(" · "))}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Agent mode state — set by the root program option handler,
|
||||
* read by commands and branding functions.
|
||||
*/
|
||||
|
||||
let _agentMode = false;
|
||||
let _currentCommand = "";
|
||||
|
||||
export function isAgentMode(): boolean {
|
||||
return _agentMode;
|
||||
}
|
||||
|
||||
export function setAgentMode(val: boolean): void {
|
||||
_agentMode = val;
|
||||
}
|
||||
|
||||
export function getCurrentCommand(): string {
|
||||
return _currentCommand;
|
||||
}
|
||||
|
||||
export function setCurrentCommand(name: string): void {
|
||||
_currentCommand = name;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
// __CLI_VERSION__ is replaced at build time by tsup (see tsup.config.ts).
|
||||
// When running via tsx in dev/test mode, fall back to reading package.json.
|
||||
// typeof is safe to use on undeclared identifiers — it returns 'undefined' without throwing.
|
||||
export const CLI_VERSION: string =
|
||||
typeof __CLI_VERSION__ !== "undefined"
|
||||
? (__CLI_VERSION__ as string)
|
||||
: (createRequire(import.meta.url)("../package.json") as { version: string })
|
||||
.version;
|
||||
@@ -39,7 +39,7 @@ afterEach(() => {
|
||||
|
||||
describe("branding constants", () => {
|
||||
it("has correct brand color", () => {
|
||||
expect(BRAND_COLOR).toBe("#F1C96C");
|
||||
expect(BRAND_COLOR).toBe("#8b5cf6");
|
||||
});
|
||||
|
||||
it("has correct tagline", () => {
|
||||
@@ -80,19 +80,19 @@ describe("printWarning", () => {
|
||||
describe("printInfo", () => {
|
||||
it("prints info message", () => {
|
||||
printInfo("Important note");
|
||||
expect(errOutput).toContain("Important note");
|
||||
expect(output).toContain("Important note");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printScope", () => {
|
||||
it("prints scope when IDs present", () => {
|
||||
printScope({ user_id: "alice", agent_id: "bot" });
|
||||
expect(errOutput).toContain("alice");
|
||||
expect(errOutput).toContain("bot");
|
||||
expect(output).toContain("alice");
|
||||
expect(output).toContain("bot");
|
||||
});
|
||||
|
||||
it("prints nothing when no IDs", () => {
|
||||
printScope({});
|
||||
expect(errOutput).toBe("");
|
||||
expect(output).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,13 @@ describe("CLI Integration — help and version", () => {
|
||||
expect(result.stdout).toContain("search");
|
||||
});
|
||||
|
||||
it("shows version with --version", () => {
|
||||
const result = run(["--version"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("0.1.0");
|
||||
});
|
||||
|
||||
|
||||
it("help --json produces valid JSON", () => {
|
||||
const result = run(["help", "--json"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createMockBackend } from "./setup.js";
|
||||
import type { Backend } from "../src/backend/base.js";
|
||||
import { setAgentMode } from "../src/state.js";
|
||||
|
||||
let mockBackend: Backend;
|
||||
|
||||
@@ -32,7 +31,6 @@ import { afterEach } from "vitest";
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
setAgentMode(false);
|
||||
});
|
||||
|
||||
describe("cmdAdd", () => {
|
||||
@@ -86,59 +84,6 @@ describe("cmdAdd", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdAdd deduplicates PENDING", () => {
|
||||
const DUPLICATE_PENDING = {
|
||||
results: [
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
],
|
||||
};
|
||||
|
||||
it("text shows one pending block", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(output.match(/Queued/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("json shows one pending entry", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "json",
|
||||
});
|
||||
const data = JSON.parse(output);
|
||||
const pending = data.results.filter((r: Record<string, unknown>) => r.status === "PENDING");
|
||||
expect(pending).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("agent shows one pending entry", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
setAgentMode(true);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "agent",
|
||||
});
|
||||
const data = JSON.parse(output);
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSearch", () => {
|
||||
it("searches and shows results in text mode", async () => {
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
@@ -180,7 +125,7 @@ describe("cmdSearch", () => {
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(errOutput).toContain("No memories found");
|
||||
expect(output).toContain("No memories found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,7 +166,7 @@ describe("cmdList", () => {
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(errOutput).toContain("No memories found");
|
||||
expect(output).toContain("No memories found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,6 +198,13 @@ describe("cmdDeleteAll", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdVersion", () => {
|
||||
it("shows version", async () => {
|
||||
const { cmdVersion } = await import("../src/commands/utils.js");
|
||||
cmdVersion();
|
||||
expect(output).toContain("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdEntitiesList", () => {
|
||||
it("lists users in table mode", async () => {
|
||||
@@ -267,168 +219,3 @@ describe("cmdEntitiesList", () => {
|
||||
expect(output).toContain("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdEventList", () => {
|
||||
it("lists events in table mode", async () => {
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "table" });
|
||||
expect(output).toContain("evt-abc-");
|
||||
expect(output).toContain("ADD");
|
||||
expect(output).toContain("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("lists events in json mode", async () => {
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "json" });
|
||||
expect(output).toContain("evt-abc-123-def-456");
|
||||
expect(output).toContain("evt-def-456-ghi-789");
|
||||
});
|
||||
|
||||
it("shows empty message when no events", async () => {
|
||||
(mockBackend.listEvents as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "table" });
|
||||
expect((output + errOutput).toLowerCase()).toContain("no events");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdEventStatus", () => {
|
||||
it("shows event details in text mode", async () => {
|
||||
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" });
|
||||
expect(output).toContain("evt-abc-123-def-456");
|
||||
expect(output).toContain("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("shows event details in json mode", async () => {
|
||||
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" });
|
||||
expect(output).toContain("evt-abc-123-def-456");
|
||||
expect(output).toContain("ADD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent mode", () => {
|
||||
it("cmdAdd outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test preference", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "agent",
|
||||
});
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("add");
|
||||
expect(parsed.data).toBeDefined();
|
||||
expect(parsed.scope).toMatchObject({ user_id: "alice" });
|
||||
expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort());
|
||||
});
|
||||
|
||||
it("cmdSearch outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "preferences", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
enableGraph: false,
|
||||
output: "agent",
|
||||
});
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("search");
|
||||
expect(Array.isArray(parsed.data)).toBe(true);
|
||||
expect(parsed.count).toBe(2);
|
||||
const keys = Object.keys(parsed.data[0]);
|
||||
expect(keys).toContain("id");
|
||||
expect(keys).toContain("memory");
|
||||
expect(keys).toContain("score");
|
||||
expect(keys).toContain("created_at");
|
||||
expect(keys).toContain("categories");
|
||||
expect(keys).not.toContain("user_id");
|
||||
expect(keys).not.toContain("agent_id");
|
||||
});
|
||||
|
||||
it("cmdList outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdList } = await import("../src/commands/memory.js");
|
||||
await cmdList(mockBackend, {
|
||||
userId: "alice",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
enableGraph: false,
|
||||
output: "agent",
|
||||
});
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("list");
|
||||
expect(Array.isArray(parsed.data)).toBe(true);
|
||||
expect(parsed.count).toBe(2);
|
||||
expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]);
|
||||
});
|
||||
|
||||
it("cmdGet outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdGet } = await import("../src/commands/memory.js");
|
||||
await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("get");
|
||||
expect(parsed.data).toBeDefined();
|
||||
expect(parsed.data).toMatchObject({ id: "abc-123-def-456" });
|
||||
expect(Object.keys(parsed.data)).not.toContain("user_id");
|
||||
});
|
||||
|
||||
it("cmdUpdate outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdUpdate } = await import("../src/commands/memory.js");
|
||||
await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("update");
|
||||
expect(parsed.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("cmdDelete outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdDelete } = await import("../src/commands/memory.js");
|
||||
await cmdDelete(mockBackend, "abc-123", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("delete");
|
||||
expect(parsed.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("cmdEventList outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdEventList } = await import("../src/commands/events.js");
|
||||
await cmdEventList(mockBackend, { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("event list");
|
||||
expect(Array.isArray(parsed.data)).toBe(true);
|
||||
expect(parsed.count).toBe(2);
|
||||
expect(Object.keys(parsed.data[0]).sort()).toEqual(
|
||||
["created_at", "event_type", "id", "latency", "status"],
|
||||
);
|
||||
expect(Object.keys(parsed.data[0])).not.toContain("updated_at");
|
||||
});
|
||||
|
||||
it("cmdEventStatus outputs JSON envelope", async () => {
|
||||
setAgentMode(true);
|
||||
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" });
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.status).toBe("success");
|
||||
expect(parsed.command).toBe("event status");
|
||||
expect(parsed.data).toBeDefined();
|
||||
expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" });
|
||||
expect(parsed.data.results[0]).toHaveProperty("memory");
|
||||
expect(parsed.data.results[0]).not.toHaveProperty("data");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
formatSingleMemory,
|
||||
formatAddResult,
|
||||
printResultSummary,
|
||||
sanitizeAgentData,
|
||||
} from "../src/output.js";
|
||||
|
||||
let output: string;
|
||||
@@ -99,18 +98,6 @@ describe("formatAddResult", () => {
|
||||
});
|
||||
expect(output).toContain("Queued");
|
||||
});
|
||||
|
||||
it("deduplicates PENDING entries with same event_id", () => {
|
||||
formatAddResult({
|
||||
results: [
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
],
|
||||
});
|
||||
// Should show only one PENDING block despite two entries with same event_id
|
||||
expect(output.match(/Queued/g)?.length).toBe(1);
|
||||
expect(output.match(/evt-dup/g)?.length).toBe(2); // event_id line + status hint line
|
||||
});
|
||||
});
|
||||
|
||||
describe("printResultSummary", () => {
|
||||
@@ -126,73 +113,3 @@ describe("printResultSummary", () => {
|
||||
expect(output).not.toContain("results");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAgentData", () => {
|
||||
it("projects add results", () => {
|
||||
const raw = [{ id: "abc", memory: "test", event: "ADD", metadata: { x: 1 }, categories: ["a"] }];
|
||||
const result = sanitizeAgentData("add", raw) as Record<string, unknown>[];
|
||||
expect(result).toEqual([{ id: "abc", memory: "test", event: "ADD" }]);
|
||||
});
|
||||
|
||||
it("passes through PENDING add items", () => {
|
||||
const raw = [{ status: "PENDING", event_id: "evt-123", noise: "x" }];
|
||||
const result = sanitizeAgentData("add", raw) as Record<string, unknown>[];
|
||||
expect(result).toEqual([{ status: "PENDING", event_id: "evt-123" }]);
|
||||
});
|
||||
|
||||
it("projects search results", () => {
|
||||
const raw = [{ id: "abc", memory: "test", score: 0.9, created_at: "2026-01-01", categories: ["a"], user_id: "u1" }];
|
||||
const result = sanitizeAgentData("search", raw) as Record<string, unknown>[];
|
||||
expect(result[0]).not.toHaveProperty("user_id");
|
||||
expect(result[0]).toHaveProperty("score");
|
||||
});
|
||||
|
||||
it("projects list results", () => {
|
||||
const raw = [{ id: "abc", memory: "test", created_at: "2026-01-01", categories: ["a"], user_id: "u1" }];
|
||||
const result = sanitizeAgentData("list", raw) as Record<string, unknown>[];
|
||||
expect(Object.keys(result[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]);
|
||||
});
|
||||
|
||||
it("projects get result", () => {
|
||||
const raw = { id: "abc", memory: "test", created_at: "2026-01-01", updated_at: "2026-01-02", categories: ["a"], metadata: { k: "v" }, user_id: "u1" };
|
||||
const result = sanitizeAgentData("get", raw) as Record<string, unknown>;
|
||||
expect(result).not.toHaveProperty("user_id");
|
||||
expect(result).toHaveProperty("metadata");
|
||||
});
|
||||
|
||||
it("projects update result", () => {
|
||||
const raw = { id: "abc", memory: "updated", extra: "noise" };
|
||||
const result = sanitizeAgentData("update", raw);
|
||||
expect(result).toEqual({ id: "abc", memory: "updated" });
|
||||
});
|
||||
|
||||
it("projects event list results", () => {
|
||||
const raw = [{ id: "evt-1", event_type: "ADD", status: "SUCCEEDED", graph_status: null, latency: 100, created_at: "2026-01-01", updated_at: "2026-01-02" }];
|
||||
const result = sanitizeAgentData("event list", raw) as Record<string, unknown>[];
|
||||
expect(result[0]).not.toHaveProperty("updated_at");
|
||||
expect(result[0]).not.toHaveProperty("graph_status");
|
||||
});
|
||||
|
||||
it("flattens event status results", () => {
|
||||
const raw = {
|
||||
id: "evt-1", event_type: "ADD", status: "SUCCEEDED",
|
||||
latency: 100, created_at: "2026-01-01", updated_at: "2026-01-02",
|
||||
results: [{ id: "mem-1", event: "ADD", user_id: "alice", data: { memory: "dark mode" } }],
|
||||
};
|
||||
const result = sanitizeAgentData("event status", raw) as Record<string, unknown>;
|
||||
const firstResult = (result.results as Record<string, unknown>[])[0];
|
||||
expect(firstResult).toHaveProperty("memory", "dark mode");
|
||||
expect(firstResult).not.toHaveProperty("data");
|
||||
});
|
||||
|
||||
it("passes through status/config/import commands unchanged", () => {
|
||||
const data = { key: "value", other: "stuff" };
|
||||
for (const cmd of ["status", "import", "config show", "config get", "config set"]) {
|
||||
expect(sanitizeAgentData(cmd, data)).toEqual(data);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles null data", () => {
|
||||
expect(sanitizeAgentData("add", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,42 +71,5 @@ export function createMockBackend(): Backend {
|
||||
{ name: "alice", count: 5 },
|
||||
{ name: "bob", count: 3 },
|
||||
]),
|
||||
listEvents: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "evt-abc-123-def-456",
|
||||
event_type: "ADD",
|
||||
status: "SUCCEEDED",
|
||||
graph_status: null,
|
||||
latency: 1234.5,
|
||||
created_at: "2026-04-01T10:00:00Z",
|
||||
updated_at: "2026-04-01T10:00:01Z",
|
||||
},
|
||||
{
|
||||
id: "evt-def-456-ghi-789",
|
||||
event_type: "SEARCH",
|
||||
status: "PENDING",
|
||||
graph_status: null,
|
||||
latency: null,
|
||||
created_at: "2026-04-01T10:01:00Z",
|
||||
updated_at: "2026-04-01T10:01:00Z",
|
||||
},
|
||||
]),
|
||||
getEvent: vi.fn().mockResolvedValue({
|
||||
id: "evt-abc-123-def-456",
|
||||
event_type: "ADD",
|
||||
status: "SUCCEEDED",
|
||||
graph_status: "SUCCEEDED",
|
||||
latency: 1234.5,
|
||||
created_at: "2026-04-01T10:00:00Z",
|
||||
updated_at: "2026-04-01T10:00:01Z",
|
||||
results: [
|
||||
{
|
||||
id: "mem-abc-123",
|
||||
event: "ADD",
|
||||
user_id: "alice",
|
||||
data: { memory: "User prefers dark mode" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('./package.json');
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
define: {
|
||||
__CLI_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require("./package.json") as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__CLI_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
@@ -34,13 +34,6 @@ mem0 search "What are Alice's preferences?" --user-id alice
|
||||
mem0 list --user-id alice
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
1. Update `version` in `pyproject.toml`
|
||||
2. Create a GitHub Release with tag `cli-v<version>` (e.g. `cli-v0.2.0`)
|
||||
|
||||
For a pre-release, use a beta version like `0.2.0b1` and check the **pre-release** checkbox.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+26
-177
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import os
|
||||
import stat as _stat_mod
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -45,14 +43,7 @@ entity_app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
event_app = typer.Typer(
|
||||
name="event",
|
||||
help="Inspect background processing events.",
|
||||
no_args_is_help=True,
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
# entity_app and event_app registered after Memory commands to control panel ordering
|
||||
# entity_app registered after Memory commands to control panel ordering
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
@@ -123,22 +114,9 @@ def _resolve_ids(
|
||||
}
|
||||
|
||||
|
||||
def _stdin_is_piped() -> bool:
|
||||
"""Return True only when stdin is an actual pipe or file redirect — not a bare open fd."""
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return False
|
||||
try:
|
||||
mode = os.fstat(sys.stdin.fileno()).st_mode
|
||||
return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _read_stdin() -> str | None:
|
||||
"""Read from stdin if it is an actual pipe or file redirect (not a TTY, not agent mode)."""
|
||||
if _stdin_is_piped():
|
||||
"""Read from stdin if it is piped (not a TTY)."""
|
||||
if not sys.stdin.isatty():
|
||||
return sys.stdin.read().strip() or None
|
||||
return None
|
||||
|
||||
@@ -150,18 +128,7 @@ def _read_stdin() -> str | None:
|
||||
def main_callback(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(False, "--version", help="Show version and exit."),
|
||||
json_agent: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
"--agent",
|
||||
help="Output as JSON for agent/programmatic use.",
|
||||
is_eager=False,
|
||||
),
|
||||
) -> None:
|
||||
if json_agent:
|
||||
from mem0_cli.state import set_agent_mode
|
||||
|
||||
set_agent_mode(True)
|
||||
if version:
|
||||
from mem0_cli.commands.utils import cmd_version
|
||||
|
||||
@@ -286,12 +253,8 @@ def search(
|
||||
help="Specific fields to return (comma-separated).",
|
||||
rich_help_panel="Search",
|
||||
),
|
||||
graph: bool = typer.Option(
|
||||
False, "--graph", help="Enable graph in search.", rich_help_panel="Search"
|
||||
),
|
||||
no_graph: bool = typer.Option(
|
||||
False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"
|
||||
),
|
||||
graph: bool = typer.Option(False, "--graph", help="Enable graph in search.", rich_help_panel="Search"),
|
||||
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"),
|
||||
output: str = typer.Option(
|
||||
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
||||
),
|
||||
@@ -306,7 +269,7 @@ def search(
|
||||
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
||||
),
|
||||
) -> None:
|
||||
"""Query your memory store — semantic, keyword, or hybrid retrieval.
|
||||
"""Search memories by semantic query.
|
||||
|
||||
Examples:
|
||||
mem0 search "preferences" --user-id alice
|
||||
@@ -318,8 +281,8 @@ def search(
|
||||
# STEP 7: stdin fallback for query
|
||||
if query is None:
|
||||
query = _read_stdin()
|
||||
if not query or not query.strip():
|
||||
print_error(err_console, "Search query cannot be empty.")
|
||||
if query is None:
|
||||
print_error(err_console, "No query provided. Pass a query argument or pipe via stdin.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
backend, config = _get_backend_and_config(api_key, base_url)
|
||||
@@ -409,12 +372,8 @@ def list_cmd(
|
||||
before: str | None = typer.Option(
|
||||
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
|
||||
),
|
||||
graph: bool = typer.Option(
|
||||
False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"
|
||||
),
|
||||
no_graph: bool = typer.Option(
|
||||
False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"
|
||||
),
|
||||
graph: bool = typer.Option(False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"),
|
||||
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"),
|
||||
output: str = typer.Option(
|
||||
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
||||
),
|
||||
@@ -504,19 +463,11 @@ def update(
|
||||
|
||||
@app.command(rich_help_panel="Memory")
|
||||
def delete(
|
||||
memory_id: str | None = typer.Argument(
|
||||
None, help="Memory ID to delete (omit when using --all or --entity)."
|
||||
),
|
||||
memory_id: str | None = typer.Argument(None, help="Memory ID to delete (omit when using --all or --entity)."),
|
||||
all_: bool = typer.Option(False, "--all", help="Delete all memories matching scope filters."),
|
||||
entity: bool = typer.Option(
|
||||
False, "--entity", help="Delete the entity itself and all its memories (cascade)."
|
||||
),
|
||||
project: bool = typer.Option(
|
||||
False, "--project", help="With --all: delete ALL memories project-wide."
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False, "--dry-run", help="Show what would be deleted without deleting."
|
||||
),
|
||||
entity: bool = typer.Option(False, "--entity", help="Delete the entity itself and all its memories (cascade)."),
|
||||
project: bool = typer.Option(False, "--project", help="With --all: delete ALL memories project-wide."),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
|
||||
user_id: str | None = typer.Option(
|
||||
None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope"
|
||||
@@ -690,12 +641,14 @@ def entity_delete(
|
||||
agent_id: str | None = typer.Option(
|
||||
None, "--agent-id", help="Agent ID.", rich_help_panel="Scope"
|
||||
),
|
||||
app_id: str | None = typer.Option(None, "--app-id", help="App ID.", rich_help_panel="Scope"),
|
||||
run_id: str | None = typer.Option(None, "--run-id", help="Run ID.", rich_help_panel="Scope"),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
|
||||
dry_run: bool = typer.Option(
|
||||
False, "--dry-run", help="Show what would be deleted without deleting."
|
||||
app_id: str | None = typer.Option(
|
||||
None, "--app-id", help="App ID.", rich_help_panel="Scope"
|
||||
),
|
||||
run_id: str | None = typer.Option(
|
||||
None, "--run-id", help="Run ID.", rich_help_panel="Scope"
|
||||
),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."),
|
||||
output: str = typer.Option(
|
||||
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
|
||||
),
|
||||
@@ -735,98 +688,23 @@ def entity_delete(
|
||||
app.add_typer(entity_app, name="entity", rich_help_panel="Management")
|
||||
|
||||
|
||||
# ── Event subcommands ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@event_app.command("list")
|
||||
def event_list(
|
||||
output: str = typer.Option(
|
||||
"table", "--output", "-o", help="Output: table, json.", rich_help_panel="Output"
|
||||
),
|
||||
api_key: str | None = typer.Option(
|
||||
None,
|
||||
"--api-key",
|
||||
help="Override API key.",
|
||||
envvar="MEM0_API_KEY",
|
||||
rich_help_panel="Connection",
|
||||
),
|
||||
base_url: str | None = typer.Option(
|
||||
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
||||
),
|
||||
) -> None:
|
||||
"""List recent background processing events.
|
||||
|
||||
Examples:
|
||||
mem0 event list
|
||||
mem0 event list -o json
|
||||
"""
|
||||
from mem0_cli.commands.events_cmd import cmd_event_list
|
||||
|
||||
backend = _get_backend(api_key, base_url)
|
||||
cmd_event_list(backend, output=output)
|
||||
|
||||
|
||||
@event_app.command("status")
|
||||
def event_status(
|
||||
event_id: str = typer.Argument(..., help="Event ID to inspect."),
|
||||
output: str = typer.Option(
|
||||
"text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output"
|
||||
),
|
||||
api_key: str | None = typer.Option(
|
||||
None,
|
||||
"--api-key",
|
||||
help="Override API key.",
|
||||
envvar="MEM0_API_KEY",
|
||||
rich_help_panel="Connection",
|
||||
),
|
||||
base_url: str | None = typer.Option(
|
||||
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
||||
),
|
||||
) -> None:
|
||||
"""Check the status of a specific background event.
|
||||
|
||||
Examples:
|
||||
mem0 event status <event-id>
|
||||
mem0 event status <event-id> -o json
|
||||
"""
|
||||
from mem0_cli.commands.events_cmd import cmd_event_status
|
||||
|
||||
backend = _get_backend(api_key, base_url)
|
||||
cmd_event_status(backend, event_id, output=output)
|
||||
|
||||
|
||||
# ── Event subgroup ──
|
||||
app.add_typer(event_app, name="event", rich_help_panel="Management")
|
||||
|
||||
|
||||
# ── Management commands ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Management")
|
||||
def init(
|
||||
api_key: str | None = typer.Option(None, "--api-key", help="API key (skip prompt)."),
|
||||
user_id: str | None = typer.Option(
|
||||
None, "--user-id", "-u", help="Default user ID (skip prompt)."
|
||||
),
|
||||
email: str | None = typer.Option(None, "--email", help="Login via email verification code."),
|
||||
code: str | None = typer.Option(
|
||||
None, "--code", help="Verification code (use with --email for non-interactive login)."
|
||||
),
|
||||
force: bool = typer.Option(
|
||||
False, "--force", help="Overwrite existing config without confirmation."
|
||||
),
|
||||
user_id: str | None = typer.Option(None, "--user-id", "-u", help="Default user ID (skip prompt)."),
|
||||
) -> None:
|
||||
"""Interactive setup wizard for mem0 CLI.
|
||||
|
||||
Examples:
|
||||
mem0 init
|
||||
mem0 init --api-key m0-xxx --user-id alice
|
||||
mem0 init --email alice@company.com
|
||||
mem0 init --email alice@company.com --code 482901
|
||||
"""
|
||||
from mem0_cli.commands.init_cmd import run_init
|
||||
|
||||
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
|
||||
run_init(api_key=api_key, user_id=user_id)
|
||||
|
||||
|
||||
# (entity_app registered at module level, below sub-group definitions)
|
||||
@@ -865,6 +743,7 @@ def status(
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.command("import", rich_help_panel="Management")
|
||||
def import_cmd(
|
||||
file_path: str = typer.Argument(..., help="JSON file to import."),
|
||||
@@ -931,7 +810,7 @@ def _build_help_json() -> dict:
|
||||
},
|
||||
},
|
||||
"search": {
|
||||
"description": "Query your memory store — semantic, keyword, or hybrid retrieval.",
|
||||
"description": "Search memories by semantic query.",
|
||||
"usage": "mem0 search <query> [OPTIONS]",
|
||||
"arguments": {"query": {"description": "Search query.", "required": False}},
|
||||
"options": {
|
||||
@@ -1035,24 +914,6 @@ def _build_help_json() -> dict:
|
||||
"value": {"description": "Value to set.", "required": True},
|
||||
},
|
||||
},
|
||||
"event": {
|
||||
"description": "Inspect background processing events.",
|
||||
"subcommands": {
|
||||
"list": {
|
||||
"description": "List recent background processing events.",
|
||||
"usage": "mem0 event list [OPTIONS]",
|
||||
"options": {"--output, -o": "Output format: table, json."},
|
||||
},
|
||||
"status": {
|
||||
"description": "Check the status of a specific background event.",
|
||||
"usage": "mem0 event status <event_id> [OPTIONS]",
|
||||
"arguments": {
|
||||
"event_id": {"description": "Event ID to inspect.", "required": True}
|
||||
},
|
||||
"options": {"--output, -o": "Output format: text, json."},
|
||||
},
|
||||
},
|
||||
},
|
||||
"entity": {
|
||||
"description": "Manage entities.",
|
||||
"subcommands": {
|
||||
@@ -1104,7 +965,6 @@ def _build_help_json() -> dict:
|
||||
"global_options": {
|
||||
"--api-key": "Override API key (env: MEM0_API_KEY).",
|
||||
"--base-url": "Override API base URL.",
|
||||
"--json / --agent": "Output as JSON for agent/programmatic use.",
|
||||
"--help": "Show help for a command.",
|
||||
"--version": "Show version and exit.",
|
||||
},
|
||||
@@ -1134,7 +994,7 @@ def help(
|
||||
console.print("Usage: mem0 <command> [OPTIONS]\n")
|
||||
console.print("[bold]Commands:[/]")
|
||||
console.print(" add Add a memory from text, messages, file, or stdin")
|
||||
console.print(" search Query your memory store (semantic, keyword, hybrid)")
|
||||
console.print(" search Search memories by semantic query")
|
||||
console.print(" get Get a specific memory by ID")
|
||||
console.print(" list List memories with optional filters")
|
||||
console.print(" update Update a memory's text or metadata")
|
||||
@@ -1142,7 +1002,6 @@ def help(
|
||||
console.print(" import Import memories from a JSON file")
|
||||
console.print(" config Manage configuration (show, get, set)")
|
||||
console.print(" entity Manage entities (list, delete)")
|
||||
console.print(" event Inspect background events (list, status)")
|
||||
console.print(" init Interactive setup wizard")
|
||||
console.print(" status Check connectivity and authentication")
|
||||
console.print()
|
||||
@@ -1159,14 +1018,4 @@ app.add_typer(config_app, name="config", rich_help_panel="Management")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import sys
|
||||
|
||||
# Allow --json/--agent anywhere in the command line (not just before subcommand).
|
||||
_json_flags = {"--json", "--agent"}
|
||||
if any(a in _json_flags for a in sys.argv[1:]):
|
||||
from mem0_cli.state import set_agent_mode
|
||||
|
||||
set_agent_mode(True)
|
||||
sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
|
||||
|
||||
app()
|
||||
|
||||
@@ -104,11 +104,6 @@ class Backend(ABC):
|
||||
@abstractmethod
|
||||
def entities(self, entity_type: str) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def list_events(self) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_event(self, event_id: str) -> dict: ...
|
||||
|
||||
|
||||
def get_backend(config: Mem0Config) -> Backend:
|
||||
|
||||
@@ -265,21 +265,18 @@ class PlatformBackend(Backend):
|
||||
app_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> dict:
|
||||
# v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
|
||||
type_map = {
|
||||
"user": user_id,
|
||||
"agent": agent_id,
|
||||
"app": app_id,
|
||||
"run": run_id,
|
||||
}
|
||||
entities = {t: v for t, v in type_map.items() if v}
|
||||
if not entities:
|
||||
params: dict[str, str] = {}
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if agent_id:
|
||||
params["agent_id"] = agent_id
|
||||
if app_id:
|
||||
params["app_id"] = app_id
|
||||
if run_id:
|
||||
params["run_id"] = run_id
|
||||
if not params:
|
||||
raise ValueError("At least one entity ID is required for delete_entities.")
|
||||
# Delete each provided entity via the v2 path-based endpoint
|
||||
result: dict = {}
|
||||
for entity_type, entity_id in entities.items():
|
||||
result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/")
|
||||
return result
|
||||
return self._request("DELETE", "/v1/entities/", params=params)
|
||||
|
||||
def status(
|
||||
self,
|
||||
@@ -287,9 +284,19 @@ class PlatformBackend(Backend):
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Check connectivity using the ping endpoint."""
|
||||
"""Check connectivity by making a lightweight API call."""
|
||||
try:
|
||||
self._request("GET", "/v1/ping/")
|
||||
# If entity IDs are available, validate with a minimal memories list
|
||||
if user_id or agent_id:
|
||||
payload: dict[str, Any] = {}
|
||||
params = {"page": "1", "page_size": "1"}
|
||||
api_filters = self._build_filters(user_id=user_id, agent_id=agent_id)
|
||||
if api_filters:
|
||||
payload["filters"] = api_filters
|
||||
self._request("POST", "/v2/memories/", json=payload, params=params)
|
||||
else:
|
||||
# No entity IDs — use entities endpoint to validate API key
|
||||
self._request("GET", "/v1/entities/")
|
||||
return {"connected": True, "backend": "platform", "base_url": self.base_url}
|
||||
except Exception as e:
|
||||
return {"connected": False, "backend": "platform", "error": str(e)}
|
||||
@@ -304,12 +311,6 @@ class PlatformBackend(Backend):
|
||||
items = [e for e in items if e.get("type", "").lower() == target_type]
|
||||
return items
|
||||
|
||||
def list_events(self) -> list[dict]:
|
||||
result = self._request("GET", "/v1/events/")
|
||||
return result if isinstance(result, list) else result.get("results", [])
|
||||
|
||||
def get_event(self, event_id: str) -> dict:
|
||||
return self._request("GET", f"/v1/event/{event_id}/")
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
|
||||
@@ -26,8 +26,8 @@ LOGO_MINI = "◆ mem0"
|
||||
|
||||
TAGLINE = "The Memory Layer for AI Agents"
|
||||
|
||||
BRAND_COLOR = "#F1C96C" # Golden
|
||||
ACCENT_COLOR = "#F5D78E"
|
||||
BRAND_COLOR = "#8b5cf6" # Purple
|
||||
ACCENT_COLOR = "#a78bfa"
|
||||
SUCCESS_COLOR = "#22c55e"
|
||||
ERROR_COLOR = "#ef4444"
|
||||
WARNING_COLOR = "#f59e0b"
|
||||
@@ -43,10 +43,6 @@ def _sym(fancy: str, plain: str) -> str:
|
||||
|
||||
def print_banner(console: Console) -> None:
|
||||
"""Print the mem0 welcome banner."""
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return
|
||||
logo_text = Text(LOGO, style=f"bold {BRAND_COLOR}")
|
||||
tagline = Text(f" {TAGLINE}\n", style=f"{ACCENT_COLOR}")
|
||||
|
||||
@@ -65,28 +61,11 @@ def print_banner(console: Console) -> None:
|
||||
|
||||
|
||||
def print_success(console: Console, message: str) -> None:
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return
|
||||
sym = _sym("✓", "[ok]")
|
||||
console.print(f"[{SUCCESS_COLOR}]{sym}[/] {message}")
|
||||
|
||||
|
||||
def print_error(console: Console, message: str, hint: str | None = None) -> None:
|
||||
from mem0_cli.state import get_current_command, is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
import json as _json
|
||||
|
||||
envelope = {
|
||||
"status": "error",
|
||||
"command": get_current_command(),
|
||||
"error": message,
|
||||
"data": None,
|
||||
}
|
||||
print(_json.dumps(envelope))
|
||||
return
|
||||
sym = _sym("✗", "[error]")
|
||||
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
|
||||
if hint:
|
||||
@@ -94,19 +73,11 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None
|
||||
|
||||
|
||||
def print_warning(console: Console, message: str) -> None:
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return
|
||||
sym = _sym("⚠", "[warn]")
|
||||
console.print(f"[{WARNING_COLOR}]{sym}[/] {message}")
|
||||
|
||||
|
||||
def print_info(console: Console, message: str) -> None:
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return
|
||||
sym = _sym("◆", "*")
|
||||
console.print(f"[{BRAND_COLOR}]{sym}[/] {message}")
|
||||
|
||||
@@ -118,9 +89,7 @@ def timed_status(console: Console, message: str):
|
||||
The spinner and timing output are sent to stderr (via ``_err``) so they
|
||||
never contaminate machine-readable stdout. The *console* parameter is
|
||||
kept for backward compatibility but is not used for spinner output.
|
||||
In agent mode the spinner is suppressed entirely.
|
||||
"""
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self):
|
||||
@@ -128,13 +97,6 @@ def timed_status(console: Console, message: str):
|
||||
self.error_msg = ""
|
||||
|
||||
ctx = _Ctx()
|
||||
if is_agent_mode():
|
||||
try:
|
||||
yield ctx
|
||||
except Exception:
|
||||
raise
|
||||
return
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
with Status(f"[{DIM_COLOR}]{message}[/]", console=_err):
|
||||
@@ -143,11 +105,6 @@ def timed_status(console: Console, message: str):
|
||||
elapsed = time.perf_counter() - start
|
||||
if ctx.error_msg:
|
||||
print_error(_err, f"{ctx.error_msg} ({elapsed:.2f}s)")
|
||||
if "Authentication failed" in ctx.error_msg:
|
||||
_err.print(
|
||||
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key"
|
||||
f" · [bold]https://app.mem0.ai/dashboard/api-keys[/bold][/]"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
elapsed = time.perf_counter() - start
|
||||
@@ -157,14 +114,11 @@ def timed_status(console: Console, message: str):
|
||||
|
||||
def print_scope(console: Console, **ids: str | None) -> None:
|
||||
"""Show active entity scope if any IDs are set."""
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return
|
||||
parts = []
|
||||
for key, val in ids.items():
|
||||
if val:
|
||||
parts.append(f"{key}={val}")
|
||||
label = key.replace("_", " ").replace("id", "ID").strip()
|
||||
parts.append(f"{label}={val}")
|
||||
if parts:
|
||||
scope_str = ", ".join(parts)
|
||||
console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]")
|
||||
|
||||
@@ -20,17 +20,12 @@ err_console = Console(stderr=True)
|
||||
|
||||
def cmd_config_show(*, output: str = "text") -> None:
|
||||
"""Display current configuration (secrets redacted)."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("config show")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
from mem0_cli.output import format_json_envelope
|
||||
|
||||
config = load_config()
|
||||
|
||||
if output in ("json", "agent"):
|
||||
format_agent_envelope(
|
||||
if output == "json":
|
||||
format_json_envelope(
|
||||
console,
|
||||
command="config show",
|
||||
data={
|
||||
@@ -89,44 +84,25 @@ def cmd_config_show(*, output: str = "text") -> None:
|
||||
|
||||
def cmd_config_get(key: str) -> None:
|
||||
"""Get a config value."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("config get")
|
||||
config = load_config()
|
||||
value = get_nested_value(config, key)
|
||||
|
||||
if value is None:
|
||||
print_error(err_console, f"Unknown config key: {key}")
|
||||
return
|
||||
|
||||
display_value = (
|
||||
redact_key(str(value)) if ("api_key" in key or "key" in key.split(".")[-1:]) else str(value)
|
||||
)
|
||||
|
||||
if is_agent_mode():
|
||||
format_agent_envelope(
|
||||
console, command="config get", data={"key": key, "value": display_value}
|
||||
)
|
||||
else:
|
||||
console.print(display_value)
|
||||
# Redact secrets
|
||||
if "api_key" in key or "key" in key.split(".")[-1:]:
|
||||
console.print(redact_key(str(value)))
|
||||
else:
|
||||
console.print(str(value))
|
||||
|
||||
|
||||
def cmd_config_set(key: str, value: str) -> None:
|
||||
"""Set a config value."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("config set")
|
||||
config = load_config()
|
||||
if set_nested_value(config, key, value):
|
||||
save_config(config)
|
||||
display = redact_key(value) if "key" in key else value
|
||||
if is_agent_mode():
|
||||
format_agent_envelope(
|
||||
console, command="config set", data={"key": key, "value": display}
|
||||
)
|
||||
else:
|
||||
print_success(console, f"{key} = {display}")
|
||||
print_success(console, f"{key} = {display}")
|
||||
else:
|
||||
print_error(err_console, f"Unknown config key: {key}")
|
||||
|
||||
@@ -18,7 +18,7 @@ from mem0_cli.branding import (
|
||||
print_success,
|
||||
timed_status,
|
||||
)
|
||||
from mem0_cli.output import format_agent_envelope, format_json
|
||||
from mem0_cli.output import format_json
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
@@ -26,16 +26,9 @@ err_console = Console(stderr=True)
|
||||
|
||||
def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> None:
|
||||
"""List entities of a given type."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("entity list")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
valid_types = {"users", "agents", "apps", "runs"}
|
||||
if entity_type not in valid_types:
|
||||
print_error(
|
||||
err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}"
|
||||
)
|
||||
print_error(err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
@@ -47,16 +40,6 @@ def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> Non
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="entity list",
|
||||
data=results,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
return
|
||||
@@ -92,39 +75,39 @@ def cmd_entities_delete(
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Delete an entity and all its memories (cascade delete)."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("entity delete")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if not force:
|
||||
print_error(err_console, "Destructive operation requires --force in agent mode.")
|
||||
raise typer.Exit(1)
|
||||
if not any([user_id, agent_id, app_id, run_id]):
|
||||
print_error(
|
||||
err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id."
|
||||
)
|
||||
print_error(err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
scope_parts = []
|
||||
if user_id:
|
||||
scope_parts.append(f"user={user_id}")
|
||||
if agent_id:
|
||||
scope_parts.append(f"agent={agent_id}")
|
||||
if app_id:
|
||||
scope_parts.append(f"app={app_id}")
|
||||
if run_id:
|
||||
scope_parts.append(f"run={run_id}")
|
||||
scope_str = ", ".join(scope_parts)
|
||||
|
||||
if dry_run:
|
||||
print_info(console, f"Would delete entity {scope_str} and all its memories.")
|
||||
scope_parts = []
|
||||
if user_id:
|
||||
scope_parts.append(f"user={user_id}")
|
||||
if agent_id:
|
||||
scope_parts.append(f"agent={agent_id}")
|
||||
if app_id:
|
||||
scope_parts.append(f"app={app_id}")
|
||||
if run_id:
|
||||
scope_parts.append(f"run={run_id}")
|
||||
scope = ", ".join(scope_parts)
|
||||
print_info(console, f"Would delete entity {scope} and all its memories.")
|
||||
print_info(console, "No changes made (dry run).")
|
||||
return
|
||||
|
||||
if not force:
|
||||
scope_parts = []
|
||||
if user_id:
|
||||
scope_parts.append(f"user={user_id}")
|
||||
if agent_id:
|
||||
scope_parts.append(f"agent={agent_id}")
|
||||
if app_id:
|
||||
scope_parts.append(f"app={app_id}")
|
||||
if run_id:
|
||||
scope_parts.append(f"run={run_id}")
|
||||
scope = ", ".join(scope_parts)
|
||||
|
||||
confirm = typer.confirm(
|
||||
f"\n \u26a0 Delete entity {scope_str} AND all its memories? This cannot be undone."
|
||||
f"\n \u26a0 Delete entity {scope} AND all its memories? This cannot be undone."
|
||||
)
|
||||
if not confirm:
|
||||
print_info(console, "Cancelled.")
|
||||
@@ -144,25 +127,7 @@ def cmd_entities_delete(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="entity delete",
|
||||
data={"deleted": True},
|
||||
scope=scope or None,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
if output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)")
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Event commands: list and status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.branding import (
|
||||
ACCENT_COLOR,
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
ERROR_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
WARNING_COLOR,
|
||||
print_info,
|
||||
timed_status,
|
||||
)
|
||||
from mem0_cli.output import format_agent_envelope, format_json
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_STATUS_STYLE = {
|
||||
"SUCCEEDED": f"[{SUCCESS_COLOR}]SUCCEEDED[/]",
|
||||
"PENDING": f"[{ACCENT_COLOR}]PENDING[/]",
|
||||
"FAILED": f"[{ERROR_COLOR}]FAILED[/]",
|
||||
"PROCESSING": f"[{WARNING_COLOR}]PROCESSING[/]",
|
||||
}
|
||||
|
||||
|
||||
def _status_styled(status: str) -> str:
|
||||
return _STATUS_STYLE.get(status.upper(), status)
|
||||
|
||||
|
||||
def cmd_event_list(backend: Backend, *, output: str = "table") -> None:
|
||||
"""List recent background events."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("event list")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
import time as _time
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Fetching events...") as _ts:
|
||||
try:
|
||||
results = backend.list_events()
|
||||
except Exception as e:
|
||||
_ts.error_msg = str(e)
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="event list",
|
||||
data=results,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
return
|
||||
|
||||
if not results:
|
||||
console.print()
|
||||
print_info(console, "No events found.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
table = Table(
|
||||
border_style=BRAND_COLOR,
|
||||
header_style=f"bold {ACCENT_COLOR}",
|
||||
row_styles=["", "dim"],
|
||||
padding=(0, 1),
|
||||
)
|
||||
table.add_column("Event ID", style="dim", max_width=10, no_wrap=True)
|
||||
table.add_column("Type", max_width=14)
|
||||
table.add_column("Status", max_width=12)
|
||||
table.add_column("Latency", max_width=10, justify="right")
|
||||
table.add_column("Created", max_width=20)
|
||||
|
||||
for ev in results:
|
||||
ev_id = str(ev.get("id", ""))[:8]
|
||||
ev_type = str(ev.get("event_type", "—"))
|
||||
status = str(ev.get("status", "—"))
|
||||
latency = ev.get("latency")
|
||||
latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else "—"
|
||||
created = str(ev.get("created_at", "—"))[:19].replace("T", " ")
|
||||
table.add_row(ev_id, ev_type, _status_styled(status), latency_str, created)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f" [{DIM_COLOR}]{len(results)} event{'s' if len(results) != 1 else ''}[/]")
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_event_status(backend: Backend, event_id: str, *, output: str = "text") -> None:
|
||||
"""Get the status of a specific background event."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("event status")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
import time as _time
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Fetching event...") as _ts:
|
||||
try:
|
||||
ev = backend.get_event(event_id)
|
||||
except Exception as e:
|
||||
_ts.error_msg = str(e)
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="event status",
|
||||
data=ev,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, ev)
|
||||
return
|
||||
|
||||
status = str(ev.get("status", "—"))
|
||||
ev_type = str(ev.get("event_type", "—"))
|
||||
latency = ev.get("latency")
|
||||
latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else "—"
|
||||
created = str(ev.get("created_at", "—"))[:19].replace("T", " ")
|
||||
updated = str(ev.get("updated_at", "—"))[:19].replace("T", " ")
|
||||
results = ev.get("results")
|
||||
|
||||
lines = []
|
||||
lines.append(f" [{DIM_COLOR}]Event ID:[/] {event_id}")
|
||||
lines.append(f" [{DIM_COLOR}]Type:[/] {ev_type}")
|
||||
lines.append(f" [{DIM_COLOR}]Status:[/] {_status_styled(status)}")
|
||||
lines.append(f" [{DIM_COLOR}]Latency:[/] {latency_str}")
|
||||
lines.append(f" [{DIM_COLOR}]Created:[/] {created}")
|
||||
lines.append(f" [{DIM_COLOR}]Updated:[/] {updated}")
|
||||
|
||||
if results:
|
||||
lines.append("")
|
||||
lines.append(f" [{DIM_COLOR}]Results ({len(results)}):[/]")
|
||||
for r in results:
|
||||
mem_id = str(r.get("id", ""))[:8]
|
||||
data = r.get("data", {})
|
||||
memory = data.get("memory", "") if isinstance(data, dict) else str(data)
|
||||
ev_name = str(r.get("event", ""))
|
||||
user = str(r.get("user_id", ""))
|
||||
detail = f"{ev_name} {memory}"
|
||||
if user:
|
||||
detail += f" [{DIM_COLOR}](user_id={user})[/]"
|
||||
lines.append(f" [{SUCCESS_COLOR}]·[/] {detail} [{DIM_COLOR}]({mem_id})[/]")
|
||||
|
||||
content = "\n".join(lines)
|
||||
panel = Panel(
|
||||
content,
|
||||
title=f"[{BRAND_COLOR}]Event Status[/]",
|
||||
title_align="left",
|
||||
border_style=BRAND_COLOR,
|
||||
padding=(1, 1),
|
||||
)
|
||||
console.print()
|
||||
console.print(panel)
|
||||
console.print()
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
@@ -19,7 +16,7 @@ from mem0_cli.branding import (
|
||||
print_info,
|
||||
print_success,
|
||||
)
|
||||
from mem0_cli.config import CONFIG_FILE, DEFAULT_BASE_URL, Mem0Config, load_config, save_config
|
||||
from mem0_cli.config import Mem0Config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
@@ -87,90 +84,7 @@ def _prompt_secret(label: str) -> str:
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _validate_email(email: str) -> None:
|
||||
"""Exit with an error if *email* doesn't look like a valid address."""
|
||||
if not _EMAIL_RE.match(email):
|
||||
print_error(err_console, f"Invalid email address: {email!r}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _email_login(
|
||||
email: str,
|
||||
code: str | None,
|
||||
base_url: str,
|
||||
) -> dict:
|
||||
"""Run the email verification code login flow.
|
||||
|
||||
Returns the parsed JSON response from the verify endpoint.
|
||||
The caller expects at minimum an ``api_key`` field.
|
||||
"""
|
||||
url = base_url.rstrip("/")
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# If code is already provided, skip sending — user already has a code
|
||||
if not code:
|
||||
# Step 1: Request verification code
|
||||
resp = client.post(
|
||||
f"{url}/api/v1/auth/email_code/",
|
||||
json={"email": email},
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
print_error(err_console, f"Failed to send code: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_success(console, "Verification code sent! Check your email.")
|
||||
|
||||
# Step 2: Get code from user
|
||||
if not sys.stdin.isatty():
|
||||
print_error(
|
||||
err_console,
|
||||
"No --code provided and terminal is non-interactive.",
|
||||
hint="Run: mem0 init --email <email> --code <code>",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print()
|
||||
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
|
||||
if not code:
|
||||
print_error(err_console, "Code is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Step 3: Verify code
|
||||
resp = client.post(
|
||||
f"{url}/api/v1/auth/email_code/verify/",
|
||||
json={"email": email, "code": code.strip()},
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
print_error(err_console, f"Verification failed: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
def run_init(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
user_id: str | None = None,
|
||||
email: str | None = None,
|
||||
code: str | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None:
|
||||
"""Interactive setup wizard for mem0 CLI.
|
||||
|
||||
When both *api_key* and *user_id* are supplied, all prompts are skipped
|
||||
@@ -179,84 +93,6 @@ def run_init(
|
||||
"""
|
||||
config = Mem0Config()
|
||||
|
||||
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
|
||||
|
||||
if code and not email:
|
||||
print_error(err_console, "--code requires --email.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Warn if an existing config with an API key would be overwritten
|
||||
if not force and CONFIG_FILE.exists():
|
||||
existing = load_config()
|
||||
if existing.platform.api_key:
|
||||
from mem0_cli.config import redact_key
|
||||
|
||||
console.print(
|
||||
f"\n [{BRAND_COLOR}]Existing configuration found[/] "
|
||||
f"[{DIM_COLOR}](API key: {redact_key(existing.platform.api_key)})[/]"
|
||||
)
|
||||
if sys.stdin.isatty():
|
||||
confirm = typer.confirm(" Overwrite existing config? This cannot be undone.")
|
||||
if not confirm:
|
||||
print_info(console, "Cancelled. Use --force to skip this check.")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
print_error(
|
||||
err_console,
|
||||
"Existing config would be overwritten.",
|
||||
hint="Use --force to overwrite.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# ── Email login flow ──────────────────────────────────────────────
|
||||
if email:
|
||||
if api_key:
|
||||
print_error(err_console, "Cannot use both --api-key and --email.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
email = email.strip().lower()
|
||||
_validate_email(email)
|
||||
|
||||
print_banner(console)
|
||||
console.print()
|
||||
print_info(console, f"Logging in as {email}...\n")
|
||||
|
||||
result = _email_login(email, code, base_url)
|
||||
|
||||
api_key_val = result.get("api_key")
|
||||
if not api_key_val:
|
||||
print_error(err_console, "Auth succeeded but no API key was returned. Contact support.")
|
||||
raise typer.Exit(1)
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
|
||||
save_config(config)
|
||||
|
||||
console.print()
|
||||
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
|
||||
console.print()
|
||||
console.print(f" [{DIM_COLOR}]Get started:[/]")
|
||||
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
|
||||
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
|
||||
console.print()
|
||||
return
|
||||
|
||||
# ── API key flow (existing) ───────────────────────────────────────
|
||||
|
||||
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||
if not sys.stdin.isatty():
|
||||
if not api_key:
|
||||
print_error(
|
||||
err_console,
|
||||
"Non-interactive terminal detected and --api-key is required.",
|
||||
hint="Run: mem0 init --api-key <key> [--user-id <id>]",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
|
||||
# Fully non-interactive when both flags provided
|
||||
if api_key and user_id:
|
||||
config.platform.api_key = api_key
|
||||
@@ -266,55 +102,20 @@ def run_init(
|
||||
print_success(console, "Configuration saved to ~/.mem0/config.json")
|
||||
return
|
||||
|
||||
# Non-TTY without full flags -> error
|
||||
if not sys.stdin.isatty() and (not api_key or not user_id):
|
||||
print_error(
|
||||
err_console,
|
||||
"Non-interactive terminal detected and required flags missing.",
|
||||
hint="Run: mem0 init --api-key <key> --user-id <id>",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_banner(console)
|
||||
console.print()
|
||||
print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
|
||||
|
||||
# If no flags at all, ask user how they want to authenticate
|
||||
if not api_key:
|
||||
console.print(f" [{BRAND_COLOR}]How would you like to authenticate?[/]")
|
||||
console.print(f" [{DIM_COLOR}]1.[/] Login with email [{DIM_COLOR}](recommended)[/]")
|
||||
console.print(f" [{DIM_COLOR}]2.[/] Enter API key manually")
|
||||
console.print()
|
||||
choice = Prompt.ask(f" [{BRAND_COLOR}]Choose[/]", choices=["1", "2"], default="1")
|
||||
|
||||
if choice == "1":
|
||||
console.print()
|
||||
email_addr = Prompt.ask(f" [{BRAND_COLOR}]Email[/]")
|
||||
if not email_addr:
|
||||
print_error(err_console, "Email is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
email_addr = email_addr.strip().lower()
|
||||
_validate_email(email_addr)
|
||||
print_info(console, f"Logging in as {email_addr}...\n")
|
||||
|
||||
result = _email_login(email_addr, None, base_url)
|
||||
|
||||
api_key_val = result.get("api_key")
|
||||
if not api_key_val:
|
||||
print_error(
|
||||
err_console, "Auth succeeded but no API key was returned. Contact support."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
|
||||
save_config(config)
|
||||
|
||||
console.print()
|
||||
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
|
||||
console.print()
|
||||
console.print(f" [{DIM_COLOR}]Get started:[/]")
|
||||
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
|
||||
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
|
||||
console.print()
|
||||
return
|
||||
|
||||
# API key flow
|
||||
# Use provided flags or prompt
|
||||
if api_key:
|
||||
config.platform.api_key = api_key
|
||||
else:
|
||||
@@ -361,10 +162,9 @@ def _setup_defaults(config: Mem0Config) -> None:
|
||||
console.print()
|
||||
print_info(console, "Set default entity IDs (press Enter to skip).\n")
|
||||
|
||||
_default_user = os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
user_id = Prompt.ask(
|
||||
f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]",
|
||||
default=_default_user,
|
||||
default="mem0-cli",
|
||||
)
|
||||
if user_id:
|
||||
config.defaults.user_id = user_id
|
||||
@@ -388,7 +188,7 @@ def _validate_platform(config: Mem0Config) -> None:
|
||||
print_error(
|
||||
err_console,
|
||||
f"Could not connect: {status.get('error', 'Unknown error')}",
|
||||
hint="Visit https://app.mem0.ai/dashboard/api-keys to get a new key, then run mem0 init again.",
|
||||
hint="Check your API key and try again.",
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, f"Connection test failed: {e}")
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat as _stat_mod
|
||||
import sys
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
@@ -22,7 +20,6 @@ from mem0_cli.branding import (
|
||||
)
|
||||
from mem0_cli.output import (
|
||||
format_add_result,
|
||||
format_agent_envelope,
|
||||
format_json,
|
||||
format_memories_table,
|
||||
format_memories_text,
|
||||
@@ -34,19 +31,6 @@ console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def _stdin_is_piped() -> bool:
|
||||
"""Return True only when stdin is an actual pipe or file redirect."""
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return False
|
||||
try:
|
||||
mode = os.fstat(sys.stdin.fileno()).st_mode
|
||||
return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_add(
|
||||
backend: Backend,
|
||||
text: str | None,
|
||||
@@ -66,11 +50,6 @@ def cmd_add(
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Add a memory."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("add")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
msgs = None
|
||||
content = text
|
||||
|
||||
@@ -91,8 +70,8 @@ def cmd_add(
|
||||
print_error(err_console, f"Invalid JSON in --messages: {e}")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
# Read from stdin only if stdin is an actual pipe or file redirect
|
||||
elif not content and _stdin_is_piped():
|
||||
# Read from stdin if no text and stdin is piped
|
||||
elif not content and not sys.stdin.isatty():
|
||||
content = sys.stdin.read().strip()
|
||||
|
||||
if not content and not msgs:
|
||||
@@ -116,21 +95,6 @@ def cmd_add(
|
||||
except json.JSONDecodeError:
|
||||
cats = [c.strip() for c in categories.split(",")]
|
||||
|
||||
# Validate --expires
|
||||
if expires:
|
||||
import re
|
||||
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
|
||||
print_error(
|
||||
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
from datetime import date
|
||||
|
||||
if date.fromisoformat(expires) <= date.today():
|
||||
print_error(err_console, "--expires date must be in the future.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with timed_status(err_console, "Adding memory...") as ts:
|
||||
try:
|
||||
result = backend.add(
|
||||
@@ -149,66 +113,24 @@ def cmd_add(
|
||||
)
|
||||
except Exception as e:
|
||||
ts.error_msg = str(e)
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
# Deduplicate PENDING entries sharing the same event_id across all output modes
|
||||
results_list = result if isinstance(result, list) else result.get("results", [result])
|
||||
seen_events: set[str] = set()
|
||||
deduped: list[dict] = []
|
||||
for r in results_list:
|
||||
if r.get("status") == "PENDING":
|
||||
eid = r.get("event_id", "")
|
||||
if eid and eid in seen_events:
|
||||
continue
|
||||
if eid:
|
||||
seen_events.add(eid)
|
||||
deduped.append(r)
|
||||
# Write back so downstream formatters see deduplicated data
|
||||
if isinstance(result, dict) and "results" in result:
|
||||
result = {**result, "results": deduped}
|
||||
else:
|
||||
result = deduped
|
||||
|
||||
if output == "agent":
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="add",
|
||||
data=deduped,
|
||||
scope=scope or None,
|
||||
count=len(deduped),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_add_result(console, result, output)
|
||||
return
|
||||
|
||||
console.print()
|
||||
print_scope(console, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
||||
count = len(deduped)
|
||||
all_pending = count > 0 and all(r.get("status") == "PENDING" for r in deduped)
|
||||
if all_pending:
|
||||
print_success(
|
||||
console,
|
||||
f"Memory queued — {count} event{'s' if count != 1 else ''} pending",
|
||||
)
|
||||
else:
|
||||
print_success(
|
||||
console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted"
|
||||
)
|
||||
# Count results
|
||||
results = result if isinstance(result, list) else result.get("results", [result])
|
||||
count = len(results) if results else 0
|
||||
print_success(
|
||||
console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted"
|
||||
)
|
||||
format_add_result(console, result, output)
|
||||
|
||||
|
||||
@@ -230,11 +152,6 @@ def cmd_search(
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Search memories."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("search")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
filters = None
|
||||
if filter_json:
|
||||
try:
|
||||
@@ -247,13 +164,6 @@ def cmd_search(
|
||||
if fields:
|
||||
field_list = [f.strip() for f in fields.split(",")]
|
||||
|
||||
if top_k < 1:
|
||||
print_error(err_console, "--top-k must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
if not (0.0 <= threshold <= 1.0):
|
||||
print_error(err_console, "--threshold must be between 0.0 and 1.0.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Searching memories...") as _ts:
|
||||
try:
|
||||
@@ -276,35 +186,11 @@ def cmd_search(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
if output == "agent":
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="search",
|
||||
data=results,
|
||||
scope=scope or None,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
elif output == "table":
|
||||
if results:
|
||||
format_memories_table(console, results, show_score=True)
|
||||
format_memories_table(console, results)
|
||||
print_result_summary(
|
||||
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
|
||||
)
|
||||
@@ -326,11 +212,6 @@ def cmd_search(
|
||||
|
||||
def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
|
||||
"""Get a specific memory by ID."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("get")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
with timed_status(err_console, "Fetching memory...") as _ts:
|
||||
try:
|
||||
result = backend.get(memory_id)
|
||||
@@ -338,10 +219,7 @@ def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(console, command="get", data=result)
|
||||
else:
|
||||
format_single_memory(console, result, output)
|
||||
format_single_memory(console, result, output)
|
||||
|
||||
|
||||
def cmd_list(
|
||||
@@ -360,18 +238,6 @@ def cmd_list(
|
||||
output: str = "table",
|
||||
) -> None:
|
||||
"""List memories."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("list")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if page_size < 1:
|
||||
print_error(err_console, "--page-size must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
if page < 1:
|
||||
print_error(err_console, "--page must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Listing memories...") as _ts:
|
||||
try:
|
||||
@@ -392,28 +258,8 @@ def cmd_list(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
if output in ("json", "agent"):
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="list",
|
||||
data=results,
|
||||
scope=scope or None,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
elif output == "table":
|
||||
if results:
|
||||
format_memories_table(console, results)
|
||||
@@ -455,11 +301,6 @@ def cmd_update(
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Update a memory."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("update")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
meta = None
|
||||
if metadata:
|
||||
try:
|
||||
@@ -477,14 +318,7 @@ def cmd_update(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="update",
|
||||
data=result,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
if output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)")
|
||||
@@ -499,11 +333,6 @@ def cmd_delete(
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Delete a single memory by ID."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("delete")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if dry_run:
|
||||
# Fetch and display what would be deleted
|
||||
try:
|
||||
@@ -524,14 +353,7 @@ def cmd_delete(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="delete",
|
||||
data={"id": memory_id, "deleted": True},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
if output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)")
|
||||
@@ -550,17 +372,12 @@ def cmd_delete_all(
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Delete all memories matching a scope."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("delete-all")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if not force:
|
||||
print_error(err_console, "Destructive operation requires --force in agent mode.")
|
||||
raise typer.Exit(1)
|
||||
if all_:
|
||||
# Project-wide wipe using wildcard entity IDs
|
||||
# Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
|
||||
if dry_run:
|
||||
print_info(console, "Would delete ALL memories project-wide.")
|
||||
print_info(console, "No changes made (dry run).")
|
||||
return
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm(
|
||||
@@ -585,14 +402,7 @@ def cmd_delete_all(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="delete-all",
|
||||
data={"deleted": True, "scope": "project"},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
if output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
if isinstance(result, dict) and "message" in result:
|
||||
@@ -650,25 +460,7 @@ def cmd_delete_all(
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="delete-all",
|
||||
data={"deleted": True},
|
||||
scope=scope or None,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
if output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
if isinstance(result, dict) and "message" in result:
|
||||
|
||||
@@ -35,26 +35,22 @@ def cmd_status(
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Check connectivity and auth."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("status")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
from mem0_cli.output import format_json_envelope
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Checking connection...") as _ts:
|
||||
result = backend.status(user_id=user_id, agent_id=agent_id)
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output in ("json", "agent"):
|
||||
format_agent_envelope(
|
||||
if output == "json":
|
||||
format_json_envelope(
|
||||
console,
|
||||
command="status",
|
||||
data={
|
||||
"connected": result.get("connected", False),
|
||||
"backend": result.get("backend", "?"),
|
||||
"base_url": result.get("base_url", ""),
|
||||
"latency_ms": int(_elapsed * 1000),
|
||||
},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
@@ -71,14 +67,6 @@ def cmd_status(
|
||||
lines.append(f" [{DIM_COLOR}]API URL:[/] {result['base_url']}")
|
||||
if result.get("error"):
|
||||
lines.append(f" [{ERROR_COLOR}]Error:[/] {result['error']}")
|
||||
if "Authentication failed" in str(result["error"]):
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key[/]"
|
||||
)
|
||||
lines.append(
|
||||
f" [{DIM_COLOR}]Get a key at [bold]https://app.mem0.ai/dashboard/api-keys[/bold][/]"
|
||||
)
|
||||
lines.append(f" [{DIM_COLOR}]Latency:[/] {_elapsed:.2f}s")
|
||||
|
||||
content = "\n".join(lines)
|
||||
@@ -108,12 +96,7 @@ def cmd_import(
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Import memories from a JSON file."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("import")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
from mem0_cli.output import format_json_envelope
|
||||
|
||||
try:
|
||||
data = json.loads(Path(file_path).read_text())
|
||||
@@ -127,9 +110,7 @@ def cmd_import(
|
||||
added = 0
|
||||
failed = 0
|
||||
_start = _time.perf_counter()
|
||||
for item in track(
|
||||
data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console
|
||||
):
|
||||
for item in track(data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console):
|
||||
content = item.get("memory", item.get("text", item.get("content", "")))
|
||||
if not content:
|
||||
failed += 1
|
||||
@@ -146,13 +127,11 @@ def cmd_import(
|
||||
failed += 1
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output in ("json", "agent"):
|
||||
scope = {k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v}
|
||||
format_agent_envelope(
|
||||
if output == "json":
|
||||
format_json_envelope(
|
||||
console,
|
||||
command="import",
|
||||
data={"added": added, "failed": failed},
|
||||
scope=scope or None,
|
||||
data={"added": added, "failed": failed, "duration_s": round(_elapsed, 2)},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -45,17 +45,6 @@ class Mem0Config:
|
||||
platform: PlatformConfig = field(default_factory=PlatformConfig)
|
||||
|
||||
|
||||
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||
"api_key": "platform.api_key",
|
||||
"base_url": "platform.base_url",
|
||||
"user_id": "defaults.user_id",
|
||||
"agent_id": "defaults.agent_id",
|
||||
"app_id": "defaults.app_id",
|
||||
"run_id": "defaults.run_id",
|
||||
"enable_graph": "defaults.enable_graph",
|
||||
}
|
||||
|
||||
|
||||
def ensure_config_dir() -> Path:
|
||||
"""Create ~/.mem0 directory with secure permissions if it doesn't exist."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -151,8 +140,7 @@ def redact_key(key: str) -> str:
|
||||
|
||||
|
||||
def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
|
||||
"""Get a config value by dotted path, e.g. 'platform.api_key' or short form 'api_key'."""
|
||||
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
|
||||
"""Get a config value by dotted path, e.g. 'platform.api_key'."""
|
||||
parts = dotted_key.split(".")
|
||||
obj: Any = config
|
||||
for part in parts:
|
||||
@@ -165,7 +153,6 @@ def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
|
||||
|
||||
def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool:
|
||||
"""Set a config value by dotted path. Returns True on success."""
|
||||
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
|
||||
parts = dotted_key.split(".")
|
||||
obj: Any = config
|
||||
for part in parts[:-1]:
|
||||
|
||||
@@ -49,9 +49,7 @@ def format_memories_text(console: Console, memories: list[dict], title: str = "m
|
||||
console.print()
|
||||
|
||||
|
||||
def format_memories_table(
|
||||
console: Console, memories: list[dict], *, show_score: bool = False
|
||||
) -> None:
|
||||
def format_memories_table(console: Console, memories: list[dict]) -> None:
|
||||
"""Render memories in a rich table."""
|
||||
table = Table(
|
||||
border_style=BRAND_COLOR,
|
||||
@@ -59,34 +57,20 @@ def format_memories_table(
|
||||
row_styles=["", "dim"],
|
||||
padding=(0, 1),
|
||||
)
|
||||
table.add_column("ID", style="dim", max_width=38, no_wrap=True)
|
||||
if show_score:
|
||||
table.add_column("Score", max_width=7, justify="right")
|
||||
table.add_column("ID", style="dim", max_width=10)
|
||||
table.add_column("Memory", max_width=50, no_wrap=False)
|
||||
table.add_column("Category", max_width=14)
|
||||
table.add_column("Created", max_width=12)
|
||||
|
||||
for mem in memories:
|
||||
mem_id = mem.get("id", "")
|
||||
mem_id = mem.get("id", "")[:8]
|
||||
memory_text = mem.get("memory", mem.get("text", ""))
|
||||
if len(memory_text) > 60:
|
||||
memory_text = memory_text[:57] + "..."
|
||||
categories = mem.get("categories", [])
|
||||
if isinstance(categories, list) and categories:
|
||||
cat = (
|
||||
categories[0]
|
||||
if len(categories) == 1
|
||||
else f"{categories[0]} (+{len(categories) - 1})"
|
||||
)
|
||||
else:
|
||||
cat = "—"
|
||||
cat = categories[0] if isinstance(categories, list) and categories else "—"
|
||||
created = _format_date(mem.get("created_at")) or "—"
|
||||
if show_score:
|
||||
score = mem.get("score")
|
||||
score_str = f"{score:.2f}" if score is not None else "—"
|
||||
table.add_row(mem_id, score_str, memory_text, cat, created)
|
||||
else:
|
||||
table.add_row(mem_id, memory_text, cat, created)
|
||||
table.add_row(mem_id, memory_text, cat, created)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
@@ -155,23 +139,16 @@ def format_add_result(console: Console, result: dict | list, output: str = "text
|
||||
return
|
||||
|
||||
console.print()
|
||||
seen_pending_events: set[str] = set()
|
||||
for r in results:
|
||||
# Detect async PENDING response from Platform API
|
||||
if r.get("status") == "PENDING":
|
||||
event_id = r.get("event_id", "")
|
||||
# Deduplicate PENDING entries with the same event_id
|
||||
if event_id and event_id in seen_pending_events:
|
||||
continue
|
||||
if event_id:
|
||||
seen_pending_events.add(event_id)
|
||||
event_id = r.get("event_id", "")[:8]
|
||||
icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]"
|
||||
parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"]
|
||||
parts.append("[white]Processing in background[/]")
|
||||
console.print(" ".join(parts))
|
||||
if event_id:
|
||||
console.print(f" [{DIM_COLOR}] event_id: {event_id}[/]")
|
||||
console.print(f" [{DIM_COLOR}] → Check status: mem0 event status {event_id}[/]")
|
||||
parts.append(f"[{DIM_COLOR}](event {event_id})[/]")
|
||||
console.print(" ".join(parts))
|
||||
continue
|
||||
|
||||
event = r.get("event", "ADD")
|
||||
@@ -232,100 +209,6 @@ def format_json_envelope(
|
||||
console.print_json(json.dumps(envelope, default=str))
|
||||
|
||||
|
||||
def sanitize_agent_data(command: str, data: Any) -> Any:
|
||||
"""Project API response data to minimal relevant fields for agent consumption."""
|
||||
|
||||
def pick(obj: dict, keys: list) -> dict:
|
||||
return {k: obj[k] for k in keys if k in obj}
|
||||
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
if command == "add":
|
||||
items = data if isinstance(data, list) else [data]
|
||||
result = []
|
||||
for item in items:
|
||||
if item.get("status") == "PENDING":
|
||||
result.append(pick(item, ["status", "event_id"]))
|
||||
else:
|
||||
result.append(pick(item, ["id", "memory", "event"]))
|
||||
return result
|
||||
|
||||
if command == "search":
|
||||
return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data]
|
||||
|
||||
if command == "list":
|
||||
return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data]
|
||||
|
||||
if command == "get":
|
||||
return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"])
|
||||
|
||||
if command == "update":
|
||||
return pick(data, ["id", "memory"])
|
||||
|
||||
if command in ("delete", "delete-all", "entity delete"):
|
||||
return data
|
||||
|
||||
if command == "entity list":
|
||||
result = []
|
||||
for r in data:
|
||||
item = pick(r, ["type", "count"])
|
||||
item["name"] = r.get("name") or r.get("id", "")
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
if command == "event list":
|
||||
return [pick(r, ["id", "event_type", "status", "latency", "created_at"]) for r in data]
|
||||
|
||||
if command == "event status":
|
||||
ev = data
|
||||
raw_results = ev.get("results") or []
|
||||
sanitized_results = []
|
||||
for r in raw_results:
|
||||
nested = r.get("data") or {}
|
||||
memory = nested.get("memory") if isinstance(nested, dict) else None
|
||||
sanitized_results.append(
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"event": r.get("event"),
|
||||
"user_id": r.get("user_id"),
|
||||
"memory": memory,
|
||||
}
|
||||
)
|
||||
result = pick(ev, ["id", "event_type", "status", "latency", "created_at", "updated_at"])
|
||||
result["results"] = sanitized_results
|
||||
return result
|
||||
|
||||
# Pass-through: status, import, config show/get/set
|
||||
return data
|
||||
|
||||
|
||||
def format_agent_envelope(
|
||||
console: Console,
|
||||
*,
|
||||
command: str,
|
||||
data: Any,
|
||||
duration_ms: int | None = None,
|
||||
scope: dict | None = None,
|
||||
count: int | None = None,
|
||||
) -> None:
|
||||
"""Output structured JSON envelope for agent/programmatic use (--json/--agent mode)."""
|
||||
envelope: dict[str, Any] = {
|
||||
"status": "success",
|
||||
"command": command,
|
||||
}
|
||||
if duration_ms is not None:
|
||||
envelope["duration_ms"] = duration_ms
|
||||
if scope:
|
||||
filtered = {k: v for k, v in scope.items() if v}
|
||||
if filtered:
|
||||
envelope["scope"] = filtered
|
||||
if count is not None:
|
||||
envelope["count"] = count
|
||||
envelope["data"] = sanitize_agent_data(command, data)
|
||||
console.print_json(json.dumps(envelope, default=str))
|
||||
|
||||
|
||||
def print_result_summary(
|
||||
console: Console,
|
||||
count: int,
|
||||
@@ -338,7 +221,7 @@ def print_result_summary(
|
||||
parts = [f"{count} result{'s' if count != 1 else ''}"]
|
||||
if page is not None:
|
||||
parts.append(f"page {page}")
|
||||
scope_parts = [f"{k}={v}" for k, v in scope_ids.items() if v]
|
||||
scope_parts = [f"{k.replace('_', ' ')}={v}" for k, v in scope_ids.items() if v]
|
||||
if scope_parts:
|
||||
parts.append(", ".join(scope_parts))
|
||||
if duration_secs is not None:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Agent mode state — set by the root callback, read by commands and branding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_agent_mode: bool = False
|
||||
_current_command: str = ""
|
||||
|
||||
|
||||
def is_agent_mode() -> bool:
|
||||
return _agent_mode
|
||||
|
||||
|
||||
def set_agent_mode(val: bool) -> None:
|
||||
global _agent_mode
|
||||
_agent_mode = val
|
||||
|
||||
|
||||
def get_current_command() -> str:
|
||||
return _current_command
|
||||
|
||||
|
||||
def set_current_command(name: str) -> None:
|
||||
global _current_command
|
||||
_current_command = name
|
||||
@@ -96,43 +96,6 @@ def mock_backend():
|
||||
{"name": "alice", "count": 5},
|
||||
{"name": "bob", "count": 3},
|
||||
]
|
||||
backend.list_events.return_value = [
|
||||
{
|
||||
"id": "evt-abc-123-def-456",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"graph_status": None,
|
||||
"latency": 1234.5,
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
"updated_at": "2026-04-01T10:00:01Z",
|
||||
},
|
||||
{
|
||||
"id": "evt-def-456-ghi-789",
|
||||
"event_type": "SEARCH",
|
||||
"status": "PENDING",
|
||||
"graph_status": None,
|
||||
"latency": None,
|
||||
"created_at": "2026-04-01T10:01:00Z",
|
||||
"updated_at": "2026-04-01T10:01:00Z",
|
||||
},
|
||||
]
|
||||
backend.get_event.return_value = {
|
||||
"id": "evt-abc-123-def-456",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"graph_status": "SUCCEEDED",
|
||||
"latency": 1234.5,
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
"updated_at": "2026-04-01T10:00:01Z",
|
||||
"results": [
|
||||
{
|
||||
"id": "mem-abc-123",
|
||||
"event": "ADD",
|
||||
"user_id": "alice",
|
||||
"data": {"memory": "User prefers dark mode"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
return backend
|
||||
|
||||
|
||||
@@ -8,19 +8,11 @@ boundaries).
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes so substring checks work regardless of color mode."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def _run(
|
||||
args: list[str],
|
||||
@@ -36,35 +28,22 @@ def _run(
|
||||
reads config from ``<home_dir>/.mem0/config.json`` instead
|
||||
of the user's real config. This is critical for tests that
|
||||
depend on a clean (no API key) or custom config state.
|
||||
|
||||
Returns a CompletedProcess whose stdout/stderr have ANSI escape codes
|
||||
stripped. GitHub Actions sets FORCE_COLOR=1 which causes Rich/Typer to
|
||||
fragment option names like --user-id into separately-styled ANSI segments,
|
||||
making plain ``in`` checks fail. Stripping here is version-agnostic and
|
||||
ensures all assertions see the same plain text regardless of terminal env.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
# Strip all MEM0_ env vars so tests start clean
|
||||
for key in list(env.keys()):
|
||||
if key.startswith("MEM0_"):
|
||||
del env[key]
|
||||
env.pop("FORCE_COLOR", None)
|
||||
if home_dir:
|
||||
env["HOME"] = home_dir
|
||||
if env_override:
|
||||
env.update(env_override)
|
||||
result = subprocess.run(
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=result.args,
|
||||
returncode=result.returncode,
|
||||
stdout=_strip_ansi(result.stdout),
|
||||
stderr=_strip_ansi(result.stderr),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -88,6 +67,7 @@ class TestCLIIntegration:
|
||||
assert result.returncode == 0
|
||||
assert "0.1.0" in result.stdout
|
||||
|
||||
|
||||
def test_add_help(self):
|
||||
result = _run(["add", "--help"])
|
||||
assert result.returncode == 0
|
||||
@@ -181,12 +161,7 @@ class TestCLIIsolated:
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert (
|
||||
"memory ID" in combined.lower()
|
||||
or "--all" in combined
|
||||
or "--entity" in combined
|
||||
or "Error" in combined
|
||||
)
|
||||
assert "memory ID" in combined.lower() or "--all" in combined or "--entity" in combined or "Error" in combined
|
||||
|
||||
def test_config_show_clean(self, clean_home):
|
||||
"""config show with no config should still work."""
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import typing
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -17,7 +16,6 @@ from mem0_cli.commands.config_cmd import (
|
||||
cmd_config_show,
|
||||
)
|
||||
from mem0_cli.commands.entities import cmd_entities_delete, cmd_entities_list
|
||||
from mem0_cli.commands.events_cmd import cmd_event_list, cmd_event_status
|
||||
from mem0_cli.commands.memory import (
|
||||
cmd_add,
|
||||
cmd_delete,
|
||||
@@ -64,7 +62,7 @@ class TestAddCommand:
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
@@ -90,7 +88,7 @@ class TestAddCommand:
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
@@ -115,7 +113,7 @@ class TestAddCommand:
|
||||
metadata='{"source": "test"}',
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
@@ -141,7 +139,7 @@ class TestAddCommand:
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="json",
|
||||
)
|
||||
@@ -167,7 +165,7 @@ class TestAddCommand:
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="quiet",
|
||||
)
|
||||
@@ -178,28 +176,30 @@ class TestAddCommand:
|
||||
def test_add_no_content_exits(self, mock_backend):
|
||||
console, _buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
# Patch stdin.isatty to return True so it doesn't try to read stdin
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False),
|
||||
pytest.raises((SystemExit, ClickExit)),
|
||||
patch("mem0_cli.commands.memory.sys") as mock_sys,
|
||||
):
|
||||
cmd_add(
|
||||
mock_backend,
|
||||
None,
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
messages=None,
|
||||
file=None,
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
with pytest.raises((SystemExit, ClickExit)):
|
||||
cmd_add(
|
||||
mock_backend,
|
||||
None,
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
messages=None,
|
||||
file=None,
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
|
||||
def test_add_invalid_metadata_json(self, mock_backend):
|
||||
console, _buf = _make_console()
|
||||
@@ -221,7 +221,7 @@ class TestAddCommand:
|
||||
metadata="not-json",
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
@@ -247,7 +247,7 @@ class TestAddCommand:
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
@@ -272,73 +272,13 @@ class TestAddCommand:
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
expires=None,
|
||||
categories="health,prefs",
|
||||
output="text",
|
||||
)
|
||||
mock_backend.add.assert_called_once()
|
||||
|
||||
|
||||
class TestAddDeduplicatesPending:
|
||||
"""Ensure duplicate PENDING entries with the same event_id are collapsed."""
|
||||
|
||||
DUPLICATE_PENDING: typing.ClassVar[dict] = {
|
||||
"results": [
|
||||
{"status": "PENDING", "event_id": "evt-dup"},
|
||||
{"status": "PENDING", "event_id": "evt-dup"},
|
||||
]
|
||||
}
|
||||
|
||||
def _run_add(self, mock_backend, output):
|
||||
mock_backend.add.return_value = self.DUPLICATE_PENDING
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_add(
|
||||
mock_backend,
|
||||
"test",
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
messages=None,
|
||||
file=None,
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output=output,
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
def test_text_shows_one_pending(self, mock_backend):
|
||||
raw = self._run_add(mock_backend, "text")
|
||||
assert raw.count("Queued") == 1
|
||||
|
||||
def test_json_shows_one_pending(self, mock_backend):
|
||||
raw = self._run_add(mock_backend, "json")
|
||||
data = json.loads(raw)
|
||||
results = data.get("results", data)
|
||||
pending = [r for r in results if r.get("status") == "PENDING"]
|
||||
assert len(pending) == 1
|
||||
|
||||
def test_agent_shows_one_pending(self, mock_backend):
|
||||
from mem0_cli.state import set_agent_mode
|
||||
|
||||
set_agent_mode(True)
|
||||
try:
|
||||
raw = self._run_add(mock_backend, "agent")
|
||||
finally:
|
||||
set_agent_mode(False)
|
||||
data = json.loads(raw)
|
||||
assert data["count"] == 1
|
||||
assert len(data["data"]) == 1
|
||||
|
||||
|
||||
class TestSearchCommand:
|
||||
def test_search_text(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
@@ -678,6 +618,28 @@ class TestDeleteAllCommand:
|
||||
run_id="*",
|
||||
)
|
||||
|
||||
def test_delete_all_project_wide_dry_run(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_delete_all(
|
||||
mock_backend,
|
||||
force=True,
|
||||
all_=True,
|
||||
dry_run=True,
|
||||
user_id=None,
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
output="text",
|
||||
)
|
||||
output = buf.getvalue()
|
||||
assert "project-wide" in output.lower()
|
||||
mock_backend.delete.assert_not_called()
|
||||
|
||||
def test_delete_all_project_wide_async_response(self, mock_backend):
|
||||
mock_backend.delete.return_value = {"message": "Memories deletion started..."}
|
||||
console, buf = _make_console()
|
||||
@@ -926,28 +888,6 @@ class TestEntitiesDeleteCommand:
|
||||
output = buf.getvalue()
|
||||
assert "deleted" in output.lower()
|
||||
|
||||
def test_delete_entity_agent_id(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.entities.console", console),
|
||||
patch("mem0_cli.commands.entities.err_console", err_console),
|
||||
):
|
||||
cmd_entities_delete(
|
||||
mock_backend,
|
||||
user_id=None,
|
||||
agent_id="bot1",
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
force=True,
|
||||
output="text",
|
||||
)
|
||||
mock_backend.delete_entities.assert_called_once_with(
|
||||
user_id=None, agent_id="bot1", app_id=None, run_id=None
|
||||
)
|
||||
output = buf.getvalue()
|
||||
assert "deleted" in output.lower()
|
||||
|
||||
def test_delete_entity_no_id_exits(self, mock_backend):
|
||||
console, _buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
@@ -1084,388 +1024,3 @@ class TestEnableGraph:
|
||||
)
|
||||
call_kwargs = mock_backend.list_memories.call_args
|
||||
assert call_kwargs.kwargs.get("enable_graph") is True
|
||||
|
||||
|
||||
class TestEventCommands:
|
||||
def test_event_list_table(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_list(mock_backend, output="table")
|
||||
out = buf.getvalue()
|
||||
assert "evt-abc-" in out
|
||||
assert "ADD" in out
|
||||
assert "SUCCEEDED" in out
|
||||
|
||||
def test_event_list_json(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_list(mock_backend, output="json")
|
||||
out = buf.getvalue()
|
||||
assert "evt-abc-123-def-456" in out
|
||||
assert "evt-def-456-ghi-789" in out
|
||||
|
||||
def test_event_list_empty(self, mock_backend):
|
||||
mock_backend.list_events.return_value = []
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_list(mock_backend, output="table")
|
||||
out = buf.getvalue()
|
||||
assert "No events" in out
|
||||
|
||||
def test_event_status_text(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text")
|
||||
out = buf.getvalue()
|
||||
assert "evt-abc-123-def-456" in out
|
||||
assert "SUCCEEDED" in out
|
||||
|
||||
def test_event_status_json(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="json")
|
||||
out = buf.getvalue()
|
||||
assert "evt-abc-123-def-456" in out
|
||||
assert "ADD" in out
|
||||
|
||||
|
||||
class TestAgentMode:
|
||||
"""Tests for --json/--agent mode: structured JSON envelope output."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Enable agent mode before each test."""
|
||||
from mem0_cli.state import set_agent_mode
|
||||
|
||||
set_agent_mode(True)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Reset agent mode after each test."""
|
||||
from mem0_cli.state import set_agent_mode
|
||||
|
||||
set_agent_mode(False)
|
||||
|
||||
# ── add ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_add(
|
||||
mock_backend,
|
||||
"I prefer dark mode",
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
messages=None,
|
||||
file=None,
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text", # will be overridden to "agent"
|
||||
)
|
||||
raw = buf.getvalue()
|
||||
data = json.loads(raw)
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "add"
|
||||
assert "data" in data
|
||||
assert isinstance(data["data"], list)
|
||||
assert data["count"] == 1
|
||||
assert set(data["data"][0].keys()) == {"id", "memory", "event"}
|
||||
|
||||
def test_add_agent_mode_scope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_add(
|
||||
mock_backend,
|
||||
"test",
|
||||
user_id="bob",
|
||||
agent_id="agent1",
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
messages=None,
|
||||
file=None,
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["scope"]["user_id"] == "bob"
|
||||
assert data["scope"]["agent_id"] == "agent1"
|
||||
|
||||
# ── search ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_search_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_search(
|
||||
mock_backend,
|
||||
"dark mode",
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
top_k=10,
|
||||
threshold=0.3,
|
||||
rerank=False,
|
||||
keyword=False,
|
||||
filter_json=None,
|
||||
fields=None,
|
||||
output="text",
|
||||
)
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "search"
|
||||
assert isinstance(data["data"], list)
|
||||
assert data["count"] == 2
|
||||
assert "duration_ms" in data
|
||||
assert set(data["data"][0].keys()) == {"id", "memory", "score", "created_at", "categories"}
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_list(
|
||||
mock_backend,
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
page=1,
|
||||
page_size=100,
|
||||
category=None,
|
||||
after=None,
|
||||
before=None,
|
||||
output="table", # will be overridden to "agent"
|
||||
)
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "list"
|
||||
assert isinstance(data["data"], list)
|
||||
assert data["count"] == 2
|
||||
assert data["scope"]["user_id"] == "alice"
|
||||
assert set(data["data"][0].keys()) == {"id", "memory", "created_at", "categories"}
|
||||
|
||||
# ── get ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_get(mock_backend, "abc-123-def-456", output="text")
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "get"
|
||||
assert isinstance(data["data"], dict)
|
||||
assert data["data"]["id"] == "abc-123-def-456"
|
||||
assert "memory" in data["data"]
|
||||
assert set(data["data"].keys()) >= {"id", "memory"}
|
||||
|
||||
# ── update ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_update_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_update(mock_backend, "abc-123", "Updated content", metadata=None, output="text")
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "update"
|
||||
assert isinstance(data["data"], dict)
|
||||
assert "memory" in data["data"]
|
||||
assert "duration_ms" in data
|
||||
|
||||
# ── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_delete(mock_backend, "abc-123-def-456", output="text")
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "delete"
|
||||
assert data["data"]["id"] == "abc-123-def-456"
|
||||
assert data["data"]["deleted"] is True
|
||||
assert "duration_ms" in data
|
||||
|
||||
# ── event list ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_event_list_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_list(mock_backend, output="table")
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "event list"
|
||||
assert isinstance(data["data"], list)
|
||||
assert data["count"] == 2
|
||||
assert "duration_ms" in data
|
||||
assert set(data["data"][0].keys()) == {
|
||||
"id",
|
||||
"event_type",
|
||||
"status",
|
||||
"latency",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
# ── event status ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_event_status_agent_mode_envelope(self, mock_backend):
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.events_cmd.console", console),
|
||||
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||
):
|
||||
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text")
|
||||
data = json.loads(buf.getvalue())
|
||||
assert data["status"] == "success"
|
||||
assert data["command"] == "event status"
|
||||
assert isinstance(data["data"], dict)
|
||||
assert data["data"]["id"] == "evt-abc-123-def-456"
|
||||
assert "duration_ms" in data
|
||||
assert set(data["data"]["results"][0].keys()) == {"id", "event", "user_id", "memory"}
|
||||
assert "data" not in data["data"]["results"][0]
|
||||
|
||||
# ── error handling ───────────────────────────────────────────────────────
|
||||
|
||||
def test_error_in_agent_mode_produces_json_to_stdout(self, mock_backend):
|
||||
"""Errors in agent mode must emit a JSON envelope to stdout, not stderr."""
|
||||
from io import StringIO
|
||||
|
||||
mock_backend.get.side_effect = Exception("Memory not found")
|
||||
console, _buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
|
||||
captured_stdout = StringIO()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
patch("sys.stdout", captured_stdout),
|
||||
pytest.raises((SystemExit, ClickExit)),
|
||||
):
|
||||
cmd_get(mock_backend, "bad-id", output="text")
|
||||
|
||||
stdout_output = captured_stdout.getvalue()
|
||||
# The error JSON envelope must be on stdout
|
||||
error_data = json.loads(stdout_output)
|
||||
assert error_data["status"] == "error"
|
||||
assert "error" in error_data
|
||||
assert error_data["data"] is None
|
||||
|
||||
def test_branding_suppressed_in_agent_mode(self, mock_backend):
|
||||
"""Scope line and success message must be absent in agent mode output."""
|
||||
console, buf = _make_console()
|
||||
err_console, _err_buf = _make_err_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||
):
|
||||
cmd_add(
|
||||
mock_backend,
|
||||
"branding test",
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
messages=None,
|
||||
file=None,
|
||||
metadata=None,
|
||||
immutable=False,
|
||||
no_infer=False,
|
||||
expires=None,
|
||||
categories=None,
|
||||
output="text",
|
||||
)
|
||||
output = buf.getvalue()
|
||||
# Must be valid JSON only — no human-readable branding
|
||||
data = json.loads(output)
|
||||
assert data["status"] == "success"
|
||||
# "Scope:" and "Memory processed" must NOT appear in the raw output
|
||||
assert "Scope:" not in output
|
||||
assert "Memory processed" not in output
|
||||
assert "spinner" not in output.lower()
|
||||
|
||||
def test_no_spinner_in_agent_mode(self, mock_backend):
|
||||
"""timed_status must not emit spinner output in agent mode."""
|
||||
err_buf = StringIO()
|
||||
err_console_buf = Console(file=err_buf, force_terminal=False, no_color=True, width=120)
|
||||
console, _buf = _make_console()
|
||||
with (
|
||||
patch("mem0_cli.commands.memory.console", console),
|
||||
patch("mem0_cli.commands.memory.err_console", err_console_buf),
|
||||
):
|
||||
cmd_search(
|
||||
mock_backend,
|
||||
"query",
|
||||
user_id="alice",
|
||||
agent_id=None,
|
||||
app_id=None,
|
||||
run_id=None,
|
||||
top_k=5,
|
||||
threshold=0.3,
|
||||
rerank=False,
|
||||
keyword=False,
|
||||
filter_json=None,
|
||||
fields=None,
|
||||
output="text",
|
||||
)
|
||||
# The err_buf captures what would have been spinner/timing noise
|
||||
# In agent mode it should be empty (no status lines printed)
|
||||
err_output = err_buf.getvalue()
|
||||
assert "Searching" not in err_output
|
||||
|
||||
@@ -11,7 +11,6 @@ from mem0_cli.output import (
|
||||
format_memories_table,
|
||||
format_memories_text,
|
||||
format_single_memory,
|
||||
sanitize_agent_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,153 +129,8 @@ class TestAddResult:
|
||||
output = buf.getvalue()
|
||||
assert output.strip() == ""
|
||||
|
||||
def test_format_add_result_deduplicates_pending_by_event_id(self):
|
||||
console, buf = _make_console()
|
||||
result = {
|
||||
"results": [
|
||||
{"status": "PENDING", "event_id": "evt-dup"},
|
||||
{"status": "PENDING", "event_id": "evt-dup"},
|
||||
]
|
||||
}
|
||||
format_add_result(console, result, "text")
|
||||
output = buf.getvalue()
|
||||
# Should show only one PENDING block despite two entries with same event_id
|
||||
assert output.count("evt-dup") == 2 # event_id line + status hint line
|
||||
assert output.count("Queued") == 1
|
||||
|
||||
def test_format_add_result_empty(self):
|
||||
console, buf = _make_console()
|
||||
format_add_result(console, {"results": []}, "text")
|
||||
output = buf.getvalue()
|
||||
assert "No memories extracted" in output
|
||||
|
||||
|
||||
class TestSanitizeAgentData:
|
||||
def test_add_projects_fields(self):
|
||||
raw = [
|
||||
{
|
||||
"id": "abc",
|
||||
"memory": "test",
|
||||
"event": "ADD",
|
||||
"metadata": {"x": 1},
|
||||
"categories": ["a"],
|
||||
}
|
||||
]
|
||||
result = sanitize_agent_data("add", raw)
|
||||
assert result == [{"id": "abc", "memory": "test", "event": "ADD"}]
|
||||
|
||||
def test_add_pending_passthrough(self):
|
||||
raw = [{"status": "PENDING", "event_id": "evt-123", "metadata": "noise"}]
|
||||
result = sanitize_agent_data("add", raw)
|
||||
assert result == [{"status": "PENDING", "event_id": "evt-123"}]
|
||||
|
||||
def test_search_projects_fields(self):
|
||||
raw = [
|
||||
{
|
||||
"id": "abc",
|
||||
"memory": "test",
|
||||
"score": 0.9,
|
||||
"created_at": "2026-01-01",
|
||||
"categories": ["a"],
|
||||
"user_id": "u1",
|
||||
"agent_id": None,
|
||||
}
|
||||
]
|
||||
result = sanitize_agent_data("search", raw)
|
||||
assert result == [
|
||||
{
|
||||
"id": "abc",
|
||||
"memory": "test",
|
||||
"score": 0.9,
|
||||
"created_at": "2026-01-01",
|
||||
"categories": ["a"],
|
||||
}
|
||||
]
|
||||
|
||||
def test_list_projects_fields(self):
|
||||
raw = [
|
||||
{
|
||||
"id": "abc",
|
||||
"memory": "test",
|
||||
"created_at": "2026-01-01",
|
||||
"categories": ["a"],
|
||||
"user_id": "u1",
|
||||
}
|
||||
]
|
||||
result = sanitize_agent_data("list", raw)
|
||||
assert result == [
|
||||
{"id": "abc", "memory": "test", "created_at": "2026-01-01", "categories": ["a"]}
|
||||
]
|
||||
|
||||
def test_get_projects_fields(self):
|
||||
raw = {
|
||||
"id": "abc",
|
||||
"memory": "test",
|
||||
"created_at": "2026-01-01",
|
||||
"updated_at": "2026-01-02",
|
||||
"categories": ["a"],
|
||||
"metadata": {"k": "v"},
|
||||
"user_id": "u1",
|
||||
}
|
||||
result = sanitize_agent_data("get", raw)
|
||||
assert "user_id" not in result
|
||||
assert "id" in result and "memory" in result
|
||||
|
||||
def test_update_projects_fields(self):
|
||||
raw = {"id": "abc", "memory": "updated", "extra": "noise"}
|
||||
result = sanitize_agent_data("update", raw)
|
||||
assert result == {"id": "abc", "memory": "updated"}
|
||||
|
||||
def test_event_list_projects_fields(self):
|
||||
raw = [
|
||||
{
|
||||
"id": "evt-1",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"graph_status": None,
|
||||
"latency": 100.0,
|
||||
"created_at": "2026-01-01",
|
||||
"updated_at": "2026-01-02",
|
||||
}
|
||||
]
|
||||
result = sanitize_agent_data("event list", raw)
|
||||
assert result == [
|
||||
{
|
||||
"id": "evt-1",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"latency": 100.0,
|
||||
"created_at": "2026-01-01",
|
||||
}
|
||||
]
|
||||
assert "updated_at" not in result[0]
|
||||
assert "graph_status" not in result[0]
|
||||
|
||||
def test_event_status_flattens_results(self):
|
||||
raw = {
|
||||
"id": "evt-1",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"latency": 100.0,
|
||||
"created_at": "2026-01-01",
|
||||
"updated_at": "2026-01-02",
|
||||
"results": [
|
||||
{"id": "mem-1", "event": "ADD", "user_id": "alice", "data": {"memory": "dark mode"}}
|
||||
],
|
||||
}
|
||||
result = sanitize_agent_data("event status", raw)
|
||||
assert result["results"][0] == {
|
||||
"id": "mem-1",
|
||||
"event": "ADD",
|
||||
"user_id": "alice",
|
||||
"memory": "dark mode",
|
||||
}
|
||||
assert "data" not in result["results"][0]
|
||||
|
||||
def test_passthrough_commands(self):
|
||||
for cmd in ("status", "import", "config show", "config get", "config set"):
|
||||
data = {"key": "value", "other": "stuff"}
|
||||
assert sanitize_agent_data(cmd, data) == data
|
||||
|
||||
def test_none_data(self):
|
||||
assert sanitize_agent_data("add", None) is None
|
||||
|
||||
@@ -82,7 +82,7 @@ new_project = client.project.create(
|
||||
|
||||
### Update Project Settings
|
||||
|
||||
Modify project configuration including custom instructions, categories, graph settings, and language preferences:
|
||||
Modify project configuration including custom instructions, categories, and graph settings:
|
||||
|
||||
```python
|
||||
# Update project with custom categories
|
||||
@@ -101,9 +101,6 @@ client.project.update(
|
||||
# Enable graph memory for the project
|
||||
client.project.update(enable_graph=True)
|
||||
|
||||
# Use the input language for memory storage and retrieval
|
||||
client.project.update(multilingual=True)
|
||||
|
||||
# Update multiple settings at once
|
||||
client.project.update(
|
||||
custom_instructions="...",
|
||||
@@ -111,8 +108,7 @@ client.project.update(
|
||||
{"personal_info": "User personal information and preferences"},
|
||||
{"work_context": "Professional context and work-related information"}
|
||||
],
|
||||
enable_graph=True,
|
||||
multilingual=True
|
||||
enable_graph=True
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -8,21 +8,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-04-01" description="v1.0.10">
|
||||
|
||||
**New Features & Updates:**
|
||||
- **LLMs:** Added MiniMax provider support for AWS Bedrock ([#4609](https://github.com/mem0ai/mem0/pull/4609))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Configs:** Migrated CassandraConfig and AzureMySQLConfig to pydantic v2 ConfigDict ([#4646](https://github.com/mem0ai/mem0/pull/4646))
|
||||
- **LLMs:** Forward `response_format` to OpenAI-compatible API for DeepSeek ([#4635](https://github.com/mem0ai/mem0/pull/4635))
|
||||
- **LLMs:** Forward `response_format` to OpenAI-compatible API for vLLM ([#4608](https://github.com/mem0ai/mem0/pull/4608))
|
||||
- **Vector Stores:** Only list authorized collections when listing MongoDB collections ([#3888](https://github.com/mem0ai/mem0/pull/3888))
|
||||
- **Core:** Reset graph database in `Memory.reset()` ([#4185](https://github.com/mem0ai/mem0/pull/4185))
|
||||
- **Core:** Make `AsyncMemory.from_config` a regular classmethod ([#4183](https://github.com/mem0ai/mem0/pull/4183))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-28" description="v1.0.9">
|
||||
|
||||
**New Features & Updates:**
|
||||
@@ -832,15 +817,6 @@ mode: "wide"
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-04-01" description="v2.4.5">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **OSS:** Replace `.single()` with `.maybeSingle()` in SupabaseDB.get() to handle missing rows ([#4599](https://github.com/mem0ai/mem0/pull/4599))
|
||||
- **Embeddings:** Pass dimensions parameter to OpenAI embeddings API ([#4632](https://github.com/mem0ai/mem0/pull/4632))
|
||||
- **OSS:** Extract JSON from chatty LLM responses in fact retrieval ([#4533](https://github.com/mem0ai/mem0/pull/4533))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-28" description="v2.4.4">
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
@@ -24,7 +24,6 @@ config = {
|
||||
"provider": "gemini",
|
||||
"config": {
|
||||
"model": "gemini-2.0-flash-001",
|
||||
"api_key": "your-gemini-api-key",
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2000,
|
||||
"top_p": 1.0
|
||||
@@ -53,7 +52,6 @@ const config = {
|
||||
provider: "gemini",
|
||||
config: {
|
||||
model: "gemini-2.0-flash-001",
|
||||
apiKey: process.env.GOOGLE_API_KEY || '',
|
||||
temperature: 0.1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3686,10 +3686,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"description": "List of custom categories to be used for memory categorization."
|
||||
},
|
||||
"multilingual": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to use the input language for memory storage and retrieval."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-88
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: CLI
|
||||
description: "Manage memories from your terminal — built for both humans and AI agents."
|
||||
description: "Manage memories from your terminal. Available for Node.js and Python."
|
||||
icon: "terminal"
|
||||
iconType: "solid"
|
||||
---
|
||||
@@ -9,10 +9,6 @@ The mem0 CLI lets you add, search, list, update, and delete memories directly fr
|
||||
|
||||
Both implementations provide identical behavior — same commands, same options, same output formats.
|
||||
|
||||
<Tip>
|
||||
**Built for AI agents.** Pass `--agent` (or `--json`) as a global flag on any command to get structured JSON output optimized for programmatic consumption — sanitized fields, no colors or spinners, and errors as JSON too. Drop it into any agent tool loop with zero extra parsing.
|
||||
</Tip>
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeGroup>
|
||||
@@ -78,23 +74,8 @@ Interactive setup wizard. Prompts for your API key and default user ID.
|
||||
```bash
|
||||
mem0 init
|
||||
mem0 init --api-key m0-xxx --user-id alice
|
||||
mem0 init --email alice@company.com
|
||||
```
|
||||
|
||||
If an existing configuration is detected, the CLI will ask for confirmation before overwriting. Use `--force` to skip the prompt (useful in CI/CD pipelines).
|
||||
|
||||
```bash
|
||||
mem0 init --api-key m0-xxx --user-id alice --force
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--api-key` | API key (skip prompt) |
|
||||
| `-u, --user-id` | Default user ID (skip prompt) |
|
||||
| `--email` | Login via email verification code |
|
||||
| `--code` | Verification code (use with `--email` for non-interactive login) |
|
||||
| `--force` | Overwrite existing config without confirmation |
|
||||
|
||||
### `mem0 add`
|
||||
|
||||
Add a memory from text, a JSON messages array, a file, or stdin.
|
||||
@@ -221,32 +202,16 @@ mem0 config get api_key # Get a specific value
|
||||
mem0 config set user_id bob # Set a value
|
||||
```
|
||||
|
||||
### `mem0 entity`
|
||||
### `mem0 entities`
|
||||
|
||||
List or delete entities (users, agents, apps, runs).
|
||||
List or delete entities (users, agents, apps).
|
||||
|
||||
```bash
|
||||
mem0 entity list users
|
||||
mem0 entity list agents --output json
|
||||
mem0 entity delete --user-id alice --force
|
||||
mem0 entities list
|
||||
mem0 entities list --type agent --output json
|
||||
mem0 entities delete --user-id alice --force
|
||||
```
|
||||
|
||||
### `mem0 event`
|
||||
|
||||
Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs).
|
||||
|
||||
```bash
|
||||
# List recent events
|
||||
mem0 event list
|
||||
|
||||
# Check the status of a specific event
|
||||
mem0 event status <event-id>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-o, --output` | Output format: `text`, `json` |
|
||||
|
||||
### `mem0 status`
|
||||
|
||||
Verify your API connection and display the current project.
|
||||
@@ -273,7 +238,6 @@ All commands support the `--output` flag to control how results are displayed:
|
||||
| `json` | Structured JSON, suitable for piping to `jq` or consumption by AI agents |
|
||||
| `table` | Tabular format (default for `list`) |
|
||||
| `quiet` | Minimal output — just IDs or status codes |
|
||||
| `agent` | Structured JSON envelope with sanitized fields — set automatically by `--json`/`--agent` |
|
||||
|
||||
Example with JSON output:
|
||||
|
||||
@@ -283,54 +247,20 @@ mem0 search "user preferences" --user-id alice --output json | jq '.data.results
|
||||
|
||||
## Use with AI agents
|
||||
|
||||
The CLI is purpose-built for use inside AI agent tool loops. Pass `--agent` or `--json` as a global flag on **any** command to activate agent mode:
|
||||
The CLI is designed to be used by AI agents and automation tools. Two features make this straightforward:
|
||||
|
||||
- Every command outputs a consistent JSON envelope: `{"status", "command", "duration_ms", "scope", "count", "data"}`
|
||||
- The `data` field contains only the fields that matter — IDs, memory text, scores, categories. Noisy API fields are stripped.
|
||||
- All human-readable output is suppressed: no spinners, no colors, no banners.
|
||||
- Errors are returned as JSON to stdout with a non-zero exit code, so your agent can catch them the same way as successes.
|
||||
- **`--output json`** returns structured data that agents can parse directly
|
||||
- **`mem0 help --json`** returns the full command tree as JSON, so agents can discover available commands programmatically
|
||||
|
||||
```bash
|
||||
# Drop --agent on any command and get clean, parseable JSON
|
||||
mem0 --agent search "response preferences" --user-id user-42
|
||||
mem0 --agent add "User prefers concise responses" --user-id user-42
|
||||
mem0 --agent list --user-id user-42
|
||||
mem0 --agent delete --all --user-id user-42 --force
|
||||
# Agent adds a memory
|
||||
mem0 add "User prefers concise responses" --user-id user-42 --output quiet
|
||||
|
||||
# Agent searches memories and parses results
|
||||
mem0 search "response preferences" --user-id user-42 --output json
|
||||
```
|
||||
|
||||
<CodeGroup>
|
||||
```json Output: mem0 --agent search "dark mode" --user-id alice
|
||||
{
|
||||
"status": "success",
|
||||
"command": "search",
|
||||
"duration_ms": 134,
|
||||
"scope": { "user_id": "alice" },
|
||||
"count": 2,
|
||||
"data": [
|
||||
{ "id": "abc-123", "memory": "User prefers dark mode", "score": 0.97, "created_at": "2026-01-15", "categories": ["preferences"] },
|
||||
{ "id": "def-456", "memory": "User uses vim keybindings", "score": 0.81, "created_at": "2026-01-10", "categories": ["tools"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json Output: mem0 --agent add "Likes concise answers" --user-id alice
|
||||
{
|
||||
"status": "success",
|
||||
"command": "add",
|
||||
"duration_ms": 210,
|
||||
"data": [
|
||||
{ "id": "ghi-789", "memory": "Likes concise answers", "event": "ADD" }
|
||||
]
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Two other agent-friendly features:
|
||||
|
||||
- **`--output json`** returns structured data without sanitization — useful when you want the full raw API response
|
||||
- **`mem0 help --json`** returns the complete command tree as JSON, so agents can self-discover available commands and options
|
||||
|
||||
For non-interactive environments (CI, agent runtimes), set credentials via `mem0 init --api-key m0-xxx --user-id alice --force` or the `MEM0_API_KEY` environment variable.
|
||||
For non-interactive environments (CI, agent runtimes), use `mem0 init --api-key --user-id` or set the `MEM0_API_KEY` environment variable to skip interactive prompts.
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -348,12 +278,10 @@ Environment variables take precedence over values in the config file, which take
|
||||
|
||||
## Global flags
|
||||
|
||||
These flags are available on all commands:
|
||||
These flags are available on all commands that interact with the API:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners |
|
||||
| `--agent` | Alias for `--json` |
|
||||
| `--api-key` | Override the configured API key for this request |
|
||||
| `--base-url` | Override the configured API base URL for this request |
|
||||
| `-o, --output` | Set the output format |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.4.5",
|
||||
"version": "2.4.4",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -68,11 +68,6 @@
|
||||
"!src/community/**"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "mem0-ts"
|
||||
},
|
||||
"keywords": [
|
||||
"mem0",
|
||||
"api",
|
||||
|
||||
@@ -167,7 +167,6 @@ export interface PromptUpdatePayload {
|
||||
exclusion_prompt?: string;
|
||||
memory_depth?: string | null;
|
||||
usecase_setting?: string | number;
|
||||
multilingual?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EmbeddingConfig } from "../types";
|
||||
export class AzureOpenAIEmbedder implements Embedder {
|
||||
private client: AzureOpenAI;
|
||||
private model: string;
|
||||
private embeddingDims: number | undefined;
|
||||
private embeddingDims?: number;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
if (!config.apiKey || !config.modelProperties?.endpoint) {
|
||||
@@ -20,16 +20,13 @@ export class AzureOpenAIEmbedder implements Embedder {
|
||||
...rest,
|
||||
});
|
||||
this.model = config.model || "text-embedding-3-small";
|
||||
this.embeddingDims = config.embeddingDims;
|
||||
this.embeddingDims = config.embeddingDims || 1536;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const response = await this.client.embeddings.create({
|
||||
model: this.model,
|
||||
input: text,
|
||||
...(this.embeddingDims !== undefined && {
|
||||
dimensions: this.embeddingDims,
|
||||
}),
|
||||
});
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
@@ -38,9 +35,6 @@ export class AzureOpenAIEmbedder implements Embedder {
|
||||
const response = await this.client.embeddings.create({
|
||||
model: this.model,
|
||||
input: texts,
|
||||
...(this.embeddingDims !== undefined && {
|
||||
dimensions: this.embeddingDims,
|
||||
}),
|
||||
});
|
||||
return response.data.map((item) => item.embedding);
|
||||
}
|
||||
|
||||
@@ -5,23 +5,21 @@ import { EmbeddingConfig } from "../types";
|
||||
export class GoogleEmbedder implements Embedder {
|
||||
private google: GoogleGenAI;
|
||||
private model: string;
|
||||
private embeddingDims: number | undefined;
|
||||
private embeddingDims?: number;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.google = new GoogleGenAI({
|
||||
apiKey: config.apiKey || process.env.GOOGLE_API_KEY,
|
||||
});
|
||||
this.model = config.model || "gemini-embedding-001";
|
||||
this.embeddingDims = config.embeddingDims;
|
||||
this.embeddingDims = config.embeddingDims || 1536;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const response = await this.google.models.embedContent({
|
||||
model: this.model,
|
||||
contents: text,
|
||||
...(this.embeddingDims !== undefined && {
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
}),
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
});
|
||||
return response.embeddings![0].values!;
|
||||
}
|
||||
@@ -30,9 +28,7 @@ export class GoogleEmbedder implements Embedder {
|
||||
const response = await this.google.models.embedContent({
|
||||
model: this.model,
|
||||
contents: texts,
|
||||
...(this.embeddingDims !== undefined && {
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
}),
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
});
|
||||
return response.embeddings!.map((item) => item.values!);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EmbeddingConfig } from "../types";
|
||||
export class OpenAIEmbedder implements Embedder {
|
||||
private openai: OpenAI;
|
||||
private model: string;
|
||||
private embeddingDims: number | undefined;
|
||||
private embeddingDims?: number;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.openai = new OpenAI({
|
||||
@@ -13,16 +13,13 @@ export class OpenAIEmbedder implements Embedder {
|
||||
baseURL: config.baseURL || config.url,
|
||||
});
|
||||
this.model = config.model || "text-embedding-3-small";
|
||||
this.embeddingDims = config.embeddingDims;
|
||||
this.embeddingDims = config.embeddingDims || 1536;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: text,
|
||||
...(this.embeddingDims !== undefined && {
|
||||
dimensions: this.embeddingDims,
|
||||
}),
|
||||
});
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
@@ -31,9 +28,6 @@ export class OpenAIEmbedder implements Embedder {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: texts,
|
||||
...(this.embeddingDims !== undefined && {
|
||||
dimensions: this.embeddingDims,
|
||||
}),
|
||||
});
|
||||
return response.data.map((item) => item.embedding);
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ See the SQL migration instructions in the code comments.`,
|
||||
.from(this.tableName)
|
||||
.select("*")
|
||||
.eq("id", vectorId)
|
||||
.maybeSingle();
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
if (!data) return null;
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Azure OpenAI Embedder — unit tests (mocked Azure OpenAI client).
|
||||
* Verifies that the `dimensions` parameter is only passed to the API
|
||||
* when the user explicitly configures `embeddingDims`.
|
||||
*/
|
||||
|
||||
const mockEmbeddingsCreate = jest.fn();
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
AzureOpenAI: jest.fn().mockImplementation(() => ({
|
||||
embeddings: { create: mockEmbeddingsCreate },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { AzureOpenAIEmbedder } from "../src/embeddings/azure";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
|
||||
const baseConfig = {
|
||||
apiKey: "test-key",
|
||||
modelProperties: { endpoint: "https://test.openai.azure.com" },
|
||||
};
|
||||
|
||||
describe("AzureOpenAIEmbedder (unit)", () => {
|
||||
beforeEach(() => {
|
||||
mockEmbeddingsCreate.mockReset();
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }],
|
||||
});
|
||||
});
|
||||
|
||||
describe("dimensions parameter", () => {
|
||||
it("does NOT pass dimensions when embeddingDims is not set", async () => {
|
||||
const embedder = new AzureOpenAIEmbedder(baseConfig);
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("dimensions");
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes dimensions when embeddingDims is explicitly set", async () => {
|
||||
const embedder = new AzureOpenAIEmbedder({
|
||||
...baseConfig,
|
||||
embeddingDims: 1024,
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello",
|
||||
dimensions: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes dimensions=1536 when embeddingDims is explicitly set to 1536", async () => {
|
||||
const embedder = new AzureOpenAIEmbedder({
|
||||
...baseConfig,
|
||||
embeddingDims: 1536,
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).toHaveProperty("dimensions", 1536);
|
||||
});
|
||||
|
||||
it("does NOT pass dimensions in embedBatch when embeddingDims is not set", async () => {
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
const embedder = new AzureOpenAIEmbedder(baseConfig);
|
||||
|
||||
await embedder.embedBatch(["hello", "world"]);
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("dimensions");
|
||||
});
|
||||
|
||||
it("passes dimensions in embedBatch when embeddingDims is explicitly set", async () => {
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
const embedder = new AzureOpenAIEmbedder({
|
||||
...baseConfig,
|
||||
embeddingDims: 512,
|
||||
});
|
||||
|
||||
await embedder.embedBatch(["hello", "world"]);
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: ["hello", "world"],
|
||||
dimensions: 512,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("embed() returns the embedding vector", async () => {
|
||||
const embedder = new AzureOpenAIEmbedder(baseConfig);
|
||||
|
||||
const result = await embedder.embed("hello");
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const batch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: batch.map((embedding) => ({ embedding })),
|
||||
});
|
||||
|
||||
const embedder = new AzureOpenAIEmbedder(baseConfig);
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
expect(result).toEqual(batch);
|
||||
});
|
||||
|
||||
it("uses custom model when provided", async () => {
|
||||
const embedder = new AzureOpenAIEmbedder({
|
||||
...baseConfig,
|
||||
model: "text-embedding-3-large",
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe("text-embedding-3-large");
|
||||
});
|
||||
|
||||
it("throws when API key is missing", () => {
|
||||
expect(() => {
|
||||
new AzureOpenAIEmbedder({
|
||||
modelProperties: { endpoint: "https://test.openai.azure.com" },
|
||||
});
|
||||
}).toThrow("Azure OpenAI requires both API key and endpoint");
|
||||
});
|
||||
|
||||
it("throws when endpoint is missing", () => {
|
||||
expect(() => {
|
||||
new AzureOpenAIEmbedder({ apiKey: "test-key" });
|
||||
}).toThrow("Azure OpenAI requires both API key and endpoint");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Google Embedder — unit tests (mocked Google GenAI client).
|
||||
* Verifies that the `outputDimensionality` config is only passed to the API
|
||||
* when the user explicitly configures `embeddingDims`.
|
||||
*/
|
||||
|
||||
const mockEmbedContent = jest.fn();
|
||||
|
||||
jest.mock("@google/genai", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
GoogleGenAI: jest.fn().mockImplementation(() => ({
|
||||
models: { embedContent: mockEmbedContent },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { GoogleEmbedder } from "../src/embeddings/google";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
|
||||
describe("GoogleEmbedder (unit)", () => {
|
||||
beforeEach(() => {
|
||||
mockEmbedContent.mockReset();
|
||||
mockEmbedContent.mockResolvedValue({
|
||||
embeddings: [{ values: mockEmbedding }],
|
||||
});
|
||||
});
|
||||
|
||||
describe("outputDimensionality parameter", () => {
|
||||
it("does NOT pass config when embeddingDims is not set", async () => {
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
expect(mockEmbedContent).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockEmbedContent.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("config");
|
||||
expect(callArgs).toEqual({
|
||||
model: "gemini-embedding-001",
|
||||
contents: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes outputDimensionality when embeddingDims is explicitly set", async () => {
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
embeddingDims: 768,
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
expect(mockEmbedContent).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockEmbedContent.mock.calls[0][0];
|
||||
expect(callArgs).toEqual({
|
||||
model: "gemini-embedding-001",
|
||||
contents: "hello",
|
||||
config: { outputDimensionality: 768 },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes outputDimensionality=1536 when embeddingDims is explicitly set to 1536", async () => {
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
embeddingDims: 1536,
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
const callArgs = mockEmbedContent.mock.calls[0][0];
|
||||
expect(callArgs).toHaveProperty("config");
|
||||
expect(callArgs.config).toEqual({ outputDimensionality: 1536 });
|
||||
});
|
||||
|
||||
it("does NOT pass config in embedBatch when embeddingDims is not set", async () => {
|
||||
mockEmbedContent.mockResolvedValue({
|
||||
embeddings: [{ values: mockEmbedding }, { values: mockEmbedding }],
|
||||
});
|
||||
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
await embedder.embedBatch(["hello", "world"]);
|
||||
|
||||
const callArgs = mockEmbedContent.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("config");
|
||||
});
|
||||
|
||||
it("passes outputDimensionality in embedBatch when embeddingDims is explicitly set", async () => {
|
||||
mockEmbedContent.mockResolvedValue({
|
||||
embeddings: [{ values: mockEmbedding }, { values: mockEmbedding }],
|
||||
});
|
||||
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
embeddingDims: 512,
|
||||
});
|
||||
|
||||
await embedder.embedBatch(["hello", "world"]);
|
||||
|
||||
const callArgs = mockEmbedContent.mock.calls[0][0];
|
||||
expect(callArgs).toEqual({
|
||||
model: "gemini-embedding-001",
|
||||
contents: ["hello", "world"],
|
||||
config: { outputDimensionality: 512 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("embed() returns the embedding vector", async () => {
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
const result = await embedder.embed("hello");
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const batch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockEmbedContent.mockResolvedValue({
|
||||
embeddings: batch.map((values) => ({ values })),
|
||||
});
|
||||
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
expect(result).toEqual(batch);
|
||||
});
|
||||
|
||||
it("uses custom model when provided", async () => {
|
||||
const embedder = new GoogleEmbedder({
|
||||
apiKey: "test-key",
|
||||
model: "text-embedding-004",
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
const callArgs = mockEmbedContent.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe("text-embedding-004");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* OpenAI Embedder — unit tests (mocked OpenAI client).
|
||||
* Verifies that the `dimensions` parameter is only passed to the API
|
||||
* when the user explicitly configures `embeddingDims`.
|
||||
*/
|
||||
|
||||
const mockEmbeddingsCreate = jest.fn();
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
embeddings: { create: mockEmbeddingsCreate },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { OpenAIEmbedder } from "../src/embeddings/openai";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
|
||||
describe("OpenAIEmbedder (unit)", () => {
|
||||
beforeEach(() => {
|
||||
mockEmbeddingsCreate.mockReset();
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }],
|
||||
});
|
||||
});
|
||||
|
||||
describe("dimensions parameter", () => {
|
||||
it("does NOT pass dimensions when embeddingDims is not set", async () => {
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("dimensions");
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes dimensions when embeddingDims is explicitly set", async () => {
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
embeddingDims: 1024,
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello",
|
||||
dimensions: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes dimensions=1536 when embeddingDims is explicitly set to 1536", async () => {
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
embeddingDims: 1536,
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).toHaveProperty("dimensions", 1536);
|
||||
});
|
||||
|
||||
it("does NOT pass dimensions in embedBatch when embeddingDims is not set", async () => {
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
await embedder.embedBatch(["hello", "world"]);
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty("dimensions");
|
||||
});
|
||||
|
||||
it("passes dimensions in embedBatch when embeddingDims is explicitly set", async () => {
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
embeddingDims: 512,
|
||||
});
|
||||
|
||||
await embedder.embedBatch(["hello", "world"]);
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs).toEqual({
|
||||
model: "text-embedding-3-small",
|
||||
input: ["hello", "world"],
|
||||
dimensions: 512,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("embed() returns the embedding vector", async () => {
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
const result = await embedder.embed("hello");
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const batch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockEmbeddingsCreate.mockResolvedValue({
|
||||
data: batch.map((embedding) => ({ embedding })),
|
||||
});
|
||||
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
expect(result).toEqual(batch);
|
||||
});
|
||||
|
||||
it("uses custom model when provided", async () => {
|
||||
const embedder = new OpenAIEmbedder({
|
||||
apiKey: "test-key",
|
||||
model: "text-embedding-3-large",
|
||||
});
|
||||
|
||||
await embedder.embed("hello");
|
||||
|
||||
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe("text-embedding-3-large");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -676,7 +676,6 @@ class MemoryClient:
|
||||
exclusion_prompt: Optional[str] = None,
|
||||
memory_depth: Optional[str] = None,
|
||||
usecase_setting: Optional[str] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the project settings.
|
||||
|
||||
@@ -690,7 +689,6 @@ class MemoryClient:
|
||||
exclusion_prompt: Exclusion prompt for the project
|
||||
memory_depth: Memory depth for the project
|
||||
usecase_setting: Usecase setting for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -720,7 +718,6 @@ class MemoryClient:
|
||||
and exclusion_prompt is None
|
||||
and memory_depth is None
|
||||
and usecase_setting is None
|
||||
and multilingual is None
|
||||
):
|
||||
raise ValueError(
|
||||
"Currently we only support updating custom_instructions or "
|
||||
@@ -739,7 +736,6 @@ class MemoryClient:
|
||||
"exclusion_prompt": exclusion_prompt,
|
||||
"memory_depth": memory_depth,
|
||||
"usecase_setting": usecase_setting,
|
||||
"multilingual": multilingual,
|
||||
}
|
||||
)
|
||||
response = self.client.patch(
|
||||
@@ -760,7 +756,6 @@ class MemoryClient:
|
||||
"exclusion_prompt": exclusion_prompt,
|
||||
"memory_depth": memory_depth,
|
||||
"usecase_setting": usecase_setting,
|
||||
"multilingual": multilingual,
|
||||
"sync_type": "sync",
|
||||
},
|
||||
)
|
||||
@@ -1559,7 +1554,6 @@ class AsyncMemoryClient:
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
enable_graph: Optional[bool] = None,
|
||||
version: Optional[str] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the project settings.
|
||||
|
||||
@@ -1569,7 +1563,6 @@ class AsyncMemoryClient:
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
enable_graph: Enable or disable the graph for the project
|
||||
version: Version of the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -1595,7 +1588,6 @@ class AsyncMemoryClient:
|
||||
and retrieval_criteria is None
|
||||
and enable_graph is None
|
||||
and version is None
|
||||
and multilingual is None
|
||||
):
|
||||
raise ValueError(
|
||||
"Currently we only support updating custom_instructions or custom_categories or retrieval_criteria, so you must provide at least one of them"
|
||||
@@ -1608,7 +1600,6 @@ class AsyncMemoryClient:
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"enable_graph": enable_graph,
|
||||
"version": version,
|
||||
"multilingual": multilingual,
|
||||
}
|
||||
)
|
||||
response = await self.async_client.patch(
|
||||
@@ -1625,7 +1616,6 @@ class AsyncMemoryClient:
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"enable_graph": enable_graph,
|
||||
"version": version,
|
||||
"multilingual": multilingual,
|
||||
"sync_type": "async",
|
||||
},
|
||||
)
|
||||
|
||||
+2
-12
@@ -399,7 +399,6 @@ class Project(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
enable_graph: Optional[bool] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -409,7 +408,6 @@ class Project(BaseProject):
|
||||
custom_categories: New categories for the project
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
enable_graph: Enable or disable the graph for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -426,12 +424,11 @@ class Project(BaseProject):
|
||||
and custom_categories is None
|
||||
and retrieval_criteria is None
|
||||
and enable_graph is None
|
||||
and multilingual is None
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, retrieval_criteria, "
|
||||
"enable_graph, multilingual"
|
||||
"enable_graph"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -440,7 +437,6 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"enable_graph": enable_graph,
|
||||
"multilingual": multilingual,
|
||||
}
|
||||
)
|
||||
response = self._client.patch(
|
||||
@@ -456,7 +452,6 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"enable_graph": enable_graph,
|
||||
"multilingual": multilingual,
|
||||
"sync_type": "sync",
|
||||
},
|
||||
)
|
||||
@@ -721,7 +716,6 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
enable_graph: Optional[bool] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -731,7 +725,6 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: New categories for the project
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
enable_graph: Enable or disable the graph for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -748,12 +741,11 @@ class AsyncProject(BaseProject):
|
||||
and custom_categories is None
|
||||
and retrieval_criteria is None
|
||||
and enable_graph is None
|
||||
and multilingual is None
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, retrieval_criteria, "
|
||||
"enable_graph, multilingual"
|
||||
"enable_graph"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -762,7 +754,6 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"enable_graph": enable_graph,
|
||||
"multilingual": multilingual,
|
||||
}
|
||||
)
|
||||
response = await self._client.patch(
|
||||
@@ -778,7 +769,6 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"enable_graph": enable_graph,
|
||||
"multilingual": multilingual,
|
||||
"sync_type": "async",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class AzureMySQLConfig(BaseModel):
|
||||
@@ -80,4 +80,5 @@ class AzureMySQLConfig(BaseModel):
|
||||
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class CassandraConfig(BaseModel):
|
||||
@@ -72,5 +72,6 @@ class CassandraConfig(BaseModel):
|
||||
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class ElasticsearchConfig(BaseModel):
|
||||
@@ -63,5 +63,3 @@ class ElasticsearchConfig(BaseModel):
|
||||
f"Please input only the following fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class MongoDBConfig(BaseModel):
|
||||
@@ -23,5 +23,3 @@ class MongoDBConfig(BaseModel):
|
||||
f"Please provide only the following fields: {', '.join(allowed_fields)}."
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=False)
|
||||
|
||||
@@ -5,7 +5,7 @@ This module provides configuration settings for integrating with Amazon Neptune
|
||||
as a vector store backend for Mem0's memory layer.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NeptuneAnalyticsConfig(BaseModel):
|
||||
@@ -22,4 +22,6 @@ class NeptuneAnalyticsConfig(BaseModel):
|
||||
collection_name: str = Field("mem0", description="Default name for the collection")
|
||||
endpoint: str = Field("endpoint", description="Graph ID for the runtime")
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=False)
|
||||
model_config = {
|
||||
"arbitrary_types_allowed": False,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class OpenSearchConfig(BaseModel):
|
||||
@@ -39,5 +39,3 @@ class OpenSearchConfig(BaseModel):
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. Allowed fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class PGVectorConfig(BaseModel):
|
||||
@@ -50,5 +50,3 @@ class PGVectorConfig(BaseModel):
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. Please input only the following fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class IndexMethod(str, Enum):
|
||||
@@ -42,5 +42,3 @@ class SupabaseConfig(BaseModel):
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. Please input only the following fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=False)
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ValkeyConfig(BaseModel):
|
||||
"""Configuration for Valkey vector store."""
|
||||
|
||||
valkey_url: str = Field(..., description="Valkey server URL (e.g., redis://localhost:6379)")
|
||||
collection_name: str = Field(..., description="Name of the index / collection")
|
||||
embedding_model_dims: int = Field(..., description="Dimensions of the embedding model")
|
||||
timezone: str = Field("UTC", description="Timezone for timestamp handling")
|
||||
index_type: str = Field("hnsw", description="Index type: 'hnsw' (default) or 'flat'")
|
||||
hnsw_m: int = Field(16, description="HNSW: number of connections per layer")
|
||||
hnsw_ef_construction: int = Field(200, description="HNSW: search width during index construction")
|
||||
hnsw_ef_runtime: int = Field(10, description="HNSW: search width during queries")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
allowed_fields = set(cls.model_fields.keys())
|
||||
input_fields = set(values.keys())
|
||||
extra_fields = input_fields - allowed_fields
|
||||
if extra_fields:
|
||||
raise ValueError(
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. "
|
||||
f"Please input only the following fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=False)
|
||||
valkey_url: str
|
||||
collection_name: str
|
||||
embedding_model_dims: int
|
||||
timezone: str = "UTC"
|
||||
index_type: str = "hnsw" # Default to HNSW, can be 'hnsw' or 'flat'
|
||||
# HNSW specific parameters with recommended defaults
|
||||
hnsw_m: int = 16 # Number of connections per layer (default from Valkey docs)
|
||||
hnsw_ef_construction: int = 200 # Search width during construction
|
||||
hnsw_ef_runtime: int = 10 # Search width during queries
|
||||
|
||||
@@ -99,8 +99,6 @@ class DeepSeekLLM(LLMBase):
|
||||
}
|
||||
)
|
||||
|
||||
if response_format:
|
||||
params["response_format"] = response_format
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
params["tool_choice"] = tool_choice
|
||||
|
||||
@@ -99,8 +99,6 @@ class VllmLLM(LLMBase):
|
||||
}
|
||||
)
|
||||
|
||||
if response_format:
|
||||
params["response_format"] = response_format
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
params["tool_choice"] = tool_choice
|
||||
|
||||
@@ -51,7 +51,7 @@ class MongoDB(VectorStoreBase):
|
||||
"""Create new collection with vector search index."""
|
||||
try:
|
||||
database = self.client[self.db_name]
|
||||
collection_names = database.list_collection_names(authorizedCollections=True)
|
||||
collection_names = database.list_collection_names()
|
||||
if self.collection_name not in collection_names:
|
||||
logger.info(f"Collection '{self.collection_name}' does not exist. Creating it now.")
|
||||
collection = database[self.collection_name]
|
||||
@@ -240,7 +240,7 @@ class MongoDB(VectorStoreBase):
|
||||
List[str]: List of collection names.
|
||||
"""
|
||||
try:
|
||||
collections = self.db.list_collection_names(authorizedCollections=True)
|
||||
collections = self.db.list_collection_names()
|
||||
logger.info(f"Listing collections in database '{self.db_name}': {collections}")
|
||||
return collections
|
||||
except PyMongoError as e:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
DatetimeRange,
|
||||
Distance,
|
||||
FieldCondition,
|
||||
Filter,
|
||||
@@ -141,23 +139,6 @@ class Qdrant(VectorStoreBase):
|
||||
]
|
||||
self.client.upsert(collection_name=self.collection_name, points=points)
|
||||
|
||||
# ISO 8601 datetime pattern for detecting datetime strings in range filters
|
||||
_ISO_DATETIME_RE = re.compile(
|
||||
r"^\d{4}-\d{2}-\d{2}" # date part
|
||||
r"([T ]\d{2}:\d{2}(:\d{2})?" # optional time part
|
||||
r"(\.\d+)?" # optional fractional seconds
|
||||
r"(Z|[+-]\d{2}:?\d{2})?" # optional timezone
|
||||
r")?$"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_datetime_range(range_kwargs: dict) -> bool:
|
||||
"""Check if all values in range kwargs are ISO datetime strings."""
|
||||
return all(
|
||||
isinstance(v, str) and Qdrant._ISO_DATETIME_RE.match(v)
|
||||
for v in range_kwargs.values()
|
||||
)
|
||||
|
||||
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
|
||||
"""
|
||||
Build a single FieldCondition from a key-value filter pair.
|
||||
@@ -196,13 +177,6 @@ class Qdrant(VectorStoreBase):
|
||||
f"Use AND to combine them as separate conditions."
|
||||
)
|
||||
range_kwargs = {op: value[op] for op in range_ops if op in value}
|
||||
if self._is_datetime_range(range_kwargs):
|
||||
try:
|
||||
return FieldCondition(key=key, range=DatetimeRange(**range_kwargs))
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(
|
||||
f"Invalid datetime value in range filter for field '{key}': {e}"
|
||||
) from e
|
||||
return FieldCondition(key=key, range=Range(**range_kwargs))
|
||||
elif "eq" in value:
|
||||
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
|
||||
|
||||
@@ -2,30 +2,6 @@
|
||||
|
||||
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
|
||||
|
||||
## [1.0.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- **CD workflow**: Added continuous deployment workflow for `@mem0/openclaw-mem0` with OIDC trusted publishing ([#4672](https://github.com/mem0ai/mem0/pull/4672))
|
||||
- **Plugin configuration manifest**: Added `compat` and `build` metadata to `package.json` specifying minimum gateway version and OpenClaw SDK compatibility (`>=2026.3.24-beta.2`) ([#4667](https://github.com/mem0ai/mem0/pull/4667))
|
||||
- **LICENSE**: Added Apache-2.0 license file to the package ([#4667](https://github.com/mem0ai/mem0/pull/4667))
|
||||
|
||||
### Fixed
|
||||
- **Dream gate correctness**: Fixed cheap-first ordering, session isolation, and verified completion in the dream gate memory consolidation pipeline ([#4666](https://github.com/mem0ai/mem0/pull/4666))
|
||||
- **Graceful startup without API key**: Plugin now starts gracefully when no API key is configured instead of crashing on init ([#4669](https://github.com/mem0ai/mem0/pull/4669))
|
||||
|
||||
## [1.0.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- **Skills-based memory architecture**: New skill-loader and skill-based extraction pipeline with batched extraction for higher quality memory capture ([#4624](https://github.com/mem0ai/mem0/pull/4624))
|
||||
- **Dream gate**: Added `dream-gate.ts` for memory consolidation and dream-cycle processing
|
||||
- **Enhanced recall**: New `recall.ts` module with improved recall logic and skill-aware retrieval
|
||||
- **Memory triage skill**: Domain-aware memory triage with companion domain support and recall protocol
|
||||
- **Memory dream skill**: Skill for memory consolidation during idle periods
|
||||
- **Plugin configuration**: Added `openclaw.plugin.json` manifest and `scripts/configure.py` setup helper
|
||||
|
||||
### Changed
|
||||
- Extraction pipeline refactored to use skills-based architecture for more contextual and higher quality memory capture
|
||||
|
||||
## [0.4.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2023] [Taranjeet Singh]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,175 +0,0 @@
|
||||
# ============================================================================
|
||||
# Agentic Memory Skills — OpenClaw Plugin Installer
|
||||
# ============================================================================
|
||||
#
|
||||
# Usage:
|
||||
# make install # Interactive — prompts for API key
|
||||
# make install MEM0_API_KEY=m0-xxx # Non-interactive
|
||||
# make uninstall # Revert to stock plugin
|
||||
# make restart # Rebuild + restart gateway
|
||||
# make status # Check everything is working
|
||||
# make logs # Tail gateway logs filtered to mem0
|
||||
# make clean # Full teardown
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
SHELL := /bin/bash
|
||||
.PHONY: install uninstall restart status logs clean build check-deps configure help
|
||||
|
||||
# Defaults
|
||||
MEM0_API_KEY ?=
|
||||
MEM0_USER_ID ?= $(shell whoami)
|
||||
OPENCLAW_CONFIG := $(HOME)/.openclaw/openclaw.json
|
||||
PLUGIN_DIR := $(shell pwd)
|
||||
|
||||
help: ## Show this help
|
||||
@echo ""
|
||||
@echo " Agentic Memory Skills — OpenClaw Plugin"
|
||||
@echo " ========================================"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Dependency checks
|
||||
# ============================================================================
|
||||
|
||||
check-deps:
|
||||
@echo "Checking dependencies..."
|
||||
@command -v node >/dev/null 2>&1 || { echo "Error: Node.js is required. Install from https://nodejs.org"; exit 1; }
|
||||
@NODE_MAJOR=$$(node -v | sed 's/v//' | cut -d. -f1); \
|
||||
if [ "$$NODE_MAJOR" -lt 18 ]; then echo "Error: Node.js >= 18 required (found $$(node -v))"; exit 1; fi
|
||||
@command -v npm >/dev/null 2>&1 || { echo "Error: npm is required"; exit 1; }
|
||||
@if ! command -v openclaw >/dev/null 2>&1; then \
|
||||
echo "OpenClaw not found. Installing..."; \
|
||||
npm install -g openclaw || { echo "Error: Failed to install OpenClaw. Try: sudo npm install -g openclaw"; exit 1; }; \
|
||||
echo "OpenClaw installed: $$(openclaw --version)"; \
|
||||
else \
|
||||
echo "OpenClaw: $$(openclaw --version 2>/dev/null || echo 'installed')"; \
|
||||
fi
|
||||
@echo "Node.js: $$(node -v)"
|
||||
@echo "All dependencies OK."
|
||||
|
||||
# ============================================================================
|
||||
# Build
|
||||
# ============================================================================
|
||||
|
||||
build: ## Build the plugin from source
|
||||
@echo "Installing npm dependencies..."
|
||||
@npm install --silent 2>/dev/null
|
||||
@echo "Building plugin..."
|
||||
@npm run build --silent 2>/dev/null
|
||||
@echo "Build OK: $$(ls -lh dist/index.js | awk '{print $$5}') ESM bundle"
|
||||
|
||||
# ============================================================================
|
||||
# Install
|
||||
# ============================================================================
|
||||
|
||||
install: check-deps build ## Full install — build, link, configure, restart
|
||||
@# Remove existing plugin (suppress errors if not installed)
|
||||
@echo "Removing existing openclaw-mem0 plugin (if any)..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@# Link local build
|
||||
@echo "Linking local plugin build..."
|
||||
@openclaw plugins install "$(PLUGIN_DIR)" --link 2>&1 | grep -v "plugins.allow"
|
||||
@# Configure AFTER link (link may overwrite config)
|
||||
@$(MAKE) --no-print-directory configure
|
||||
@# Restart gateway
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo ""
|
||||
@echo "============================================"
|
||||
@echo " Installation complete!"
|
||||
@echo "============================================"
|
||||
@echo ""
|
||||
@echo " Plugin: openclaw-mem0 (skills mode)"
|
||||
@echo " User: $(MEM0_USER_ID)"
|
||||
@echo " Web UI: http://127.0.0.1:18789"
|
||||
@echo ""
|
||||
@echo " Verify with: make status"
|
||||
@echo " View logs: make logs"
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Configure — patch openclaw.json with required settings
|
||||
# ============================================================================
|
||||
|
||||
configure:
|
||||
@if [ -z "$(MEM0_API_KEY)" ]; then \
|
||||
echo ""; \
|
||||
echo " Enter your Mem0 API key (from https://app.mem0.ai/dashboard/api-keys):"; \
|
||||
echo -n " > "; \
|
||||
read -r key; \
|
||||
if [ -z "$$key" ]; then echo "Error: API key is required."; exit 1; fi; \
|
||||
MEM0_API_KEY="$$key" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
|
||||
else \
|
||||
MEM0_API_KEY="$(MEM0_API_KEY)" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Status & Logs
|
||||
# ============================================================================
|
||||
|
||||
status: ## Check plugin health and skills registration
|
||||
@echo "Plugin health:"
|
||||
@openclaw plugins doctor 2>&1 | grep -v "plugins.allow"
|
||||
@echo ""
|
||||
@echo "Skills:"
|
||||
@openclaw skills list 2>&1 | grep -E "memory|Status|---" | head -10
|
||||
@echo ""
|
||||
@echo "Last gateway registration:"
|
||||
@tail -50 /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep "openclaw-mem0: registered" | tail -1 | grep -o '"1":"[^"]*"' || echo " No gateway log found. Is the gateway running?"
|
||||
|
||||
logs: ## Tail gateway logs (mem0 activity only)
|
||||
@tail -f /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep --line-buffered -o '"1":"[^"]*"' | grep --line-buffered -i "mem0\|skills-mode\|stored\|inject\|recall"
|
||||
|
||||
# ============================================================================
|
||||
# Restart
|
||||
# ============================================================================
|
||||
|
||||
restart: build ## Rebuild plugin and restart gateway
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo "Gateway restarted. Verify: make status"
|
||||
|
||||
# ============================================================================
|
||||
# Uninstall — revert to stock plugin
|
||||
# ============================================================================
|
||||
|
||||
uninstall: ## Revert to stock openclaw-mem0 from npm
|
||||
@echo "Uninstalling local plugin..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@echo "Installing stock plugin from npm..."
|
||||
@openclaw plugins install @mem0/openclaw-mem0 2>&1 | grep -v "plugins.allow"
|
||||
@# Restore config backup if it exists
|
||||
@if [ -f "$(OPENCLAW_CONFIG).pre-skills-backup" ]; then \
|
||||
cp "$(OPENCLAW_CONFIG).pre-skills-backup" "$(OPENCLAW_CONFIG)"; \
|
||||
echo "Restored config from backup."; \
|
||||
else \
|
||||
echo "Note: No config backup found. You may need to manually revert openclaw.json changes."; \
|
||||
fi
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo "Reverted to stock openclaw-mem0."
|
||||
|
||||
# ============================================================================
|
||||
# Clean
|
||||
# ============================================================================
|
||||
|
||||
clean: ## Full teardown — stop gateway, remove plugin, clean build
|
||||
@echo "Stopping gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@echo "Removing plugin..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf dist/ node_modules/
|
||||
@echo "Clean complete."
|
||||
+8
-9
@@ -171,7 +171,6 @@ const ALLOWED_KEYS = [
|
||||
"searchThreshold",
|
||||
"topK",
|
||||
"oss",
|
||||
"skills",
|
||||
];
|
||||
|
||||
function assertAllowedKeys(
|
||||
@@ -196,9 +195,14 @@ export const mem0ConfigSchema = {
|
||||
const mode: Mem0Mode =
|
||||
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
|
||||
|
||||
// Platform mode requires apiKey — but don't throw on missing config.
|
||||
// The plugin should register successfully and log a setup message.
|
||||
const needsSetup = mode === "platform" && (typeof cfg.apiKey !== "string" || !cfg.apiKey);
|
||||
// Platform mode requires apiKey
|
||||
if (mode === "platform") {
|
||||
if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
|
||||
throw new Error(
|
||||
"apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve env vars in oss config
|
||||
let ossConfig: Mem0Config["oss"];
|
||||
@@ -236,12 +240,7 @@ export const mem0ConfigSchema = {
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
needsSetup,
|
||||
oss: ossConfig,
|
||||
skills:
|
||||
cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills)
|
||||
? (cfg.skills as Mem0Config["skills"])
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Dream Gate — activity tracking, gate logic, and lock mechanism
|
||||
* for automatic memory consolidation.
|
||||
*
|
||||
* State persists in the plugin's stateDir so it survives gateway restarts.
|
||||
* Lock prevents concurrent consolidation runs.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface DreamState {
|
||||
lastConsolidatedAt: number; // ms since epoch, 0 = never
|
||||
sessionsSince: number; // interactive sessions since last consolidation
|
||||
lastSessionId: string | null;
|
||||
}
|
||||
|
||||
interface DreamLock {
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface DreamGateConfig {
|
||||
minHours: number;
|
||||
minSessions: number;
|
||||
minMemories: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: DreamGateConfig = {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
minMemories: 20,
|
||||
};
|
||||
|
||||
const LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// ============================================================================
|
||||
// State Persistence
|
||||
// ============================================================================
|
||||
|
||||
function statePath(stateDir: string): string {
|
||||
return path.join(stateDir, "dream-state.json");
|
||||
}
|
||||
|
||||
function lockPath(stateDir: string): string {
|
||||
return path.join(stateDir, "dream.lock");
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch { /* exists */ }
|
||||
}
|
||||
|
||||
function readState(stateDir: string): DreamState {
|
||||
try {
|
||||
const raw = fs.readFileSync(statePath(stateDir), "utf-8");
|
||||
return JSON.parse(raw) as DreamState;
|
||||
} catch {
|
||||
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(stateDir: string, state: DreamState): void {
|
||||
ensureDir(stateDir);
|
||||
fs.writeFileSync(statePath(stateDir), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Called from agent_end on every interactive turn.
|
||||
* Increments session counter (deduped by sessionId).
|
||||
*/
|
||||
export function incrementSessionCount(stateDir: string, sessionId: string): void {
|
||||
const state = readState(stateDir);
|
||||
if (state.lastSessionId !== sessionId) {
|
||||
state.sessionsSince++;
|
||||
state.lastSessionId = sessionId;
|
||||
writeState(stateDir, state);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gate Logic
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check cheap gates (time + sessions). These are local file reads only.
|
||||
* Call this BEFORE any API calls. If this fails, skip the expensive
|
||||
* memory count check entirely.
|
||||
*/
|
||||
export function checkCheapGates(
|
||||
stateDir: string,
|
||||
config: { minHours?: number; minSessions?: number },
|
||||
): { proceed: boolean; reason?: string } {
|
||||
const minHours = config.minHours ?? DEFAULTS.minHours;
|
||||
const minSessions = config.minSessions ?? DEFAULTS.minSessions;
|
||||
const state = readState(stateDir);
|
||||
|
||||
// Gate 1: Time (one local file read)
|
||||
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
|
||||
if (hoursSince < minHours) {
|
||||
return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
|
||||
}
|
||||
|
||||
// Gate 2: Sessions (same file, already read)
|
||||
if (state.sessionsSince < minSessions) {
|
||||
return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
|
||||
}
|
||||
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check expensive memory count gate. Only call AFTER checkCheapGates passes.
|
||||
*/
|
||||
export function checkMemoryGate(
|
||||
memoryCount: number,
|
||||
config: { minMemories?: number },
|
||||
): { pass: boolean; reason?: string } {
|
||||
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
|
||||
if (memoryCount < minMemories) {
|
||||
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
|
||||
}
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Try to acquire the dream lock. Returns true if acquired.
|
||||
* Stale locks (older than 1 hour) are reclaimed.
|
||||
*/
|
||||
export function acquireDreamLock(stateDir: string): boolean {
|
||||
ensureDir(stateDir);
|
||||
const lp = lockPath(stateDir);
|
||||
|
||||
// Check existing lock
|
||||
try {
|
||||
const raw = fs.readFileSync(lp, "utf-8");
|
||||
const lock = JSON.parse(raw) as DreamLock;
|
||||
const age = Date.now() - lock.startedAt;
|
||||
if (age < LOCK_STALE_MS) {
|
||||
return false; // Held and not stale
|
||||
}
|
||||
// Stale lock — remove it before attempting exclusive create
|
||||
try { fs.unlinkSync(lp); } catch { /* race ok */ }
|
||||
} catch {
|
||||
// No lock file, proceed
|
||||
}
|
||||
|
||||
// Atomic create with exclusive flag (wx). If two processes race,
|
||||
// only one succeeds. The other gets EEXIST.
|
||||
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
|
||||
try {
|
||||
fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false; // Lost race
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the dream lock and record successful completion.
|
||||
*/
|
||||
export function releaseDreamLock(stateDir: string): void {
|
||||
try {
|
||||
fs.unlinkSync(lockPath(stateDir));
|
||||
} catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that consolidation completed. Resets session counter.
|
||||
*/
|
||||
export function recordDreamCompletion(stateDir: string): void {
|
||||
const state = readState(stateDir);
|
||||
state.lastConsolidatedAt = Date.now();
|
||||
state.sessionsSince = 0;
|
||||
state.lastSessionId = null;
|
||||
writeState(stateDir, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current dream state for logging/diagnostics.
|
||||
*/
|
||||
export function getDreamState(stateDir: string): DreamState {
|
||||
return readState(stateDir);
|
||||
}
|
||||
+21
-614
@@ -38,22 +38,6 @@ import {
|
||||
isNonInteractiveTrigger,
|
||||
isSubagentSession,
|
||||
} from "./isolation.ts";
|
||||
import {
|
||||
loadTriagePrompt,
|
||||
loadDreamPrompt,
|
||||
resolveCategories,
|
||||
ttlToExpirationDate,
|
||||
isSkillsMode,
|
||||
} from "./skill-loader.ts";
|
||||
import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
|
||||
import {
|
||||
incrementSessionCount,
|
||||
checkCheapGates,
|
||||
checkMemoryGate,
|
||||
acquireDreamLock,
|
||||
releaseDreamLock,
|
||||
recordDreamCompletion,
|
||||
} from "./dream-gate.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports (for tests and external consumers)
|
||||
@@ -94,23 +78,6 @@ const memoryPlugin = {
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
const cfg = mem0ConfigSchema.parse(api.pluginConfig);
|
||||
|
||||
if (cfg.needsSetup) {
|
||||
api.logger.warn(
|
||||
"openclaw-mem0: API key not configured. Memory features are disabled.\n" +
|
||||
" To set up, run:\n" +
|
||||
' openclaw config set plugins.entries.openclaw-mem0.config.apiKey "m0-your-key"\n' +
|
||||
" openclaw gateway restart\n" +
|
||||
" Get your key at: https://app.mem0.ai/dashboard/api-keys"
|
||||
);
|
||||
api.registerService({
|
||||
id: "openclaw-mem0",
|
||||
start: () => { api.logger.info("openclaw-mem0: waiting for API key configuration"); },
|
||||
stop: () => {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Track current session ID for tool-level session scoping.
|
||||
@@ -128,9 +95,8 @@ const memoryPlugin = {
|
||||
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
|
||||
resolveUserId(cfg.userId, opts, currentSessionId);
|
||||
|
||||
const skillsActive = isSkillsMode(cfg.skills);
|
||||
api.logger.info(
|
||||
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
|
||||
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
||||
);
|
||||
|
||||
// Helper: build add options
|
||||
@@ -149,24 +115,22 @@ const memoryPlugin = {
|
||||
return opts;
|
||||
}
|
||||
|
||||
// Helper: build search options (skills config overrides legacy defaults)
|
||||
// Helper: build search options
|
||||
function buildSearchOptions(
|
||||
userIdOverride?: string,
|
||||
limit?: number,
|
||||
runId?: string,
|
||||
sessionKey?: string,
|
||||
): SearchOptions {
|
||||
const recallCfg = cfg.skills?.recall;
|
||||
const opts: SearchOptions = {
|
||||
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
||||
top_k: limit ?? cfg.topK,
|
||||
limit: limit ?? cfg.topK,
|
||||
threshold: recallCfg?.threshold ?? cfg.searchThreshold,
|
||||
keyword_search: recallCfg?.keywordSearch !== false,
|
||||
reranking: recallCfg?.rerank !== false,
|
||||
threshold: cfg.searchThreshold,
|
||||
keyword_search: true,
|
||||
reranking: true,
|
||||
source: "OPENCLAW",
|
||||
};
|
||||
if (recallCfg?.filterMemories) opts.filter_memories = true;
|
||||
if (runId) opts.run_id = runId;
|
||||
return opts;
|
||||
}
|
||||
@@ -175,7 +139,7 @@ const memoryPlugin = {
|
||||
// Tools
|
||||
// ========================================================================
|
||||
|
||||
registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId, skillsActive);
|
||||
registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId);
|
||||
|
||||
// ========================================================================
|
||||
// CLI Commands
|
||||
@@ -189,22 +153,17 @@ const memoryPlugin = {
|
||||
|
||||
registerHooks(api, provider, cfg, _effectiveUserId, buildAddOptions, buildSearchOptions, {
|
||||
setCurrentSessionId: (id: string) => { currentSessionId = id; },
|
||||
getStateDir: () => pluginStateDir,
|
||||
}, skillsActive);
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// Service
|
||||
// ========================================================================
|
||||
|
||||
// State directory for persistent gate tracking (dream consolidation)
|
||||
let pluginStateDir: string | undefined;
|
||||
|
||||
api.registerService({
|
||||
id: "openclaw-mem0",
|
||||
start: (...args: any[]) => {
|
||||
pluginStateDir = args[0]?.stateDir;
|
||||
start: () => {
|
||||
api.logger.info(
|
||||
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, stateDir: ${pluginStateDir ?? "none"})`,
|
||||
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
||||
);
|
||||
},
|
||||
stop: () => {
|
||||
@@ -228,7 +187,6 @@ function registerTools(
|
||||
buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions,
|
||||
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions,
|
||||
getCurrentSessionId: () => string | undefined,
|
||||
skillsActive: boolean = false,
|
||||
) {
|
||||
api.registerTool(
|
||||
{
|
||||
@@ -265,28 +223,14 @@ function registerTools(
|
||||
'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
|
||||
}),
|
||||
),
|
||||
categories: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description:
|
||||
'Filter results by category (e.g. ["identity", "preference"]). Only returns memories tagged with these categories.',
|
||||
}),
|
||||
),
|
||||
filters: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description:
|
||||
'Advanced filters object. Supports date ranges and metadata filtering. Examples: {"created_at": {"gte": "2026-03-01"}} for recent memories, {"AND": [{"categories": {"contains": "decision"}}, {"created_at": {"gte": "2026-01-01"}}]} for decisions this year. Operators: eq, ne, gt, gte, lt, lte, in, contains, icontains. Logical: AND, OR, NOT.',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { query, limit, userId, agentId, scope = "all", categories: filterCategories, filters: agentFilters } = params as {
|
||||
const { query, limit, userId, agentId, scope = "all" } = params as {
|
||||
query: string;
|
||||
limit?: number;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
scope?: "session" | "long-term" | "all";
|
||||
categories?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -294,36 +238,29 @@ function registerTools(
|
||||
const uid = _resolveUserId({ agentId, userId });
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
// Apply agent-provided filters to search options
|
||||
const applyFilters = (opts: SearchOptions): SearchOptions => {
|
||||
if (filterCategories?.length) opts.categories = filterCategories;
|
||||
if (agentFilters) opts.filters = agentFilters;
|
||||
return opts;
|
||||
};
|
||||
|
||||
if (scope === "session") {
|
||||
if (currentSessionId) {
|
||||
results = await provider.search(
|
||||
query,
|
||||
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
|
||||
buildSearchOptions(uid, limit, currentSessionId),
|
||||
);
|
||||
}
|
||||
} else if (scope === "long-term") {
|
||||
results = await provider.search(
|
||||
query,
|
||||
applyFilters(buildSearchOptions(uid, limit)),
|
||||
buildSearchOptions(uid, limit),
|
||||
);
|
||||
} else {
|
||||
// "all" — search both scopes and combine
|
||||
const longTermResults = await provider.search(
|
||||
query,
|
||||
applyFilters(buildSearchOptions(uid, limit)),
|
||||
buildSearchOptions(uid, limit),
|
||||
);
|
||||
let sessionResults: MemoryItem[] = [];
|
||||
if (currentSessionId) {
|
||||
sessionResults = await provider.search(
|
||||
query,
|
||||
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
|
||||
buildSearchOptions(uid, limit, currentSessionId),
|
||||
);
|
||||
}
|
||||
// Deduplicate by ID, preferring long-term
|
||||
@@ -390,25 +327,7 @@ function registerTools(
|
||||
description:
|
||||
"Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
|
||||
parameters: Type.Object({
|
||||
text: Type.Optional(
|
||||
Type.String({ description: "Single fact to remember. Use 'facts' array instead when storing multiple facts from one conversation turn." }),
|
||||
),
|
||||
facts: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Array of facts to store in one call. ALL facts MUST share the same category. If a turn has facts in different categories, make one call per category. Category determines retention policy (TTL, immutability).",
|
||||
}),
|
||||
),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Memory category. Determines retention policy (TTL, immutability). All facts in this call inherit this category. Options: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"',
|
||||
}),
|
||||
),
|
||||
importance: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Importance override (0.0-1.0). Omit to use category default. Applies to all facts in this call. Defaults: identity/config 0.95, rules 0.90, preferences 0.85, decisions 0.80, projects 0.75, operational 0.60",
|
||||
}),
|
||||
),
|
||||
text: Type.String({ description: "Information to remember" }),
|
||||
userId: Type.Optional(
|
||||
Type.String({
|
||||
description: "User ID to scope this memory",
|
||||
@@ -422,7 +341,7 @@ function registerTools(
|
||||
),
|
||||
metadata: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description: "Additional metadata to attach to this memory",
|
||||
description: "Optional metadata to attach to this memory",
|
||||
}),
|
||||
),
|
||||
longTerm: Type.Optional(
|
||||
@@ -433,121 +352,22 @@ function registerTools(
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as {
|
||||
text?: string;
|
||||
facts?: string[];
|
||||
category?: string;
|
||||
importance?: number;
|
||||
const { text, userId, agentId, longTerm = true } = params as {
|
||||
text: string;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
longTerm?: boolean;
|
||||
};
|
||||
const { userId, agentId, longTerm = true } = p;
|
||||
|
||||
// Resolve facts: prefer 'facts' array, fall back to single 'text'
|
||||
const allFacts: string[] = p.facts?.length ? p.facts : (p.text ? [p.text] : []);
|
||||
if (allFacts.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No facts provided. Pass 'text' or 'facts' array." }],
|
||||
details: { error: "missing_facts" },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
// Block subagent writes at the tool level. The system prompt
|
||||
// instructs subagents not to store, but a disobedient tool call
|
||||
// would write to a transient namespace that is never read again.
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_store from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Memory storage is not available in subagent sessions. The main agent handles memory." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
const uid = _resolveUserId({ agentId, userId });
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
||||
|
||||
// Skills mode: bypass extraction LLM, store directly via infer=false
|
||||
if (skillsActive) {
|
||||
// Enforce batch homogeneity: if no category provided for a multi-fact
|
||||
// batch, warn. The prompt teaches batch-by-category but this is the
|
||||
// runtime safety net.
|
||||
if (allFacts.length > 1 && !p.category) {
|
||||
api.logger.warn(
|
||||
`openclaw-mem0: multi-fact batch (${allFacts.length} facts) without category. Retention policy defaults to uncategorized. Prompt instructs batch-by-category.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve metadata: prefer explicit params, fall back to metadata record
|
||||
const rawMetadata = p.metadata;
|
||||
const category = p.category ?? rawMetadata?.category as string | undefined;
|
||||
const importance = p.importance ?? rawMetadata?.importance as number | undefined;
|
||||
const parsedMetadata: Record<string, unknown> = {
|
||||
...(rawMetadata ?? {}),
|
||||
...(category && { category }),
|
||||
...(importance !== undefined && { importance }),
|
||||
};
|
||||
const categories = resolveCategories(cfg.skills);
|
||||
const catConfig = category ? categories[category] : undefined;
|
||||
const expirationDate = catConfig ? ttlToExpirationDate(catConfig.ttl) : undefined;
|
||||
const isImmutable = catConfig?.immutable ?? false;
|
||||
|
||||
// Single API call: all facts go as deduced_memories array
|
||||
const addOpts: AddOptions = {
|
||||
user_id: uid,
|
||||
source: "OPENCLAW",
|
||||
infer: false,
|
||||
deduced_memories: allFacts,
|
||||
metadata: parsedMetadata ?? {},
|
||||
...(expirationDate && { expiration_date: expirationDate }),
|
||||
...(isImmutable && { immutable: true }),
|
||||
};
|
||||
if (runId) addOpts.run_id = runId;
|
||||
if (cfg.mode === "platform") {
|
||||
addOpts.output_format = "v1.1";
|
||||
if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) {
|
||||
addOpts.enable_graph = true;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await provider.add(
|
||||
[{ role: "user", content: allFacts.join("\n") }],
|
||||
addOpts,
|
||||
);
|
||||
|
||||
const count = result.results?.length ?? 0;
|
||||
api.logger.info(
|
||||
`openclaw-mem0: skills-mode stored ${count} memor${count === 1 ? "y" : "ies"} from ${allFacts.length} fact(s) in 1 API call (infer=false, category=${category ?? "none"})`,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map(f => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "stored",
|
||||
mode: "skills",
|
||||
infer: false,
|
||||
category,
|
||||
factCount: allFacts.length,
|
||||
results: result.results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy mode: let mem0 extraction LLM handle it
|
||||
const combinedText = allFacts.join("\n");
|
||||
|
||||
// Pre-check for near-duplicates so the extraction model has
|
||||
// context about existing memories and can UPDATE rather than ADD
|
||||
const preview = combinedText.slice(0, 200);
|
||||
const preview = text.slice(0, 200);
|
||||
const dedupOpts = buildSearchOptions(uid, 3);
|
||||
dedupOpts.threshold = 0.85;
|
||||
const existing = await provider.search(preview, dedupOpts);
|
||||
@@ -558,7 +378,7 @@ function registerTools(
|
||||
}
|
||||
|
||||
const result = await provider.add(
|
||||
[{ role: "user", content: combinedText }],
|
||||
[{ role: "user", content: text }],
|
||||
buildAddOptions(uid, runId, currentSessionId),
|
||||
);
|
||||
|
||||
@@ -790,16 +610,6 @@ function registerTools(
|
||||
};
|
||||
|
||||
try {
|
||||
// Block subagent deletes at the tool level.
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_forget from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Memory deletion is not available in subagent sessions. The main agent handles memory." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
if (memoryId) {
|
||||
await provider.delete(memoryId);
|
||||
return {
|
||||
@@ -888,150 +698,6 @@ function registerTools(
|
||||
},
|
||||
{ name: "memory_forget" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memory_update",
|
||||
label: "Memory Update",
|
||||
description:
|
||||
"Update an existing memory's text in place. Use when a fact has changed and you have the memory ID. This is atomic and preserves the memory's history. Preferred over delete-then-store for corrections.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({ description: "The memory ID to update" }),
|
||||
text: Type.String({ description: "The new text for this memory (replaces the old text)" }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { memoryId, text } = params as { memoryId: string; text: string };
|
||||
|
||||
try {
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_update from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Memory update is not available in subagent sessions." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
await provider.update(memoryId, text);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"` },
|
||||
],
|
||||
details: { action: "updated", id: memoryId },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Memory update failed: ${String(err)}` },
|
||||
],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memory_update" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memory_delete_all",
|
||||
label: "Memory Delete All",
|
||||
description:
|
||||
"Delete ALL memories for a user. Use with extreme caution. This is irreversible. Only use when the user explicitly asks to forget everything or reset their memory.",
|
||||
parameters: Type.Object({
|
||||
confirm: Type.Boolean({
|
||||
description: "Must be true to proceed. Safety gate to prevent accidental bulk deletion.",
|
||||
}),
|
||||
userId: Type.Optional(
|
||||
Type.String({ description: "User ID to delete all memories for (default: configured userId)" }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { confirm, userId } = params as { confirm: boolean; userId?: string };
|
||||
|
||||
try {
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_delete_all from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Bulk memory deletion is not available in subagent sessions." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Bulk deletion requires confirm: true. Ask the user to confirm before proceeding." }],
|
||||
details: { error: "confirmation_required" },
|
||||
};
|
||||
}
|
||||
|
||||
const uid = _resolveUserId({ userId });
|
||||
await provider.deleteAll(uid);
|
||||
api.logger.info(`openclaw-mem0: deleted all memories for user ${uid}`);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `All memories deleted for user "${uid}".` },
|
||||
],
|
||||
details: { action: "deleted_all", user_id: uid },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Bulk memory deletion failed: ${String(err)}` },
|
||||
],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memory_delete_all" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memory_history",
|
||||
label: "Memory History",
|
||||
description:
|
||||
"View the edit history of a specific memory. Shows all changes over time including previous values, new values, and timestamps. Useful for understanding how a memory evolved.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({ description: "The memory ID to view history for" }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { memoryId } = params as { memoryId: string };
|
||||
|
||||
try {
|
||||
const history = await provider.history(memoryId);
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No history found for memory ${memoryId}.` }],
|
||||
details: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const text = history
|
||||
.map((h, i) => `${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`)
|
||||
.join("\n\n");
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}` },
|
||||
],
|
||||
details: { count: history.length, history },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Memory history failed: ${String(err)}` },
|
||||
],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memory_history" },
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1147,72 +813,6 @@ function registerCli(
|
||||
console.error(`Stats failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
mem0
|
||||
.command("dream")
|
||||
.description("Run memory consolidation (review, merge, prune stored memories)")
|
||||
.option("--dry-run", "Show memory inventory without running consolidation")
|
||||
.action(async (opts: { dryRun?: boolean }) => {
|
||||
try {
|
||||
const uid = cfg.userId;
|
||||
const memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
|
||||
const count = Array.isArray(memories) ? memories.length : 0;
|
||||
|
||||
if (count === 0) {
|
||||
console.log("No memories to consolidate.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current state summary on stderr (keeps stdout clean for piping)
|
||||
const catCounts = new Map<string, number>();
|
||||
for (const mem of memories) {
|
||||
const cat = (mem.metadata as any)?.category ?? mem.categories?.[0] ?? "uncategorized";
|
||||
catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1);
|
||||
}
|
||||
process.stderr.write(`\nMemory inventory for "${uid}":\n`);
|
||||
for (const [cat, num] of [...catCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
process.stderr.write(` ${cat}: ${num}\n`);
|
||||
}
|
||||
process.stderr.write(` TOTAL: ${count}\n\n`);
|
||||
|
||||
if (opts.dryRun) {
|
||||
process.stderr.write("Dry run — no changes made.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load dream prompt and format it with the full memory inventory
|
||||
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
|
||||
if (!dreamPrompt) {
|
||||
process.stderr.write("Dream skill file not found at skills/memory-dream/SKILL.md\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the full dream context: protocol + memory dump
|
||||
const memoryDump = (memories as MemoryItem[]).map((m, i) => {
|
||||
const cat = (m.metadata as any)?.category ?? m.categories?.[0] ?? "uncategorized";
|
||||
const imp = (m.metadata as any)?.importance ?? "?";
|
||||
const created = m.created_at ?? "unknown";
|
||||
return `${i + 1}. [${m.id}] (${cat}, importance: ${imp}, created: ${created}) ${m.memory}`;
|
||||
}).join("\n");
|
||||
|
||||
const fullPrompt = [
|
||||
"<dream-protocol>",
|
||||
dreamPrompt,
|
||||
"</dream-protocol>",
|
||||
"",
|
||||
`<all-memories count="${count}" user="${uid}">`,
|
||||
memoryDump,
|
||||
"</all-memories>",
|
||||
"",
|
||||
"Begin consolidation. Review all memories above and execute merge, delete, and rewrite operations using the available tools.",
|
||||
].join("\n");
|
||||
|
||||
// Only the prompt goes to stdout — safe to pipe directly
|
||||
process.stdout.write(fullPrompt + "\n");
|
||||
process.stderr.write(`Dream prompt written to stdout (${fullPrompt.length} chars). Pipe with: openclaw mem0 dream | openclaw run --stdin\n`);
|
||||
} catch (err) {
|
||||
console.error(`Dream failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ commands: ["mem0"] },
|
||||
);
|
||||
@@ -1231,201 +831,8 @@ function registerHooks(
|
||||
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions,
|
||||
session: {
|
||||
setCurrentSessionId: (id: string) => void;
|
||||
getStateDir: () => string | undefined;
|
||||
},
|
||||
skillsActive: boolean = false,
|
||||
) {
|
||||
// ========================================================================
|
||||
// SKILLS MODE: Agentic memory via before_prompt_build
|
||||
// ========================================================================
|
||||
if (skillsActive) {
|
||||
// Use before_prompt_build instead of before_agent_start:
|
||||
// - prependSystemContext: static memory protocol (provider-cacheable, no per-turn cost)
|
||||
// - prependContext: dynamic recalled memories (changes every turn)
|
||||
//
|
||||
// NOTE: We previously used a shared `lastCleanUserMessage` variable populated
|
||||
// by message_received to get clean user content. That variable was process-global
|
||||
// mutable state vulnerable to cross-session races. Removed in favor of using
|
||||
// sanitizeQuery() on event.prompt within this hook, where ctx.sessionKey is
|
||||
// available and the execution is scoped to the correct session.
|
||||
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
||||
if (!event.prompt || event.prompt.length < 5) return;
|
||||
|
||||
const trigger = ctx?.trigger ?? undefined;
|
||||
const sessionId = ctx?.sessionKey ?? undefined;
|
||||
if (isNonInteractiveTrigger(trigger, sessionId)) {
|
||||
api.logger.info("openclaw-mem0: skills-mode skipping non-interactive trigger");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip recall for system/bootstrap prompts. These are OpenClaw internal
|
||||
// commands (/new, /reset) that contain system instructions, not user queries.
|
||||
// Sending them to mem0 search wastes API calls and returns noise.
|
||||
const promptLower = event.prompt.toLowerCase();
|
||||
const isSystemPrompt =
|
||||
promptLower.includes("a new session was started") ||
|
||||
promptLower.includes("session startup sequence") ||
|
||||
promptLower.includes("/new or /reset") ||
|
||||
promptLower.startsWith("system:") ||
|
||||
promptLower.startsWith("run your session");
|
||||
if (isSystemPrompt) {
|
||||
api.logger.info("openclaw-mem0: skills-mode skipping recall for system/bootstrap prompt");
|
||||
// Still inject the protocol, just skip recall search
|
||||
const systemContext = loadTriagePrompt(cfg.skills ?? {});
|
||||
return { prependSystemContext: systemContext };
|
||||
}
|
||||
|
||||
if (sessionId) session.setCurrentSessionId(sessionId);
|
||||
|
||||
const isSubagent = isSubagentSession(sessionId);
|
||||
const userId = _effectiveUserId(isSubagent ? undefined : sessionId);
|
||||
|
||||
// Static protocol goes in prependSystemContext (cacheable across turns)
|
||||
let systemContext = loadTriagePrompt(cfg.skills ?? {});
|
||||
if (isSubagent) {
|
||||
systemContext = "You are a subagent — use these memories for context but do not assume you are this user. Do NOT store new memories.\n\n" + systemContext;
|
||||
}
|
||||
|
||||
// Dynamic recall goes in prependContext (changes every turn).
|
||||
// Strategy controls how much the plugin searches automatically:
|
||||
// "always" — long-term + session search every turn (2 searches)
|
||||
// "smart" — long-term search only, no session search (1 search) [default]
|
||||
// "manual" — no auto-recall; agent controls all search via memory_search (0 searches)
|
||||
let recallContext = "";
|
||||
const recallEnabled = cfg.skills?.recall?.enabled !== false;
|
||||
const recallStrategy = cfg.skills?.recall?.strategy ?? "smart";
|
||||
|
||||
if (recallEnabled && recallStrategy !== "manual") {
|
||||
try {
|
||||
const query = sanitizeQuery(event.prompt);
|
||||
|
||||
// Smart mode: skip session search (saves 1 API call per turn)
|
||||
const sessionIdForRecall = recallStrategy === "always"
|
||||
? (isSubagent ? undefined : sessionId)
|
||||
: undefined; // smart: long-term only
|
||||
|
||||
const recallResult = await skillRecall(
|
||||
provider,
|
||||
query,
|
||||
userId,
|
||||
cfg.skills ?? {},
|
||||
sessionIdForRecall,
|
||||
);
|
||||
|
||||
api.logger.info(
|
||||
`openclaw-mem0: skills-mode recall (strategy=${recallStrategy}) injecting ${recallResult.memories.length} memories (~${recallResult.tokenEstimate} tokens)`,
|
||||
);
|
||||
|
||||
recallContext = recallResult.context;
|
||||
} catch (err) {
|
||||
api.logger.warn(`openclaw-mem0: skills-mode recall failed: ${String(err)}`);
|
||||
}
|
||||
} else if (recallEnabled && recallStrategy === "manual") {
|
||||
api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search");
|
||||
}
|
||||
|
||||
// Auto-dream: check CHEAP gates first (local file reads only).
|
||||
// Only hit the API for memory count if time + session gates pass.
|
||||
const stateDir = session.getStateDir();
|
||||
const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false;
|
||||
let dreamContext = "";
|
||||
if (dreamEnabled && stateDir && !isSubagent) {
|
||||
try {
|
||||
const cheapResult = checkCheapGates(stateDir, cfg.skills?.dream ?? {});
|
||||
if (cheapResult.proceed) {
|
||||
// Cheap gates passed. Now do the expensive memory count check.
|
||||
const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" });
|
||||
const memCount = Array.isArray(memories) ? memories.length : 0;
|
||||
const memResult = checkMemoryGate(memCount, cfg.skills?.dream ?? {});
|
||||
if (memResult.pass && acquireDreamLock(stateDir)) {
|
||||
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
|
||||
if (dreamPrompt) {
|
||||
dreamContext = "\n\n<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n</auto-dream>";
|
||||
// Track which session triggered dream (session-keyed, not global)
|
||||
dreamSessionId = sessionId;
|
||||
api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`);
|
||||
} else {
|
||||
releaseDreamLock(stateDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`openclaw-mem0: auto-dream gate check failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prependSystemContext: systemContext, // cached by provider
|
||||
prependContext: recallContext + dreamContext, // per-turn dynamic
|
||||
};
|
||||
});
|
||||
|
||||
// Session-keyed dream tracking. Only the session that triggered dream
|
||||
// can complete it. Prevents cross-session false completion.
|
||||
let dreamSessionId: string | undefined;
|
||||
|
||||
api.on("agent_end", async (event: any, ctx: any) => {
|
||||
const sessionId = ctx?.sessionKey ?? undefined;
|
||||
const trigger = ctx?.trigger ?? undefined;
|
||||
if (sessionId) session.setCurrentSessionId(sessionId);
|
||||
|
||||
// If dream was triggered for THIS session, handle cleanup regardless
|
||||
// of success/failure. A failed turn must still release the lock.
|
||||
const stateDir = session.getStateDir();
|
||||
if (dreamSessionId && dreamSessionId === sessionId && stateDir) {
|
||||
dreamSessionId = undefined;
|
||||
|
||||
if (!event.success) {
|
||||
// Turn failed/aborted after lock acquired. Release lock, do not
|
||||
// record completion. Gates will re-trigger next eligible turn.
|
||||
releaseDreamLock(stateDir);
|
||||
api.logger.warn("openclaw-mem0: auto-dream turn failed, lock released, will retry");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the model actually performed WRITE operations (not just reads).
|
||||
// Only count memory_store, memory_update, memory_forget, memory_delete_all.
|
||||
// Exclude memory_list and memory_search (read-only, orient-only pass).
|
||||
// Scan only the LAST assistant message (this turn), not the full session
|
||||
// snapshot, to avoid matching earlier tool calls from prior turns.
|
||||
const WRITE_TOOLS = new Set(["memory_store", "memory_update", "memory_forget", "memory_delete_all"]);
|
||||
const messages = event.messages ?? [];
|
||||
// Find the last assistant message (this turn's output)
|
||||
const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant");
|
||||
const writeToolUsed = lastAssistant && Array.isArray(lastAssistant.content)
|
||||
? lastAssistant.content.some((block: any) =>
|
||||
block.type === "tool_use" && WRITE_TOOLS.has(block.name)
|
||||
)
|
||||
: false;
|
||||
|
||||
if (writeToolUsed) {
|
||||
releaseDreamLock(stateDir);
|
||||
recordDreamCompletion(stateDir);
|
||||
api.logger.info("openclaw-mem0: auto-dream completed (verified write tool usage), lock released");
|
||||
} else {
|
||||
releaseDreamLock(stateDir);
|
||||
api.logger.warn("openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.success) return;
|
||||
|
||||
// Track session for dream gating (interactive turns only)
|
||||
if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) {
|
||||
incrementSessionCount(stateDir, sessionId);
|
||||
}
|
||||
|
||||
api.logger.info("openclaw-mem0: skills-mode agent_end (no auto-capture)");
|
||||
});
|
||||
|
||||
return; // Skip legacy hook registration
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// LEGACY MODE: Original auto-recall + auto-capture behavior
|
||||
// ========================================================================
|
||||
|
||||
// Auto-recall: inject relevant memories before agent starts
|
||||
if (cfg.autoRecall) {
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"id": "openclaw-mem0",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"uiHints": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
@@ -69,11 +68,6 @@
|
||||
"label": "Open-Source Configuration",
|
||||
"advanced": true,
|
||||
"help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. Has sensible defaults — only override what you need."
|
||||
},
|
||||
"skills": {
|
||||
"label": "Agentic Memory Skills",
|
||||
"advanced": true,
|
||||
"help": "Enable skills-based memory extraction. The agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Disables auto-capture when active."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
@@ -167,56 +161,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"triage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"importanceThreshold": { "type": "number" },
|
||||
"enableGraph": { "type": "boolean" },
|
||||
"credentialPatterns": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"recall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"strategy": { "type": "string", "enum": ["always", "smart", "manual"] },
|
||||
"tokenBudget": { "type": "number" },
|
||||
"maxMemories": { "type": "number" },
|
||||
"rerank": { "type": "boolean" },
|
||||
"keywordSearch": { "type": "boolean" },
|
||||
"filterMemories": { "type": "boolean" },
|
||||
"threshold": { "type": "number" },
|
||||
"identityAlwaysInclude": { "type": "boolean" },
|
||||
"categoryOrder": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"dream": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"schedule": { "type": "string" },
|
||||
"mergeThreshold": { "type": "number" },
|
||||
"maxMemoriesPerUser": { "type": "number" },
|
||||
"preserveImmutable": { "type": "boolean" },
|
||||
"credentialScan": { "type": "boolean" },
|
||||
"expireStaleAfterDays": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"domain": { "type": "string" },
|
||||
"customRules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include": { "type": "array", "items": { "type": "string" } },
|
||||
"exclude": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"categories": { "type": "object" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
+3
-17
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "1.0.1",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "openclaw"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"plugin",
|
||||
@@ -26,8 +21,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"openclaw.plugin.json",
|
||||
"skills"
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
@@ -40,15 +34,7 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.3.24-beta.2",
|
||||
"minGatewayVersion": "2026.3.24-beta.2"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.3.24-beta.2",
|
||||
"pluginSdkVersion": "2026.3.24-beta.2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
|
||||
+4
-73
@@ -111,12 +111,6 @@ class PlatformProvider implements Mem0Provider {
|
||||
if (options.enable_graph) opts.enable_graph = options.enable_graph;
|
||||
if (options.output_format) opts.output_format = options.output_format;
|
||||
if (options.source) opts.source = options.source;
|
||||
// Agentic harness: direct storage bypass
|
||||
if (options.infer !== undefined) opts.infer = options.infer;
|
||||
if (options.deduced_memories) opts.deduced_memories = options.deduced_memories;
|
||||
if (options.metadata) opts.metadata = options.metadata;
|
||||
if (options.expiration_date) opts.expiration_date = options.expiration_date;
|
||||
if (options.immutable) opts.immutable = options.immutable;
|
||||
|
||||
const result = await this.client.add(messages, opts);
|
||||
return normalizeAddResult(result);
|
||||
@@ -124,26 +118,17 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
// Base filters: always scope by user_id, optionally by run_id
|
||||
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) baseFilters.run_id = options.run_id;
|
||||
|
||||
// Merge agent-provided filters (created_at ranges, metadata, etc.)
|
||||
// with base filters. Agent filters extend, never override user scoping.
|
||||
const mergedFilters = options.filters
|
||||
? { AND: [baseFilters, options.filters] }
|
||||
: baseFilters;
|
||||
const filters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) filters.run_id = options.run_id;
|
||||
|
||||
const opts: Record<string, unknown> = {
|
||||
api_version: "v2",
|
||||
filters: mergedFilters,
|
||||
filters,
|
||||
};
|
||||
if (options.top_k != null) opts.top_k = options.top_k;
|
||||
if (options.threshold != null) opts.threshold = options.threshold;
|
||||
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
||||
if (options.reranking != null) opts.rerank = options.reranking;
|
||||
if (options.filter_memories != null) opts.filter_memories = options.filter_memories;
|
||||
if (options.categories != null) opts.categories = options.categories;
|
||||
|
||||
const results = await this.client.search(query, opts);
|
||||
return normalizeSearchResults(results);
|
||||
@@ -170,26 +155,10 @@ class PlatformProvider implements Mem0Provider {
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.update(memoryId, { text });
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.delete(memoryId);
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.deleteAll({ user_id: userId });
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
|
||||
await this.ensureClient();
|
||||
const result = await this.client.history(memoryId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -268,24 +237,7 @@ class OSSProvider implements Mem0Provider {
|
||||
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) addOpts.runId = options.run_id;
|
||||
if (options.source) addOpts.source = options.source;
|
||||
// Agentic harness: direct storage bypass
|
||||
if (options.infer !== undefined) addOpts.infer = options.infer;
|
||||
if (options.metadata) addOpts.metadata = options.metadata;
|
||||
if (options.expiration_date) addOpts.expirationDate = options.expiration_date;
|
||||
if (options.immutable) addOpts.immutable = options.immutable;
|
||||
|
||||
// OSS SDK doesn't support deduced_memories — when infer=false, it stores
|
||||
// raw message content directly. Rewrite messages to contain the facts so
|
||||
// OSS stores the right text.
|
||||
let effectiveMessages = messages;
|
||||
if (options.infer === false && options.deduced_memories?.length) {
|
||||
effectiveMessages = options.deduced_memories.map((fact) => ({
|
||||
role: "user",
|
||||
content: fact,
|
||||
}));
|
||||
}
|
||||
|
||||
const result = await this.memory.add(effectiveMessages, addOpts);
|
||||
const result = await this.memory.add(messages, addOpts);
|
||||
return normalizeAddResult(result);
|
||||
}
|
||||
|
||||
@@ -331,31 +283,10 @@ class OSSProvider implements Mem0Provider {
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.update(memoryId, { data: text });
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.delete(memoryId);
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.deleteAll({ userId });
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
|
||||
await this.ensureMemory();
|
||||
try {
|
||||
const result = await this.memory.history(memoryId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch {
|
||||
// OSS may not support history depending on config
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Token-budgeted, category-ranked recall engine.
|
||||
*
|
||||
* Replaces naive "dump all search results" with:
|
||||
* 1. Search with rerank + keyword_search
|
||||
* 2. Rank by category priority (identity first)
|
||||
* 3. Token-budget the results
|
||||
* 4. Format by category with importance scores
|
||||
*/
|
||||
|
||||
import type { Mem0Provider, MemoryItem, SkillsConfig, SearchOptions } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Defaults
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_TOKEN_BUDGET = 1500;
|
||||
const DEFAULT_MAX_MEMORIES = 15;
|
||||
const DEFAULT_THRESHOLD = 0.4;
|
||||
const DEFAULT_CATEGORY_ORDER = [
|
||||
"identity",
|
||||
"configuration",
|
||||
"rule",
|
||||
"preference",
|
||||
"decision",
|
||||
"technical",
|
||||
"relationship",
|
||||
"project",
|
||||
"operational",
|
||||
];
|
||||
|
||||
// Rough token estimate: ~4 chars per token for English text
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface RecallResult {
|
||||
/** Formatted context string for injection */
|
||||
context: string;
|
||||
/** Raw memories retrieved */
|
||||
memories: MemoryItem[];
|
||||
/** Token count estimate */
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Category Detection
|
||||
// ============================================================================
|
||||
|
||||
function getMemoryCategory(memory: MemoryItem): string {
|
||||
// Check metadata first (skill-stored memories have explicit category)
|
||||
if (memory.metadata?.category && typeof memory.metadata.category === "string") {
|
||||
return memory.metadata.category;
|
||||
}
|
||||
// Check categories array (mem0-extracted memories)
|
||||
if (memory.categories?.length) {
|
||||
return memory.categories[0];
|
||||
}
|
||||
return "uncategorized";
|
||||
}
|
||||
|
||||
function getMemoryImportance(memory: MemoryItem): number {
|
||||
if (memory.metadata?.importance && typeof memory.metadata.importance === "number") {
|
||||
return memory.metadata.importance;
|
||||
}
|
||||
// Default importance by category
|
||||
const cat = getMemoryCategory(memory);
|
||||
const defaults: Record<string, number> = {
|
||||
identity: 0.95,
|
||||
configuration: 0.95,
|
||||
rule: 0.90,
|
||||
preference: 0.85,
|
||||
decision: 0.80,
|
||||
technical: 0.80,
|
||||
relationship: 0.75,
|
||||
project: 0.75,
|
||||
operational: 0.60,
|
||||
};
|
||||
return defaults[cat] ?? 0.50;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Estimation
|
||||
// ============================================================================
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Ranking
|
||||
// ============================================================================
|
||||
|
||||
function rankMemories(
|
||||
memories: MemoryItem[],
|
||||
categoryOrder: string[],
|
||||
): MemoryItem[] {
|
||||
const orderMap = new Map(categoryOrder.map((cat, i) => [cat, i]));
|
||||
|
||||
return [...memories].sort((a, b) => {
|
||||
const catA = getMemoryCategory(a);
|
||||
const catB = getMemoryCategory(b);
|
||||
const orderA = orderMap.get(catA) ?? 999;
|
||||
const orderB = orderMap.get(catB) ?? 999;
|
||||
|
||||
// Primary sort: category priority
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
|
||||
// Secondary sort: importance (higher first)
|
||||
const impA = getMemoryImportance(a);
|
||||
const impB = getMemoryImportance(b);
|
||||
if (impA !== impB) return impB - impA;
|
||||
|
||||
// Tertiary sort: search relevance score
|
||||
return (b.score ?? 0) - (a.score ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Budgeting
|
||||
// ============================================================================
|
||||
|
||||
function budgetMemories(
|
||||
rankedMemories: MemoryItem[],
|
||||
tokenBudget: number,
|
||||
maxMemories: number,
|
||||
identityAlwaysInclude: boolean,
|
||||
): MemoryItem[] {
|
||||
const selected: MemoryItem[] = [];
|
||||
let usedTokens = 0;
|
||||
|
||||
for (const memory of rankedMemories) {
|
||||
if (selected.length >= maxMemories) break;
|
||||
|
||||
const memTokens = estimateTokens(memory.memory);
|
||||
const isIdentity = getMemoryCategory(memory) === "identity" ||
|
||||
getMemoryCategory(memory) === "configuration";
|
||||
|
||||
// Identity/config always included if flag is set
|
||||
if (identityAlwaysInclude && isIdentity) {
|
||||
selected.push(memory);
|
||||
usedTokens += memTokens;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Budget check for non-identity memories
|
||||
if (usedTokens + memTokens > tokenBudget) continue;
|
||||
|
||||
selected.push(memory);
|
||||
usedTokens += memTokens;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Formatting
|
||||
// ============================================================================
|
||||
|
||||
function formatRecalledMemories(
|
||||
memories: MemoryItem[],
|
||||
userId: string,
|
||||
): string {
|
||||
if (memories.length === 0) {
|
||||
return `<recalled-memories>\nNo stored memories found for "${userId}".\n</recalled-memories>`;
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = new Map<string, MemoryItem[]>();
|
||||
for (const mem of memories) {
|
||||
const cat = getMemoryCategory(mem);
|
||||
const existing = grouped.get(cat) || [];
|
||||
existing.push(mem);
|
||||
grouped.set(cat, existing);
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`<recalled-memories>`,
|
||||
`Stored memories for "${userId}" (${memories.length} total, ranked by importance):`,
|
||||
"",
|
||||
];
|
||||
|
||||
// Format each category group
|
||||
for (const [category, mems] of grouped.entries()) {
|
||||
const label = category.charAt(0).toUpperCase() + category.slice(1);
|
||||
lines.push(`${label}:`);
|
||||
for (const mem of mems) {
|
||||
const imp = getMemoryImportance(mem);
|
||||
const cats = mem.categories?.length ? ` [${mem.categories.join(", ")}]` : "";
|
||||
lines.push(`- ${mem.memory}${cats} (${Math.round(imp * 100)}%)`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("</recalled-memories>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Sanitization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Strip OpenClaw metadata prefix from event.prompt before using as search query.
|
||||
* This only removes framework noise (sender metadata, timestamps) — NOT
|
||||
* conversational rewriting. Query rewriting is the agent's responsibility
|
||||
* via the skill protocol (the agent formulates search queries with context).
|
||||
*/
|
||||
export function sanitizeQuery(raw: string): string {
|
||||
let cleaned = raw.replace(/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "");
|
||||
cleaned = cleaned.replace(/^\[.*?\]\s*/g, "");
|
||||
cleaned = cleaned.trim();
|
||||
return cleaned || raw;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Perform token-budgeted, category-ranked recall.
|
||||
*/
|
||||
export async function recall(
|
||||
provider: Mem0Provider,
|
||||
query: string,
|
||||
userId: string,
|
||||
config: SkillsConfig = {},
|
||||
sessionId?: string,
|
||||
): Promise<RecallResult> {
|
||||
const recallConfig = config.recall ?? {};
|
||||
const tokenBudget = recallConfig.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
|
||||
const maxMemories = recallConfig.maxMemories ?? DEFAULT_MAX_MEMORIES;
|
||||
const threshold = recallConfig.threshold ?? DEFAULT_THRESHOLD;
|
||||
const categoryOrder = recallConfig.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
|
||||
const identityAlwaysInclude = recallConfig.identityAlwaysInclude !== false;
|
||||
|
||||
// Build search options with enhanced features
|
||||
const searchOpts: SearchOptions = {
|
||||
user_id: userId,
|
||||
top_k: maxMemories * 2, // Over-fetch for ranking
|
||||
threshold,
|
||||
keyword_search: recallConfig.keywordSearch !== false, // Default on
|
||||
reranking: recallConfig.rerank !== false, // Default on
|
||||
};
|
||||
if (recallConfig.filterMemories) {
|
||||
searchOpts.filter_memories = true;
|
||||
}
|
||||
|
||||
// Sanitize query: strip OpenClaw metadata prefix before searching
|
||||
const cleanQuery = sanitizeQuery(query);
|
||||
|
||||
// Search long-term memories
|
||||
let longTermMemories: MemoryItem[] = [];
|
||||
try {
|
||||
longTermMemories = await provider.search(cleanQuery, searchOpts);
|
||||
} catch (err) {
|
||||
// Graceful degradation — recall failure shouldn't block the agent
|
||||
console.warn("[mem0] Recall search failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Search session memories if we have a session
|
||||
let sessionMemories: MemoryItem[] = [];
|
||||
if (sessionId) {
|
||||
try {
|
||||
sessionMemories = await provider.search(cleanQuery, {
|
||||
...searchOpts,
|
||||
run_id: sessionId,
|
||||
top_k: 5,
|
||||
});
|
||||
} catch {
|
||||
// Session search failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: session memories that are also in long-term
|
||||
const longTermIds = new Set(longTermMemories.map((m) => m.id));
|
||||
const uniqueSession = sessionMemories.filter((m) => !longTermIds.has(m.id));
|
||||
|
||||
// Combine and rank
|
||||
const allMemories = [...longTermMemories, ...uniqueSession];
|
||||
const ranked = rankMemories(allMemories, categoryOrder);
|
||||
const budgeted = budgetMemories(ranked, tokenBudget, maxMemories, identityAlwaysInclude);
|
||||
|
||||
// Format for injection
|
||||
const context = formatRecalledMemories(budgeted, userId);
|
||||
const tokenEstimate = estimateTokens(context);
|
||||
|
||||
return { context, memories: budgeted, tokenEstimate };
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch ~/.openclaw/openclaw.json with skills-mode settings."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
config_path = os.path.expanduser("~/.openclaw/openclaw.json")
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
print(f"Error: {config_path} not found. Run 'openclaw configure' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Backup original config
|
||||
backup_path = config_path + ".pre-skills-backup"
|
||||
if not os.path.exists(backup_path):
|
||||
shutil.copy2(config_path, backup_path)
|
||||
print(f" Backed up config to {backup_path}")
|
||||
else:
|
||||
print(f" Backup already exists at {backup_path}")
|
||||
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
# 1. Tools profile = full (exposes plugin tools to the model)
|
||||
cfg["tools"] = {"profile": "full"}
|
||||
|
||||
# 2. Disable built-in session-memory hook
|
||||
cfg.setdefault("hooks", {}).setdefault("internal", {}).setdefault("entries", {})
|
||||
cfg["hooks"]["internal"]["entries"]["session-memory"] = {"enabled": False}
|
||||
|
||||
# 3. Plugin config with skills enabled
|
||||
entries = cfg.setdefault("plugins", {}).setdefault("entries", {})
|
||||
entries["openclaw-mem0"] = {
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"apiKey": api_key,
|
||||
"userId": user_id,
|
||||
"skills": {
|
||||
"triage": {"enabled": True},
|
||||
"recall": {
|
||||
"enabled": True,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": True,
|
||||
"keywordSearch": True,
|
||||
"identityAlwaysInclude": True,
|
||||
},
|
||||
"dream": {"enabled": True},
|
||||
"domain": "companion",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
||||
print(" Config updated:")
|
||||
print(" tools.profile = full")
|
||||
print(" session-memory = disabled")
|
||||
print(f" skills = enabled (user: {user_id}, domain: companion)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* Skill Loader — reads skill markdown files, merges domain overlays,
|
||||
* injects user config, and produces the final injectable prompt string.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { SkillsConfig, CategoryConfig } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Defaults
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
configuration: { importance: 0.95, ttl: null },
|
||||
rule: { importance: 0.90, ttl: null },
|
||||
identity: { importance: 0.95, ttl: null, immutable: true },
|
||||
preference: { importance: 0.85, ttl: null },
|
||||
decision: { importance: 0.80, ttl: null },
|
||||
technical: { importance: 0.80, ttl: null },
|
||||
relationship: { importance: 0.75, ttl: null },
|
||||
project: { importance: 0.75, ttl: "90d" },
|
||||
operational: { importance: 0.60, ttl: "7d" },
|
||||
};
|
||||
|
||||
const DEFAULT_CREDENTIAL_PATTERNS = [
|
||||
"sk-", "m0-", "ghp_", "AKIA", "ak_", "Bearer ",
|
||||
"bot\\d+:AA", "password=", "token=", "secret=",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Skill File Reader
|
||||
// ============================================================================
|
||||
|
||||
interface SkillFrontmatter {
|
||||
name: string;
|
||||
description?: string;
|
||||
"user-invocable"?: boolean;
|
||||
metadata?: string;
|
||||
applies_to?: string;
|
||||
}
|
||||
|
||||
interface ParsedSkill {
|
||||
frontmatter: SkillFrontmatter;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseSkillFile(content: string): ParsedSkill {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!fmMatch) {
|
||||
return {
|
||||
frontmatter: { name: "unknown" },
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
|
||||
const fmBlock = fmMatch[1];
|
||||
const body = fmMatch[2].trim();
|
||||
|
||||
// Simple YAML-like parsing (no dependency needed)
|
||||
const fm: Record<string, any> = {};
|
||||
for (const line of fmBlock.split("\n")) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value: any = line.slice(colonIdx + 1).trim();
|
||||
if (value === "false") value = false;
|
||||
else if (value === "true") value = true;
|
||||
fm[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: fm as SkillFrontmatter,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Loader
|
||||
// ============================================================================
|
||||
|
||||
// Resolve skills directory with multiple fallback strategies.
|
||||
// OpenClaw may load the plugin via jiti or custom loaders that break
|
||||
// import.meta.url, so we try several paths.
|
||||
function resolveSkillsDir(): string {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// Strategy 1: import.meta.url (works in native ESM)
|
||||
try {
|
||||
const metaDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
candidates.push(path.join(metaDir, "skills"));
|
||||
candidates.push(path.join(metaDir, "..", "skills"));
|
||||
} catch { /* import.meta.url may not be available */ }
|
||||
|
||||
// Strategy 2: __dirname (works in CJS / jiti)
|
||||
if (typeof __dirname !== "undefined") {
|
||||
candidates.push(path.join(__dirname, "skills"));
|
||||
candidates.push(path.join(__dirname, "..", "skills"));
|
||||
}
|
||||
|
||||
// Validate: must contain the expected subdirectory structure
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, "memory-triage", "SKILL.md"))) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ?? "skills"; // Will fail gracefully in readSkillFile
|
||||
}
|
||||
|
||||
const SKILLS_DIR = resolveSkillsDir();
|
||||
|
||||
function readSkillFile(skillName: string): string | null {
|
||||
// Skills use OpenClaw directory format: <skill-name>/SKILL.md
|
||||
const filePath = path.join(SKILLS_DIR, skillName, "SKILL.md");
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a domain overlay, scoped to a specific skill.
|
||||
* Domain overlays live inside the skill directory: <skill>/domains/<domain>.md
|
||||
* The `applies_to` frontmatter field is checked for backward compatibility.
|
||||
*/
|
||||
function readDomainOverlay(domain: string, targetSkill: string): string | null {
|
||||
// Domain overlays are stored inside the target skill's directory
|
||||
const filePath = path.join(SKILLS_DIR, targetSkill, "domains", `${domain}.md`);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = parseSkillFile(content);
|
||||
// Check applies_to for backward compat (skip if targeting a different skill)
|
||||
const appliesTo = parsed.frontmatter.applies_to;
|
||||
if (appliesTo && appliesTo !== targetSkill) {
|
||||
return null;
|
||||
}
|
||||
return parsed.body;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config Injection — render user-configured knobs into prompt text
|
||||
// ============================================================================
|
||||
|
||||
function renderCategoriesBlock(categories: Record<string, CategoryConfig>): string {
|
||||
const lines: string[] = ["\n## Active Category Configuration (overrides defaults above)\n"];
|
||||
for (const [name, cat] of Object.entries(categories)) {
|
||||
const ttlLabel = cat.ttl ? `expires: ${cat.ttl}` : "permanent";
|
||||
const immLabel = cat.immutable ? ", immutable" : "";
|
||||
lines.push(`- **${name.toUpperCase()}** (importance: ${cat.importance} | ${ttlLabel}${immLabel})`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderTriageKnobs(config: SkillsConfig): string {
|
||||
const triage = config.triage;
|
||||
if (!triage) return "";
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (triage.importanceThreshold !== undefined) {
|
||||
lines.push(`- Only store facts with importance >= ${triage.importanceThreshold}`);
|
||||
}
|
||||
|
||||
const patterns = resolveCredentialPatterns(config);
|
||||
if (config.triage?.credentialPatterns) {
|
||||
lines.push(`- Credential patterns to scan: ${patterns.join(", ")}`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return "";
|
||||
return "\n## Active Configuration Overrides\n\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TTL Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Convert TTL string like "7d", "90d" to ISO date from today */
|
||||
export function ttlToExpirationDate(ttl: string | null): string | null {
|
||||
if (!ttl) return null;
|
||||
const match = ttl.match(/^(\d+)d$/);
|
||||
if (!match) return null;
|
||||
const days = parseInt(match[1], 10);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string;
|
||||
prompt: string;
|
||||
frontmatter: SkillFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a skill by name, merge domain overlays and user config,
|
||||
* and return the final injectable prompt string.
|
||||
*/
|
||||
export function loadSkill(
|
||||
skillName: string,
|
||||
config: SkillsConfig = {},
|
||||
): LoadedSkill | null {
|
||||
const raw = readSkillFile(skillName);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = parseSkillFile(raw);
|
||||
const parts: string[] = [parsed.body];
|
||||
|
||||
// Domain overlays only apply to the skill they target (checked via applies_to)
|
||||
if (config.domain) {
|
||||
const overlay = readDomainOverlay(config.domain, skillName);
|
||||
if (overlay) {
|
||||
parts.push("\n" + overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject user-configured categories into triage skill prompt
|
||||
if (skillName === "memory-triage" && config.categories) {
|
||||
const mergedCats = resolveCategories(config);
|
||||
parts.push(renderCategoriesBlock(mergedCats));
|
||||
}
|
||||
|
||||
// Inject triage knobs (maxFactsPerTurn, importanceThreshold, credentialPatterns)
|
||||
if (skillName === "memory-triage") {
|
||||
const knobs = renderTriageKnobs(config);
|
||||
if (knobs) parts.push(knobs);
|
||||
}
|
||||
|
||||
// Append user custom rules (triage-only — extraction rules don't apply to recall/dream)
|
||||
if (skillName === "memory-triage" && config.customRules) {
|
||||
const rulesBlock: string[] = ["\n## User Custom Rules\n"];
|
||||
if (config.customRules.include?.length) {
|
||||
rulesBlock.push("Additionally extract:");
|
||||
for (const rule of config.customRules.include) {
|
||||
rulesBlock.push(`- ${rule}`);
|
||||
}
|
||||
}
|
||||
if (config.customRules.exclude?.length) {
|
||||
rulesBlock.push("\nAdditionally skip:");
|
||||
for (const rule of config.customRules.exclude) {
|
||||
rulesBlock.push(`- ${rule}`);
|
||||
}
|
||||
}
|
||||
parts.push(rulesBlock.join("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
prompt: parts.join("\n"),
|
||||
frontmatter: parsed.frontmatter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the memory system prompt for injection via prependSystemContext.
|
||||
*
|
||||
* Primary path: load the full SKILL.md via loadSkill(), which merges
|
||||
* domain overlays, category overrides, custom rules, and triage knobs.
|
||||
* This ensures the config surface (skills.domain, customRules, categories)
|
||||
* is what the live before_prompt_build path actually sends.
|
||||
*
|
||||
* Fallback: if SKILL.md cannot be read (missing file, broken path), use
|
||||
* a minimal inline protocol so memory still functions.
|
||||
*/
|
||||
export function loadTriagePrompt(config: SkillsConfig = {}): string {
|
||||
// Try to load the full skill with all config-driven overlays
|
||||
const triage = loadSkill("memory-triage", config);
|
||||
|
||||
if (triage) {
|
||||
// Full SKILL.md loaded with domain overlays, categories, custom rules, knobs merged.
|
||||
// Wrap in <memory-system> and append the operational instructions that
|
||||
// are not part of the SKILL.md (tool format, batching, search protocol).
|
||||
const parts: string[] = [];
|
||||
parts.push("<memory-system>");
|
||||
parts.push("IMPORTANT: Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
|
||||
parts.push("");
|
||||
parts.push(triage.prompt);
|
||||
parts.push("");
|
||||
parts.push("## Tool Usage");
|
||||
parts.push("");
|
||||
parts.push("Batch facts by CATEGORY. All facts in one memory_store call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.");
|
||||
parts.push("");
|
||||
parts.push("FORMAT (single category):");
|
||||
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
|
||||
parts.push("FORMAT (mixed categories in one turn, separate calls):");
|
||||
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
|
||||
parts.push(' memory_store(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")');
|
||||
// Only include search instructions if recall is enabled
|
||||
if (config.recall?.enabled !== false) {
|
||||
const strategy = config.recall?.strategy ?? "smart";
|
||||
parts.push("");
|
||||
parts.push("## Searching Memory");
|
||||
parts.push("");
|
||||
|
||||
// In manual mode, the agent is fully responsible for all search
|
||||
if (strategy === "manual") {
|
||||
parts.push("You control all memory search. No automatic recall happens. Use memory_search proactively:");
|
||||
parts.push("- At the start of a new conversation, search for user identity and context.");
|
||||
parts.push("- When the user references something you do not have context for.");
|
||||
parts.push("- When the conversation topic shifts to a new domain.");
|
||||
parts.push("- Before updating a memory, search to find the existing version.");
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push("When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.");
|
||||
parts.push("Stored memories are third-person factual statements. Write a query that matches storage language, not conversation language.");
|
||||
parts.push("Process: (1) Name your target. (2) Extract signal: proper nouns, technical terms, domain concepts. (3) Bridge to storage language: add terms the stored memory contains (user, decided, prefers, rule, configured, based in). (4) Compose 3-6 keywords.");
|
||||
parts.push('WRONG: memory_search("Who was that nutritionist my wife recommended?")');
|
||||
parts.push('RIGHT: memory_search("nutritionist wife recommended relationship")');
|
||||
parts.push('WRONG: memory_search("What timezone am I in?")');
|
||||
parts.push('RIGHT: memory_search("user timezone location based")');
|
||||
parts.push("");
|
||||
parts.push("ENTITY SCOPING: Memories are scoped by user_id, agent_id, and run_id. You do not need to pass these in most cases. The plugin handles scoping automatically based on the current session.");
|
||||
parts.push("- Default behavior: all memory operations use the configured userId and current session. You do not need to pass userId or agentId.");
|
||||
parts.push("- Use agentId only when you need to read or write memories for a DIFFERENT agent (e.g., querying what the 'researcher' agent knows). This accesses a separate namespace.");
|
||||
parts.push("- Use userId only when explicitly instructed to operate on a different user's memories.");
|
||||
parts.push("- Do not pass run_id directly. The plugin manages session scoping through the scope parameter.");
|
||||
parts.push("- In multi-agent setups, each agent has isolated memory. The main agent's memories are separate from subagent memories.");
|
||||
parts.push("");
|
||||
parts.push("SEARCH SCOPE: Choose the right scope for each search:");
|
||||
parts.push('- scope: "long-term" for user context, identity, preferences, decisions (default, most common)');
|
||||
parts.push('- scope: "session" for facts from this conversation only');
|
||||
parts.push('- scope: "all" only when you truly need both scopes combined');
|
||||
parts.push("Using a specific scope avoids unnecessary backend fan-out.");
|
||||
parts.push("");
|
||||
parts.push("SEARCH FILTERS: When the user's intent implies a time range or category constraint, pass a `filters` object alongside your rewritten query.");
|
||||
parts.push('- Time: "last week" -> filters: {"created_at": {"gte": "2026-03-24"}}');
|
||||
parts.push('- Category: "my preferences" -> categories: ["preference"]');
|
||||
parts.push("- Available operators: eq, ne, gt, gte, lt, lte, in, contains. Logical: AND, OR, NOT.");
|
||||
}
|
||||
parts.push("</memory-system>");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Fallback: SKILL.md not found. Minimal inline protocol.
|
||||
const parts: string[] = [];
|
||||
parts.push("<memory-system>");
|
||||
parts.push("You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.");
|
||||
parts.push("Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
|
||||
parts.push("Most turns produce ZERO memory operations. That is correct.");
|
||||
parts.push("Only store facts a new agent would need days later: identity, preferences, decisions, rules, projects, configs.");
|
||||
parts.push("Batch facts by CATEGORY. All facts in one call must share the same category.");
|
||||
parts.push('Format: memory_store(facts: ["fact text"], category: "identity")');
|
||||
parts.push("NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).");
|
||||
if (config.recall?.enabled !== false) {
|
||||
parts.push("When searching, rewrite queries for retrieval. Do not pass raw user messages.");
|
||||
}
|
||||
parts.push("</memory-system>");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the dream skill prompt for consolidation sessions.
|
||||
*/
|
||||
export function loadDreamPrompt(config: SkillsConfig = {}): string {
|
||||
const dream = loadSkill("memory-dream", config);
|
||||
if (!dream) return "";
|
||||
return dream.prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective categories — user overrides merged with defaults.
|
||||
*/
|
||||
export function resolveCategories(
|
||||
config: SkillsConfig = {},
|
||||
): Record<string, CategoryConfig> {
|
||||
return { ...DEFAULT_CATEGORIES, ...(config.categories || {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credential patterns — user overrides merged with defaults.
|
||||
*/
|
||||
export function resolveCredentialPatterns(config: SkillsConfig = {}): string[] {
|
||||
return config.triage?.credentialPatterns ?? DEFAULT_CREDENTIAL_PATTERNS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if skills mode is active (triage enabled).
|
||||
*/
|
||||
export function isSkillsMode(config: SkillsConfig | undefined): boolean {
|
||||
if (!config) return false;
|
||||
return config.triage?.enabled !== false; // enabled by default when skills config exists
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
name: memory-dream
|
||||
description: >
|
||||
Memory consolidation protocol. Reviews all stored memories, merges duplicates,
|
||||
removes noise and credentials, rewrites unclear entries, and enforces TTL expiration.
|
||||
Use when the user asks to clean up, consolidate, or review their memories.
|
||||
Also triggers automatically after sufficient activity (configurable).
|
||||
user-invocable: true
|
||||
metadata:
|
||||
{"openclaw": {"emoji": "💤"}}
|
||||
---
|
||||
|
||||
# Memory Consolidation
|
||||
|
||||
You are performing a memory consolidation pass. Your goal is to review all stored memories for this user and improve their overall quality. Think of this as compressing raw observations into clean, durable knowledge.
|
||||
|
||||
Follow these four phases in order. Do not skip phases.
|
||||
|
||||
## Phase 1: Orient
|
||||
|
||||
Survey the current memory landscape before making any changes.
|
||||
|
||||
1. Call `memory_list` to load all stored memories.
|
||||
2. Count memories by category. Note the total.
|
||||
3. Identify the oldest and newest memories by their timestamps.
|
||||
4. Note any obvious problems visible in the list: duplicates, very short entries, entries without temporal anchors.
|
||||
|
||||
Do not modify anything in this phase. The goal is to understand what you are working with.
|
||||
|
||||
## Phase 2: Gather Targets
|
||||
|
||||
Identify which memories need action. Use the tools to investigate.
|
||||
|
||||
**Search for recent additions:**
|
||||
Call `memory_search` with a `created_at` filter to find memories added since the last consolidation. These are the most likely to need merging or cleanup.
|
||||
|
||||
**Check edit history on suspicious entries:**
|
||||
If a memory looks like it was updated multiple times or has contradictory content, call `memory_history` on it to understand how it evolved.
|
||||
|
||||
**Classify each target into one of these actions:**
|
||||
- DELETE: contains credentials, expired by TTL, pure noise, raw tool output, standalone timestamps
|
||||
- MERGE: two or more memories express the same fact in different words, or a series tracks incremental changes to the same entity
|
||||
- REWRITE: vague, missing temporal anchor, uses first person instead of third, wrong category, overly verbose
|
||||
|
||||
## Phase 3: Consolidate
|
||||
|
||||
Execute the actions identified in Phase 2. Work in this priority order:
|
||||
|
||||
### 3a. Delete dangerous and expired entries
|
||||
|
||||
Delete immediately using `memory_forget`:
|
||||
- Credentials, API keys, tokens, passwords, secrets (patterns: sk-, m0-, ghp_, AKIA, Bearer, password=, token=, secret=)
|
||||
- Pure timestamps with no context
|
||||
- Raw tool output stored as memory
|
||||
- Heartbeat or cron execution records
|
||||
- Generic acknowledgments stored as memory ("ok", "got it")
|
||||
- Operational memories older than 7 days
|
||||
- Project memories older than 90 days
|
||||
|
||||
### 3b. Merge duplicates
|
||||
|
||||
When two or more memories express the same fact:
|
||||
1. Pick the most complete version as the base
|
||||
2. Call `memory_update` on the best version to incorporate missing details from the others
|
||||
3. Call `memory_forget` on the redundant entries
|
||||
|
||||
`memory_update` is preferred over forget-then-store because it is atomic and preserves edit history.
|
||||
|
||||
When merging, follow these rules:
|
||||
- Keep the user's original words for opinions and preferences
|
||||
- Preserve temporal anchors from both versions
|
||||
- Do not exceed 50 words in the merged result
|
||||
- The merged memory must be self-contained (understandable without the deleted ones)
|
||||
|
||||
### 3c. Rewrite unclear entries
|
||||
|
||||
When a memory needs improvement but is not a duplicate:
|
||||
1. Call `memory_update` with the improved text
|
||||
|
||||
Rewrite when:
|
||||
- Memory uses first person ("I prefer") instead of third ("User prefers")
|
||||
- Memory lacks a temporal anchor for time-sensitive information
|
||||
- Memory is vague ("likes python") and can be made specific ("User prefers Python for backend development")
|
||||
- Memory has the wrong category assignment
|
||||
- Memory is over 50 words and can be compressed without losing information
|
||||
|
||||
## Phase 4: Report
|
||||
|
||||
After completing all operations, summarize what you did:
|
||||
|
||||
```
|
||||
Consolidation complete.
|
||||
- Reviewed: [total count]
|
||||
- Deleted (credentials/secrets): [count]
|
||||
- Deleted (expired/stale): [count]
|
||||
- Merged: [count] groups into [count] memories
|
||||
- Rewritten: [count]
|
||||
- Final count: [total remaining]
|
||||
- Issues found: [any notable problems or observations]
|
||||
```
|
||||
|
||||
## Quality Targets
|
||||
|
||||
After consolidation, the memory store should have:
|
||||
- Zero memories containing credentials or secrets
|
||||
- Zero duplicate memories (same fact in different words)
|
||||
- All project and operational memories have temporal anchors ("As of YYYY-MM-DD")
|
||||
- All memories use third person voice
|
||||
- All memories are correctly categorized
|
||||
- Each memory is 15-50 words, self-contained, and atomic (one fact per memory)
|
||||
@@ -1,340 +0,0 @@
|
||||
---
|
||||
name: memory-triage
|
||||
description: >
|
||||
ALWAYS USE THIS SKILL. Persistent long-term memory protocol powered by mem0.
|
||||
After EVERY response, evaluate the conversation for durable facts worth storing
|
||||
via memory_store. Handles identity, preferences, decisions, configurations, rules,
|
||||
projects, and relationships. Use memory_store (NOT workspace files) for all user facts.
|
||||
Read this skill at the start of every session.
|
||||
user-invocable: false
|
||||
metadata:
|
||||
{"openclaw": {"always": true, "emoji": "🧠"}}
|
||||
---
|
||||
|
||||
# Memory Protocol
|
||||
|
||||
You have persistent long-term memory powered by mem0. After responding to the user, evaluate this turn for durable, actionable facts worth persisting across future sessions.
|
||||
|
||||
Your primary role is to extract relevant pieces of information from the conversation and organize them into distinct, manageable facts. This allows for easy retrieval and personalization in future interactions.
|
||||
|
||||
**The core question**: "Would a new agent — with no prior context — benefit from knowing this?" If no → do nothing. Most turns produce zero memory operations. That is correct and expected.
|
||||
|
||||
## Decision Gate
|
||||
|
||||
Every candidate fact must pass ALL four gates:
|
||||
|
||||
**Gate 1 — FUTURE UTILITY**: Would this matter to a new agent days or weeks from now?
|
||||
- Pass: identity, configurations, standing rules, preferences with rationale, decisions, project milestones, relationships, important personal details
|
||||
- Fail: tool outputs, status checks, one-time commands, transient state, small talk, generic responses → SKIP
|
||||
|
||||
**Gate 2 — NOVELTY**: Check your recalled memories below — is this already known?
|
||||
- Already known and unchanged → SKIP
|
||||
- Known but materially changed → UPDATE (find old → forget → store new)
|
||||
- Genuinely new → proceed
|
||||
- **Material difference test**: Only UPDATE if new information adds real context, details, or changes meaning. Cosmetic differences (synonyms, rephrasing, punctuation) are NOT updates. "Loves daily walks" vs "enjoys daily walks" = no material change = SKIP.
|
||||
|
||||
**Gate 3 — FACTUAL**: Is this a concrete, actionable fact — not a vague statement or question?
|
||||
- Pass: specific names, configs, choices with rationale, deadlines, system states, plans, preferences
|
||||
- Fail: vague impressions, questions, small talk, acknowledgments, generic assistant responses ("Sure, I can help") → SKIP
|
||||
|
||||
**Gate 4 — SAFE**: Does this contain ANY credential, secret, or token?
|
||||
- Scan for: `sk-`, `m0-`, `ghp_`, `AKIA`, `ak_`, `Bearer `, bot tokens (digits:alphanumeric), webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env context, `password=`, `token=`, `secret=`, `.env` values
|
||||
- ANY match → NEVER STORE the value. Instead, store that the credential was configured:
|
||||
- WRONG: "User's API key is sk-abc123..."
|
||||
- RIGHT: "API key was configured for the service (as of 2026-03-30)"
|
||||
- When in doubt → SKIP. No exceptions.
|
||||
|
||||
All four gates must pass. If any fails → do nothing.
|
||||
|
||||
## What to Extract (Priority Order)
|
||||
|
||||
### 1. Configuration & System State (importance: 0.95 | permanent)
|
||||
Tools/services configured, installed, or removed (with versions/dates). Model assignments for agents. Cron schedules, automation pipelines, deployment configs. Architecture decisions. Specific identifiers: file paths, sheet IDs, channel IDs, machine specs.
|
||||
```
|
||||
"User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
|
||||
"User's executive orchestrator agent Quin runs on Claude Opus, heartbeat every 10 min"
|
||||
```
|
||||
|
||||
### 2. Standing Rules & Policies (importance: 0.90 | permanent)
|
||||
Explicit user directives about behavior. Workflow policies. Security constraints, permission boundaries. Always capture the reason.
|
||||
```
|
||||
"User rule: never create accounts without explicit user consent. Reason: security policy"
|
||||
"User rule: each agent must review model selection before completing a task"
|
||||
```
|
||||
|
||||
### 3. Identity & Demographics (importance: 0.95 | permanent)
|
||||
Name, location, timezone, language preferences. Occupation, employer, job role, industry. Keep related facts together in a single memory.
|
||||
```
|
||||
"User is Chris, senior platform engineer at Mem0, based in EST timezone"
|
||||
```
|
||||
|
||||
### 4. Preferences & Opinions (importance: 0.85 | permanent)
|
||||
Communication style, tool preferences, technology opinions. Always capture the WHY when stated. Preserve the user's exact words for feelings and opinions.
|
||||
```
|
||||
"User prefers Cursor over VS Code for AI-assisted coding because of inline completions"
|
||||
"User prefers terse responses with no trailing summaries"
|
||||
```
|
||||
|
||||
### 5. Goals, Projects & Milestones (importance: 0.75 | expires: 90 days)
|
||||
Active projects with name, description, current status. Completed milestones with dates. Deadlines, roadmaps, progress.
|
||||
```
|
||||
"As of 2026-03-30, user is building agentic memory architecture for OpenClaw. Status: active development, team demo planned early April"
|
||||
"ElevenLabs voice integration fully configured as of 2026-02-20"
|
||||
```
|
||||
|
||||
### 6. Technical Context (importance: 0.80 | permanent)
|
||||
Tech stack, development environment, agent ecosystem structure (names, roles, relationships). Skill levels.
|
||||
```
|
||||
"User's stack: Python/Django backend, Next.js 15 frontend, PostgreSQL with pgvector, deployed on EKS"
|
||||
```
|
||||
|
||||
### 7. Relationships & People (importance: 0.75 | permanent)
|
||||
Names and roles of people mentioned. Team structure, key contacts.
|
||||
```
|
||||
"Deshraj owns the frontend, Taranjeet owns the backend platform at Mem0"
|
||||
```
|
||||
|
||||
### 8. Decisions & Lessons (importance: 0.80 | permanent)
|
||||
Important decisions made with reasoning. Lessons learned. Strategies that worked or failed.
|
||||
```
|
||||
"As of 2026-03-30, user decided to use infer=false for all skill-based memory storage — agent extracts, mem0 stores directly without re-extraction"
|
||||
```
|
||||
|
||||
## CRITICAL: Memory Completeness and Self-Containment
|
||||
|
||||
Each memory you store must be a **self-contained, independently understandable fact**. This is the single most important quality rule.
|
||||
|
||||
### Entity-Based Grouping
|
||||
|
||||
**ALWAYS group all information about the same entity, concept, event, or subject into a SINGLE unified memory.** If multiple pieces of information refer to the same entity (e.g., a conference, a project, a person, a system), they MUST be combined into one comprehensive memory.
|
||||
|
||||
**DO NOT split requirements, specifications, or details about the same entity across multiple memory_store calls.** Even if information is phrased differently ("Budget for X", "X requires Y", "X needs Z"), if they all refer to the same entity, combine ALL into ONE call.
|
||||
|
||||
**WRONG** — fragmented into separate facts:
|
||||
```
|
||||
memory_store(facts: ["Conference requires at least 4 breakout rooms", "Conference requires vegan options", "Conference requires parking"], category: "project")
|
||||
```
|
||||
|
||||
**CORRECT** — grouped into one self-contained fact:
|
||||
```
|
||||
memory_store(facts: ["Conference requires at least 4 breakout rooms for 30-40 people each, robust vegan and vegetarian options with allergen-free alternatives, parking for at least 100 vehicles, venue within walking distance of transit"], category: "project")
|
||||
```
|
||||
|
||||
**WRONG** — same entity split into separate facts:
|
||||
```
|
||||
memory_store(facts: ["Budget is $150-175 per person for TechForward event", "TechForward event requires strong WiFi", "TechForward event requires hybrid capabilities"], category: "project")
|
||||
```
|
||||
|
||||
**CORRECT** — combined into one fact about TechForward:
|
||||
```
|
||||
memory_store(facts: ["TechForward event has a budget of $150-175 per person per day including venue rental, standard AV setup, and catering. Requires strong WiFi and hybrid event capabilities for remote attendees."], category: "project")
|
||||
```
|
||||
|
||||
**Only create separate memories when information refers to genuinely different entities, concepts, or unrelated topics** (e.g., "TechForward event" vs "Marketing campaign" are separate).
|
||||
|
||||
### No Pronouns — Use Specific Names
|
||||
|
||||
DO NOT create memories that rely on pronouns (they, them, he, she, it). Always use specific names and entities.
|
||||
|
||||
- **WRONG**: "They work at Google" and "They live in San Francisco"
|
||||
- **CORRECT**: "John works at Google and lives in San Francisco"
|
||||
|
||||
### No Inference
|
||||
|
||||
Do not infer unstated attributes (gender, age, ethnicity, beliefs) from names or context.
|
||||
- **WRONG**: "Kiran's sister visited him last week"
|
||||
- **CORRECT**: "Kiran's sister visited last week"
|
||||
|
||||
### No Assistant Attribution
|
||||
|
||||
Do not store characterizations from assistant messages (e.g., "user seems excited") unless the user explicitly confirmed them.
|
||||
|
||||
## How to Store
|
||||
|
||||
Use `memory_store` with the `facts` array. All facts in one call MUST share the same category because category determines retention policy (TTL, immutability).
|
||||
|
||||
```
|
||||
memory_store(
|
||||
facts: ["fact one in third person", "fact two in third person"],
|
||||
category: "identity"
|
||||
)
|
||||
```
|
||||
|
||||
If a turn produces facts in different categories, make one call per category:
|
||||
|
||||
```
|
||||
memory_store(facts: ["User is Alex, senior engineer at Stripe, PST timezone"], category: "identity")
|
||||
memory_store(facts: ["As of 2026-04-01, user decided to migrate from Postgres to CockroachDB"], category: "decision")
|
||||
```
|
||||
|
||||
Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `technical`, `relationship`, `project`
|
||||
|
||||
### Storage Principles
|
||||
|
||||
**15-50 WORDS per fact**: Each fact should be 1-2 sentences. If combining would exceed this, consolidate into key facts rather than creating a paragraph. Distill rather than append.
|
||||
|
||||
**OUTCOMES OVER INTENT**: Extract what WAS DONE, not what was requested.
|
||||
- GOOD: "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates"
|
||||
- BAD: "User wants to update call scripts"
|
||||
|
||||
**TEMPORAL ANCHORING**: Time-sensitive facts MUST include "As of YYYY-MM-DD, ..."
|
||||
- If no date available, note "date unknown" rather than omitting.
|
||||
- Extract dates from conversation context or the current date.
|
||||
|
||||
**PRESERVE USER'S WORDS**: When the user expresses feelings, opinions, or preferences, keep their exact phrasing.
|
||||
- GOOD: "User says daily walks with Poppy are the best part of their day"
|
||||
- BAD: "User finds emotional significance in walking their dog"
|
||||
|
||||
**THIRD PERSON**: "User prefers..." not "I prefer..."
|
||||
|
||||
**NO PRONOUNS**: Use specific names and entities. Not "they" or "it."
|
||||
|
||||
**PRESERVE LANGUAGE**: If the user speaks Spanish, store in Spanish. Do not translate.
|
||||
|
||||
**BATCH BY CATEGORY**: Group all same-category facts into one call. Different categories require separate calls. Most turns need zero or one call.
|
||||
|
||||
### Updating Existing Memories
|
||||
|
||||
When a recalled memory needs updating (fact changed, status changed, new detail added):
|
||||
1. `memory_search` to find the existing memory
|
||||
2. `memory_forget` on the old memory's ID
|
||||
3. `memory_store` with the corrected/expanded fact
|
||||
|
||||
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
|
||||
|
||||
**Material difference test**: Only update if the new version adds real information.
|
||||
- "User likes Python" → "User prefers Python for backend services because of async support" = material update (added rationale, specificity)
|
||||
- "User likes Python" → "User enjoys Python" = NOT material = SKIP
|
||||
- When both have unique context, combine: Old "Trip to Paris in September with Jack" + New "User can't wait to visit Eiffel Tower" → "Trip to Paris in September 2025 with friend Jack, user says they can't wait to visit the Eiffel Tower and try authentic French pastries"
|
||||
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, update one to the comprehensive version and forget the others.
|
||||
- Old: "User has a dog" + "Dog's name is Poppy" + "User walks dog daily"
|
||||
- New: "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- Action: forget all three old memories, store one consolidated memory
|
||||
|
||||
**Temporary vs permanent changes**: A temporary constraint (e.g., injury pausing a hobby) does NOT contradict the underlying preference. Store the constraint as a new memory; don't delete the preference.
|
||||
- Old: "User enjoys hiking on weekends"
|
||||
- New: "User has temporarily paused hiking due to knee injury"
|
||||
- Action: store the new constraint, leave old preference untouched
|
||||
|
||||
## What NEVER to Store
|
||||
|
||||
- **Credentials and secrets** — even embedded in config blocks, setup logs, or tool output. Includes sk-, m0-, ak_, ghp_, bot tokens, bearer tokens, webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env contexts. Record that the credential was configured, never the value itself.
|
||||
- **Raw tool output** — bash results, file contents, API responses, logs, diffs, test output. Extract only the durable OUTCOME or ROOT CAUSE.
|
||||
- **One-time commands** — "stop the script", "continue where you left off", "run this"
|
||||
- **Acknowledgments and emotional reactions** — "ok", "sure", "sounds good", "sir", "got it", "thanks", "you're right"
|
||||
- **Transient UI/navigation states** — "user is in admin panel", "relay is attached"
|
||||
- **Ephemeral process status** — "download at 50%", "daemon not running", "still syncing"
|
||||
- **Cron heartbeat outputs** — NO_REPLY, HEARTBEAT_OK, compaction directives
|
||||
- **Timestamps as standalone facts** — "Current time is 3:25 PM" is NEVER worth storing. But DO use timestamps to anchor other facts.
|
||||
- **System routing metadata** — message IDs, sender IDs, channel routing info
|
||||
- **Generic small talk** — no informational content
|
||||
- **Raw code snippets** — capture the intent/decision, not the code itself
|
||||
- **Information the user explicitly asks not to remember**
|
||||
- **Facts already in recalled memories that haven't materially changed**
|
||||
- **Generic assistant responses** — "Sure, I can help", "How can I assist you?"
|
||||
|
||||
## Worked Examples
|
||||
|
||||
### Example 1: Configuration extraction (entity-grouped)
|
||||
```
|
||||
User: "I set up the research agent on Claude Sonnet with a 30-min cron. It checks HackerNews and sends summaries to #research-feed in Slack."
|
||||
Agent: [responds helpfully]
|
||||
→ memory_store(facts: ["User's research agent runs on Claude Sonnet, cron every 30 minutes, monitors HackerNews and posts summaries to Slack #research-feed"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 2: NOOP — tool output
|
||||
```
|
||||
User: "Run the healthcheck on all services"
|
||||
Agent: [executes healthcheck, returns results]
|
||||
→ No memory operations. Tool output fails Gate 1.
|
||||
```
|
||||
|
||||
### Example 3: NOOP — already recalled, no material change
|
||||
```
|
||||
Recalled: ["User is Chris, senior platform engineer at Mem0"]
|
||||
User: "Hey Chris here again"
|
||||
→ No memory operations. Already known, no material change.
|
||||
```
|
||||
|
||||
### Example 4: Rule with rationale (preserving user's words)
|
||||
```
|
||||
User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac mini only has 256GB"
|
||||
→ memory_store(facts: ["User rule: avoid Docker for local dev. Reason: ate 40GB of disk on 256GB Mac mini"], category: "rule")
|
||||
```
|
||||
|
||||
### Example 5: UPDATE — combining contexts from both versions
|
||||
```
|
||||
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
|
||||
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
|
||||
→ memory_search("Paris trip planning")
|
||||
→ memory_forget("mem-id-of-old")
|
||||
→ memory_store(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
|
||||
```
|
||||
|
||||
### Example 6: Outcome over intent
|
||||
```
|
||||
User: "Update the call scripts sheet with the new truth-based templates"
|
||||
Agent: [updates the sheet successfully]
|
||||
→ memory_store(facts: ["Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates (as of 2026-03-30)"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 7: Credential — store the fact, not the value
|
||||
```
|
||||
User: "Use this API key for the new service: sk-proj-abc123def456"
|
||||
Agent: [configures the service]
|
||||
→ memory_store(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 8: NOOP — cosmetic difference, not material
|
||||
```
|
||||
Recalled: ["User has a dog named Poppy and enjoys their daily walks together"]
|
||||
User: "Yeah me and Poppy love our daily walks"
|
||||
→ No memory operations. Semantically equivalent. No new context.
|
||||
```
|
||||
|
||||
### Example 9: Entity grouping — single call, not fragmented
|
||||
```
|
||||
User: "The budget for the offsite is $200 per head. We need a venue with WiFi, parking for 50 cars, and a projector."
|
||||
→ memory_store(facts: ["Team offsite budget is $200 per person. Venue requirements: WiFi, parking for 50 vehicles, and projector setup."], category: "project")
|
||||
All details about the same entity (offsite) go in one fact, one call.
|
||||
```
|
||||
|
||||
### Example 10: Temporary constraint — don't delete the preference
|
||||
```
|
||||
Recalled: ["User enjoys hiking on weekends and finds it therapeutic"]
|
||||
User: "I hurt my knee last week, can't hike for a while"
|
||||
→ memory_store(facts: ["As of 2026-03-30, user has temporarily paused hiking due to knee injury"], category: "project")
|
||||
DO NOT delete the hiking preference. It is temporarily paused, not contradicted.
|
||||
```
|
||||
|
||||
### Example 11: Mixed categories in one turn — separate calls
|
||||
```
|
||||
User: "I'm Sarah, I work at Cloudflare. I just decided to switch our monitoring from Datadog to Grafana because of cost."
|
||||
→ memory_store(facts: ["User is Sarah, works at Cloudflare"], category: "identity")
|
||||
→ memory_store(facts: ["As of 2026-03-30, user decided to switch monitoring from Datadog to Grafana due to cost"], category: "decision")
|
||||
Two calls because identity and decision have different retention policies.
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting
|
||||
```
|
||||
User: "Hi"
|
||||
Agent: "Hello! How can I help?"
|
||||
→ No memory operations. No extractable facts.
|
||||
```
|
||||
|
||||
### Example 11: Consolidation — rich memory absorbs atomic ones
|
||||
```
|
||||
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
|
||||
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
|
||||
→ memory_search("dog Poppy walks") → find all three old memory IDs
|
||||
→ memory_forget(id-1), memory_forget(id-2), memory_forget(id-3)
|
||||
→ memory_store(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting, nothing to store
|
||||
```
|
||||
User: "Hi"
|
||||
Agent: "Hello! How can I help?"
|
||||
→ No memory operations. No extractable facts.
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user