Compare commits

..
51 changed files with 4265 additions and 2266 deletions
+100
View File
@@ -0,0 +1,100 @@
name: CLI Node CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
pull_request:
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: cli/node
run: pnpm run lint
- name: Type check
working-directory: cli/node
run: pnpm run typecheck
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Run tests
working-directory: cli/node
run: pnpm run test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Build
working-directory: cli/node
run: pnpm run build
- name: Verify dist output
run: |
test -f cli/node/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
+79
View File
@@ -0,0 +1,79 @@
name: CLI Python CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
pull_request:
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dev dependencies
working-directory: cli/python
run: pip install -e ".[dev]"
- name: Lint with ruff
working-directory: cli/python
run: ruff check .
- name: Check formatting
working-directory: cli/python
run: ruff format --check .
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
working-directory: cli/python
run: pip install -e ".[dev]"
- name: Run tests
working-directory: cli/python
run: pytest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Hatch
run: pip install hatch
- name: Build
working-directory: cli/python
run: hatch build --clean
- name: Verify dist output
run: |
ls cli/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
ls cli/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
+2 -2
View File
@@ -162,7 +162,7 @@ For detailed integration steps, see the [Quickstart](https://docs.mem0.ai/quicks
## 📚 Documentation & Support ## 📚 Documentation & Support
- Full docs: https://docs.mem0.ai - Full docs: https://docs.mem0.ai
- Community: [Discord](https://mem0.dev/DiG) · [Twitter](https://x.com/mem0ai) - Community: [Discord](https://mem0.dev/DiG) · [X (formerly Twitter)](https://x.com/mem0ai)
- Contact: founders@mem0.ai - Contact: founders@mem0.ai
## Citation ## Citation
@@ -180,4 +180,4 @@ We now have a paper you can cite:
## ⚖️ License ## ⚖️ 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.
+13
View File
@@ -153,8 +153,19 @@ Interactive setup wizard for mem0 CLI.
|-----------------|--------|----------|---------|------| |-----------------|--------|----------|---------|------|
| `--api-key` | string | No | - | API key (skip prompt). | | `--api-key` | string | No | - | API key (skip prompt). |
| `-u, --user-id` | string | No | - | Default user ID (skip prompt). | | `-u, --user-id` | string | No | - | Default user ID (skip prompt). |
| `--email` | string | No | - | Login via email verification code. |
| `--code` | string | No | - | Verification code (use with --email for non-interactive login). |
**Behavior:** **Behavior:**
*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 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. - 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. - Interactive mode: prints banner, prompts for API key (masked with `*`), prompts for default user ID (default: `mem0-cli`), validates connection, saves config.
@@ -164,6 +175,8 @@ Interactive setup wizard for mem0 CLI.
```bash ```bash
mem0 init mem0 init
mem0 init --api-key m0-xxx --user-id alice mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
``` ```
--- ---
+8 -2
View File
@@ -17,9 +17,15 @@ Both packages install a `mem0` binary with identical behavior.
## Quick start ## Quick start
```bash ```bash
# Authenticate and save config # Interactive setup wizard
mem0 init 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 # Add a memory
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
@@ -40,7 +46,7 @@ mem0 delete <memory-id>
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `mem0 init` | Interactive setup wizard — configures API key and default user ID | | `mem0 init` | Setup wizard — login via email or configure API key manually |
| `mem0 add` | Add a memory from text, JSON messages, a file, or stdin | | `mem0 add` | Add a memory from text, JSON messages, a file, or stdin |
| `mem0 search` | Search memories using natural language | | `mem0 search` | Search memories using natural language |
| `mem0 list` | List memories with optional filters and pagination | | `mem0 list` | List memories with optional filters and pagination |
+8 -3
View File
@@ -503,15 +503,20 @@
}, },
{ {
"name": "init", "name": "init",
"description": "Interactive setup wizard for mem0 CLI.", "description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
"usage": "mem0 init", "usage": "mem0 init [OPTIONS]",
"needsBackend": false, "needsBackend": false,
"needsConfig": false, "needsConfig": false,
"resolveIds": false, "resolveIds": false,
"resolveGraph": false, "resolveGraph": false,
"confirmDangerous": false, "confirmDangerous": false,
"arguments": [], "arguments": [],
"options": [] "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": "status", "name": "status",
+1 -1
View File
@@ -7,7 +7,7 @@
"mem0": "./dist/index.js" "mem0": "./dist/index.js"
}, },
"scripts": { "scripts": {
"build": "tsup src/index.ts --format esm --dts --clean", "build": "tsup",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
+80 -71
View File
@@ -6,110 +6,119 @@ import type { Mem0Config } from "../config.js";
import { PlatformBackend } from "./platform.js"; import { PlatformBackend } from "./platform.js";
export interface AddOptions { export interface AddOptions {
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
immutable?: boolean; immutable?: boolean;
infer?: boolean; infer?: boolean;
expires?: string; expires?: string;
categories?: string[]; categories?: string[];
enableGraph?: boolean; enableGraph?: boolean;
} }
export interface SearchOptions { export interface SearchOptions {
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
topK?: number; topK?: number;
threshold?: number; threshold?: number;
rerank?: boolean; rerank?: boolean;
keyword?: boolean; keyword?: boolean;
filters?: Record<string, unknown>; filters?: Record<string, unknown>;
fields?: string[]; fields?: string[];
enableGraph?: boolean; enableGraph?: boolean;
} }
export interface ListOptions { export interface ListOptions {
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
category?: string; category?: string;
after?: string; after?: string;
before?: string; before?: string;
enableGraph?: boolean; enableGraph?: boolean;
} }
export interface DeleteOptions { export interface DeleteOptions {
all?: boolean; all?: boolean;
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
} }
export interface EntityIds { export interface EntityIds {
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
} }
export interface Backend { export interface Backend {
add( add(
content?: string, content?: string,
messages?: Record<string, unknown>[], messages?: Record<string, unknown>[],
opts?: AddOptions, opts?: AddOptions,
): Promise<Record<string, unknown>>; ): 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( update(
memoryId: string, memoryId: string,
content?: string, content?: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
): Promise<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>[]>; entities(entityType: string): Promise<Record<string, unknown>[]>;
} }
export class AuthError extends Error { export class AuthError extends Error {
constructor(message = "Authentication failed. Your API key may be invalid or expired.") { constructor(
super(message); message = "Authentication failed. Your API key may be invalid or expired.",
this.name = "AuthError"; ) {
} super(message);
this.name = "AuthError";
}
} }
export class NotFoundError extends Error { export class NotFoundError extends Error {
constructor(path: string) { constructor(path: string) {
super(`Resource not found: ${path}`); super(`Resource not found: ${path}`);
this.name = "NotFoundError"; this.name = "NotFoundError";
} }
} }
export class APIError extends Error { export class APIError extends Error {
constructor(path: string, detail: string) { constructor(path: string, detail: string) {
super(`Bad request to ${path}: ${detail}`); super(`Bad request to ${path}: ${detail}`);
this.name = "APIError"; this.name = "APIError";
} }
} }
export function getBackend(config: Mem0Config): Backend { export function getBackend(config: Mem0Config): Backend {
return new PlatformBackend(config.platform); return new PlatformBackend(config.platform);
} }
+8 -1
View File
@@ -3,5 +3,12 @@
*/ */
export { getBackend } from "./base.js"; 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"; export { AuthError, NotFoundError, APIError } from "./base.js";
+301 -266
View File
@@ -4,300 +4,335 @@
import type { PlatformConfig } from "../config.js"; import type { PlatformConfig } from "../config.js";
import { import {
type AddOptions, APIError,
APIError, type AddOptions,
AuthError, AuthError,
type Backend, type Backend,
type DeleteOptions, type DeleteOptions,
type EntityIds, type EntityIds,
type ListOptions, type ListOptions,
NotFoundError, NotFoundError,
type SearchOptions, type SearchOptions,
} from "./base.js"; } from "./base.js";
export class PlatformBackend implements Backend { export class PlatformBackend implements Backend {
private baseUrl: string; private baseUrl: string;
private headers: Record<string, string>; private headers: Record<string, string>;
constructor(config: PlatformConfig) { constructor(config: PlatformConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, ""); this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.headers = { this.headers = {
Authorization: `Token ${config.apiKey}`, Authorization: `Token ${config.apiKey}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
} }
private async _request( private async _request(
method: string, method: string,
path: string, path: string,
opts?: { json?: unknown; params?: Record<string, string> }, opts?: { json?: unknown; params?: Record<string, string> },
): Promise<unknown> { ): Promise<unknown> {
let url = `${this.baseUrl}${path}`; let url = `${this.baseUrl}${path}`;
if (opts?.params) { if (opts?.params) {
const qs = new URLSearchParams(opts.params).toString(); const qs = new URLSearchParams(opts.params).toString();
url += `?${qs}`; url += `?${qs}`;
} }
const fetchOpts: RequestInit = { const fetchOpts: RequestInit = {
method, method,
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}; };
if (opts?.json) { if (opts?.json) {
fetchOpts.body = JSON.stringify(opts.json); fetchOpts.body = JSON.stringify(opts.json);
} }
const resp = await fetch(url, fetchOpts); const resp = await fetch(url, fetchOpts);
if (resp.status === 401) { if (resp.status === 401) {
throw new AuthError(); throw new AuthError();
} }
if (resp.status === 404) { if (resp.status === 404) {
throw new NotFoundError(path); throw new NotFoundError(path);
} }
if (resp.status === 400) { if (resp.status === 400) {
let detail: string; let detail: string;
try { try {
const body = await resp.json(); const body = (await resp.json()) as Record<string, unknown>;
detail = (body as Record<string, string>).detail ?? resp.statusText; detail =
} catch { ((body.detail ?? body.message ?? JSON.stringify(body)) as string) ??
detail = resp.statusText; resp.statusText;
} } catch {
throw new APIError(path, detail); detail = resp.statusText;
} }
if (!resp.ok) { throw new APIError(path, detail);
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); }
} if (!resp.ok) {
if (resp.status === 204) { let detail: string = resp.statusText;
return {}; try {
} const body = (await resp.json()) as Record<string, unknown>;
return resp.json(); 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();
}
async add( async add(
content?: string, content?: string,
messages?: Record<string, unknown>[], messages?: Record<string, unknown>[],
opts: AddOptions = {}, opts: AddOptions = {},
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
if (messages) { if (messages) {
payload.messages = messages; payload.messages = messages;
} else if (content) { } else if (content) {
payload.messages = [{ role: "user", content }]; payload.messages = [{ role: "user", content }];
} }
if (opts.userId) payload.user_id = opts.userId; if (opts.userId) payload.user_id = opts.userId;
if (opts.agentId) payload.agent_id = opts.agentId; if (opts.agentId) payload.agent_id = opts.agentId;
if (opts.appId) payload.app_id = opts.appId; if (opts.appId) payload.app_id = opts.appId;
if (opts.runId) payload.run_id = opts.runId; if (opts.runId) payload.run_id = opts.runId;
if (opts.metadata) payload.metadata = opts.metadata; if (opts.metadata) payload.metadata = opts.metadata;
if (opts.immutable) payload.immutable = true; if (opts.immutable) payload.immutable = true;
if (opts.infer === false) payload.infer = false; if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires; if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories; if (opts.categories) payload.categories = opts.categories;
if (opts.enableGraph) payload.enable_graph = true; if (opts.enableGraph) payload.enable_graph = true;
return (await this._request("POST", "/v1/memories/", { json: payload })) as Record< return (await this._request("POST", "/v1/memories/", {
string, json: payload,
unknown })) as Record<string, unknown>;
>; }
}
private _buildFilters(opts: { private _buildFilters(opts: {
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
extraFilters?: Record<string, unknown>; extraFilters?: Record<string, unknown>;
}): Record<string, unknown> | undefined { }): Record<string, unknown> | undefined {
// If caller passed a pre-built filter structure, use it directly // If caller passed a pre-built filter structure, use it directly
if (opts.extraFilters && ("AND" in opts.extraFilters || "OR" in opts.extraFilters)) { if (
return opts.extraFilters; opts.extraFilters &&
} ("AND" in opts.extraFilters || "OR" in opts.extraFilters)
) {
return opts.extraFilters;
}
const andConditions: Record<string, unknown>[] = []; const andConditions: Record<string, unknown>[] = [];
if (opts.userId) andConditions.push({ user_id: opts.userId }); if (opts.userId) andConditions.push({ user_id: opts.userId });
if (opts.agentId) andConditions.push({ agent_id: opts.agentId }); if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
if (opts.appId) andConditions.push({ app_id: opts.appId }); if (opts.appId) andConditions.push({ app_id: opts.appId });
if (opts.runId) andConditions.push({ run_id: opts.runId }); if (opts.runId) andConditions.push({ run_id: opts.runId });
if (opts.extraFilters) { if (opts.extraFilters) {
for (const [k, v] of Object.entries(opts.extraFilters)) { for (const [k, v] of Object.entries(opts.extraFilters)) {
andConditions.push({ [k]: v }); andConditions.push({ [k]: v });
} }
} }
if (andConditions.length === 1) return andConditions[0]; if (andConditions.length === 1) return andConditions[0];
if (andConditions.length > 1) return { AND: andConditions }; if (andConditions.length > 1) return { AND: andConditions };
return undefined; return undefined;
} }
async search(query: string, opts: SearchOptions = {}): Promise<Record<string, unknown>[]> { async search(
const payload: Record<string, unknown> = { query: string,
query, opts: SearchOptions = {},
top_k: opts.topK ?? 10, ): Promise<Record<string, unknown>[]> {
threshold: opts.threshold ?? 0.3, const payload: Record<string, unknown> = {
}; query,
top_k: opts.topK ?? 10,
threshold: opts.threshold ?? 0.3,
};
const apiFilters = this._buildFilters({ const apiFilters = this._buildFilters({
userId: opts.userId, userId: opts.userId,
agentId: opts.agentId, agentId: opts.agentId,
appId: opts.appId, appId: opts.appId,
runId: opts.runId, runId: opts.runId,
extraFilters: opts.filters, extraFilters: opts.filters,
}); });
if (apiFilters) payload.filters = apiFilters; if (apiFilters) payload.filters = apiFilters;
if (opts.rerank) payload.rerank = true; if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true; if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields; if (opts.fields) payload.fields = opts.fields;
if (opts.enableGraph) payload.enable_graph = true; if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/search/", { const result = (await this._request("POST", "/v2/memories/search/", {
json: payload, json: payload,
})) as unknown; })) as unknown;
if (Array.isArray(result)) return result; if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>; const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[]; return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
} }
async get(memoryId: string): Promise<Record<string, unknown>> { async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<string, unknown>; return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
} string,
unknown
>;
}
async listMemories(opts: ListOptions = {}): Promise<Record<string, unknown>[]> { async listMemories(
const payload: Record<string, unknown> = {}; opts: ListOptions = {},
const params: Record<string, string> = { ): Promise<Record<string, unknown>[]> {
page: String(opts.page ?? 1), const payload: Record<string, unknown> = {};
page_size: String(opts.pageSize ?? 100), const params: Record<string, string> = {
}; page: String(opts.page ?? 1),
page_size: String(opts.pageSize ?? 100),
};
const extra: Record<string, unknown> = {}; const extra: Record<string, unknown> = {};
if (opts.category) { if (opts.category) {
extra.categories = { contains: opts.category }; extra.categories = { contains: opts.category };
} }
if (opts.after) { if (opts.after) {
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), gte: opts.after }; extra.created_at = {
} ...(extra.created_at as Record<string, unknown> | undefined),
if (opts.before) { gte: opts.after,
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), lte: opts.before }; };
} }
if (opts.before) {
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
lte: opts.before,
};
}
const apiFilters = this._buildFilters({ const apiFilters = this._buildFilters({
userId: opts.userId, userId: opts.userId,
agentId: opts.agentId, agentId: opts.agentId,
appId: opts.appId, appId: opts.appId,
runId: opts.runId, runId: opts.runId,
extraFilters: Object.keys(extra).length > 0 ? extra : undefined, extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
}); });
if (apiFilters) payload.filters = apiFilters; if (apiFilters) payload.filters = apiFilters;
if (opts.enableGraph) payload.enable_graph = true; if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/", { json: payload, params })) as unknown; const result = (await this._request("POST", "/v2/memories/", {
if (Array.isArray(result)) return result; json: payload,
const obj = result as Record<string, unknown>; params,
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[]; })) 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( async update(
memoryId: string, memoryId: string,
content?: string, content?: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
if (content) payload.text = content; if (content) payload.text = content;
if (metadata) payload.metadata = metadata; if (metadata) payload.metadata = metadata;
return (await this._request("PUT", `/v1/memories/${memoryId}/`, { return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
json: payload, json: payload,
})) as Record<string, unknown>; })) as Record<string, unknown>;
} }
async delete( async delete(
memoryId?: string, memoryId?: string,
opts: DeleteOptions = {}, opts: DeleteOptions = {},
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
if (opts.all) { if (opts.all) {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (opts.userId) params.user_id = opts.userId; if (opts.userId) params.user_id = opts.userId;
if (opts.agentId) params.agent_id = opts.agentId; if (opts.agentId) params.agent_id = opts.agentId;
if (opts.appId) params.app_id = opts.appId; if (opts.appId) params.app_id = opts.appId;
if (opts.runId) params.run_id = opts.runId; if (opts.runId) params.run_id = opts.runId;
return (await this._request("DELETE", "/v1/memories/", { params })) as Record< return (await this._request("DELETE", "/v1/memories/", {
string, params,
unknown })) as Record<string, unknown>;
>; }
} if (memoryId) {
if (memoryId) { return (await this._request(
return (await this._request("DELETE", `/v1/memories/${memoryId}/`)) as Record< "DELETE",
string, `/v1/memories/${memoryId}/`,
unknown )) as Record<string, unknown>;
>; }
} throw new Error("Either memoryId or --all is required");
throw new Error("Either memoryId or --all is required"); }
}
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> { async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (opts.userId) params.user_id = opts.userId; if (opts.userId) params.user_id = opts.userId;
if (opts.agentId) params.agent_id = opts.agentId; if (opts.agentId) params.agent_id = opts.agentId;
if (opts.appId) params.app_id = opts.appId; if (opts.appId) params.app_id = opts.appId;
if (opts.runId) params.run_id = opts.runId; if (opts.runId) params.run_id = opts.runId;
if (Object.keys(params).length === 0) { if (Object.keys(params).length === 0) {
throw new Error("At least one entity ID is required for deleteEntities."); throw new Error("At least one entity ID is required for deleteEntities.");
} }
return (await this._request("DELETE", "/v1/entities/", { params })) as Record< return (await this._request("DELETE", "/v1/entities/", {
string, params,
unknown })) as Record<string, unknown>;
>; }
}
async status( async status(
opts: { userId?: string; agentId?: string } = {}, opts: { userId?: string; agentId?: string } = {},
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
if (opts.userId || opts.agentId) { if (opts.userId || opts.agentId) {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
const statusParams: Record<string, string> = { page: "1", page_size: "1" }; const statusParams: Record<string, string> = {
const apiFilters = this._buildFilters({ page: "1",
userId: opts.userId, page_size: "1",
agentId: opts.agentId, };
}); const apiFilters = this._buildFilters({
if (apiFilters) payload.filters = apiFilters; userId: opts.userId,
await this._request("POST", "/v2/memories/", { json: payload, params: statusParams }); agentId: opts.agentId,
} else { });
await this._request("GET", "/v1/entities/"); if (apiFilters) payload.filters = apiFilters;
} await this._request("POST", "/v2/memories/", {
return { connected: true, backend: "platform", base_url: this.baseUrl }; json: payload,
} catch (e) { params: statusParams,
return { });
connected: false, } else {
backend: "platform", await this._request("GET", "/v1/entities/");
error: e instanceof Error ? e.message : String(e), }
}; 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>[]> { async entities(entityType: string): Promise<Record<string, unknown>[]> {
const result = (await this._request("GET", "/v1/entities/")) as unknown; const result = (await this._request("GET", "/v1/entities/")) as unknown;
let items: Record<string, unknown>[]; let items: Record<string, unknown>[];
if (Array.isArray(result)) { if (Array.isArray(result)) {
items = result; items = result;
} else { } else {
items = ((result as Record<string, unknown>).results ?? []) as Record<string, unknown>[]; items = ((result as Record<string, unknown>).results ?? []) as Record<
} string,
unknown
>[];
}
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
users: "user", users: "user",
agents: "agent", agents: "agent",
apps: "app", apps: "app",
runs: "run", runs: "run",
}; };
const targetType = typeMap[entityType]; const targetType = typeMap[entityType];
if (targetType) { if (targetType) {
items = items.filter( items = items.filter(
(e) => (e.type as string | undefined)?.toLowerCase() === targetType, (e) => (e.type as string | undefined)?.toLowerCase() === targetType,
); );
} }
return items; return items;
} }
} }
+73 -69
View File
@@ -4,10 +4,7 @@
import chalk from "chalk"; import chalk from "chalk";
import ora, { type Ora } from "ora"; import ora, { type Ora } from "ora";
import { createRequire } from "node:module"; import { CLI_VERSION } from "./version.js";
const _require = createRequire(import.meta.url);
const PKG_VERSION: string = _require("../package.json").version;
export const LOGO = ` export const LOGO = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗ ███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
@@ -40,75 +37,78 @@ const dim = chalk.hex(DIM_COLOR);
* plain-text for piped/non-TTY or NO_COLOR environments. * plain-text for piped/non-TTY or NO_COLOR environments.
*/ */
export function sym(fancy: string, plain: string): string { export function sym(fancy: string, plain: string): string {
if (!process.stdout.isTTY || process.env.NO_COLOR) return plain; if (!process.stdout.isTTY || process.env.NO_COLOR) return plain;
return fancy; return fancy;
} }
export function printBanner(): void { export function printBanner(): void {
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2)) const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
const logoLines = LOGO.trimEnd().split("\n"); const logoLines = LOGO.trimEnd().split("\n");
const tagline = ` ${TAGLINE}`; const tagline = ` ${TAGLINE}`;
const subtitle = `Node.js SDK · v${PKG_VERSION}`; const subtitle = `Node.js SDK · v${CLI_VERSION}`;
const contentLines = ["", ...logoLines, "", tagline, ""]; const contentLines = ["", ...logoLines, "", tagline, ""];
// Compute inner width from longest content line + padding both sides // Compute inner width from longest content line + padding both sides
const maxContent = Math.max(...contentLines.map((l) => l.length)); const maxContent = Math.max(...contentLines.map((l) => l.length));
const innerWidth = maxContent + pad * 2; const innerWidth = maxContent + pad * 2;
const totalWidth = innerWidth + 2; // + 2 for │ borders const totalWidth = innerWidth + 2; // + 2 for │ borders
const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`); const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`);
const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after
const bottomBorder = brand(`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`); const bottomBorder = brand(
`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`,
);
const body = contentLines.map((line) => { const body = contentLines.map((line) => {
const rightPad = innerWidth - pad - line.length; const rightPad = innerWidth - pad - line.length;
return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`; return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`;
}); });
// Re-color tagline line with accent instead of brand.bold // Re-color tagline line with accent instead of brand.bold
const taglineIdx = body.length - 2; // second-to-last (before trailing empty line) const taglineIdx = body.length - 2; // second-to-last (before trailing empty line)
const taglineRightPad = innerWidth - pad - tagline.length; const taglineRightPad = innerWidth - pad - tagline.length;
body[taglineIdx] = `${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`; body[taglineIdx] =
`${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
console.log(topBorder); console.log(topBorder);
for (const line of body) console.log(line); for (const line of body) console.log(line);
console.log(bottomBorder); console.log(bottomBorder);
} }
export function printSuccess(message: string): void { export function printSuccess(message: string): void {
console.log(`${success(sym("✓", "[ok]"))} ${message}`); console.log(`${success(sym("✓", "[ok]"))} ${message}`);
} }
export function printError(message: string, hint?: string): void { export function printError(message: string, hint?: string): void {
console.error(`${error(sym("✗", "[error]") + " Error:")} ${message}`); console.error(`${error(`${sym("✗", "[error]")} Error:`)} ${message}`);
if (hint) { if (hint) {
console.error(` ${dim(hint)}`); console.error(` ${dim(hint)}`);
} }
} }
export function printWarning(message: string): void { export function printWarning(message: string): void {
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`); console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
} }
export function printInfo(message: string): void { export function printInfo(message: string): void {
console.log(`${brand(sym("◆", "*"))} ${message}`); console.error(`${brand(sym("◆", "*"))} ${message}`);
} }
export function printScope(ids: Record<string, string | undefined>): void { export function printScope(ids: Record<string, string | undefined>): void {
const parts: string[] = []; const parts: string[] = [];
for (const [key, val] of Object.entries(ids)) { for (const [key, val] of Object.entries(ids)) {
if (val) { if (val) {
const label = key.replace(/_/g, " ").replace("id", "ID").trim(); const label = key.replace(/_/g, " ").replace("id", "ID").trim();
parts.push(`${label}=${val}`); parts.push(`${label}=${val}`);
} }
} }
if (parts.length > 0) { if (parts.length > 0) {
console.log(` ${dim(`Scope: ${parts.join(", ")}`)}`); console.error(` ${dim(`Scope: ${parts.join(", ")}`)}`);
} }
} }
export interface TimedStatusContext { export interface TimedStatusContext {
successMsg: string; successMsg: string;
errorMsg: string; errorMsg: string;
} }
/** /**
@@ -116,29 +116,33 @@ export interface TimedStatusContext {
* Equivalent to Python's timed_status context manager. * Equivalent to Python's timed_status context manager.
*/ */
export async function timedStatus<T>( export async function timedStatus<T>(
message: string, message: string,
fn: (ctx: TimedStatusContext) => Promise<T>, fn: (ctx: TimedStatusContext) => Promise<T>,
): Promise<T> { ): Promise<T> {
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" }; const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
const spinner = ora({ text: dim(message), color: "magenta", stream: process.stderr }).start(); const spinner = ora({
const start = performance.now(); text: dim(message),
color: "magenta",
stream: process.stderr,
}).start();
const start = performance.now();
try { try {
const result = await fn(ctx); const result = await fn(ctx);
const elapsed = ((performance.now() - start) / 1000).toFixed(2); const elapsed = ((performance.now() - start) / 1000).toFixed(2);
spinner.stop(); spinner.stop();
if (ctx.successMsg) { if (ctx.successMsg) {
console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`); console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`);
} }
return result; return result;
} catch (err) { } catch (err) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2); const elapsed = ((performance.now() - start) / 1000).toFixed(2);
spinner.stop(); spinner.stop();
if (ctx.errorMsg) { if (ctx.errorMsg) {
console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`); console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`);
} }
throw err; throw err;
} }
} }
/** Format helpers using brand colors for external use. */ /** Format helpers using brand colors for external use. */
+67 -64
View File
@@ -3,88 +3,91 @@
*/ */
import Table from "cli-table3"; import Table from "cli-table3";
import { printError, printSuccess, colors } from "../branding.js"; import { colors, printError, printSuccess } from "../branding.js";
import { import {
getNestedValue, getNestedValue,
loadConfig, loadConfig,
redactKey, redactKey,
saveConfig, saveConfig,
setNestedValue, setNestedValue,
} from "../config.js"; } from "../config.js";
import { formatJsonEnvelope } from "../output.js"; import { formatJsonEnvelope } from "../output.js";
const { brand, accent, dim } = colors; const { brand, accent, dim } = colors;
export function cmdConfigShow(opts: { output?: string } = {}): void { export function cmdConfigShow(opts: { output?: string } = {}): void {
const config = loadConfig(); const config = loadConfig();
if (opts.output === "json") { if (opts.output === "json") {
formatJsonEnvelope({ formatJsonEnvelope({
command: "config show", command: "config show",
data: { data: {
defaults: { defaults: {
user_id: config.defaults.userId || null, user_id: config.defaults.userId || null,
agent_id: config.defaults.agentId || null, agent_id: config.defaults.agentId || null,
app_id: config.defaults.appId || null, app_id: config.defaults.appId || null,
run_id: config.defaults.runId || null, run_id: config.defaults.runId || null,
enable_graph: config.defaults.enableGraph, enable_graph: config.defaults.enableGraph,
}, },
platform: { platform: {
api_key: redactKey(config.platform.apiKey), api_key: redactKey(config.platform.apiKey),
base_url: config.platform.baseUrl, base_url: config.platform.baseUrl,
}, },
}, },
}); });
return; return;
} }
console.log(); console.log();
console.log(` ${brand("◆ mem0 Configuration")}\n`); console.log(` ${brand("◆ mem0 Configuration")}\n`);
const table = new Table({ const table = new Table({
head: [accent("Key"), accent("Value")], head: [accent("Key"), accent("Value")],
style: { head: [], border: [] }, style: { head: [], border: [] },
}); });
// Defaults // Defaults
table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]); table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]);
table.push(["defaults.agent_id", config.defaults.agentId || dim("(not set)")]); table.push([
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]); "defaults.agent_id",
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]); config.defaults.agentId || dim("(not set)"),
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]); ]);
table.push(["", ""]); 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 // Platform
table.push(["platform.api_key", redactKey(config.platform.apiKey)]); table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
table.push(["platform.base_url", config.platform.baseUrl]); table.push(["platform.base_url", config.platform.baseUrl]);
console.log(table.toString()); console.log(table.toString());
console.log(); console.log();
} }
export function cmdConfigGet(key: string): void { export function cmdConfigGet(key: string): void {
const config = loadConfig(); const config = loadConfig();
const value = getNestedValue(config, key); const value = getNestedValue(config, key);
if (value === undefined) { if (value === undefined) {
printError(`Unknown config key: ${key}`); printError(`Unknown config key: ${key}`);
} else { } else {
// Redact secrets // Redact secrets
if (key.includes("api_key") || key.split(".").pop() === "key") { if (key.includes("api_key") || key.split(".").pop() === "key") {
console.log(redactKey(String(value))); console.log(redactKey(String(value)));
} else { } else {
console.log(String(value)); console.log(String(value));
} }
} }
} }
export function cmdConfigSet(key: string, value: string): void { export function cmdConfigSet(key: string, value: string): void {
const config = loadConfig(); const config = loadConfig();
if (setNestedValue(config, key, value)) { if (setNestedValue(config, key, value)) {
saveConfig(config); saveConfig(config);
const display = key.includes("key") ? redactKey(value) : value; const display = key.includes("key") ? redactKey(value) : value;
printSuccess(`${key} = ${display}`); printSuccess(`${key} = ${display}`);
} else { } else {
printError(`Unknown config key: ${key}`); printError(`Unknown config key: ${key}`);
} }
} }
+125 -108
View File
@@ -4,8 +4,14 @@
import readline from "node:readline"; import readline from "node:readline";
import Table from "cli-table3"; import Table from "cli-table3";
import { printError, printInfo, printSuccess, timedStatus, colors } from "../branding.js";
import type { Backend } from "../backend/base.js"; import type { Backend } from "../backend/base.js";
import {
colors,
printError,
printInfo,
printSuccess,
timedStatus,
} from "../branding.js";
import { formatJson } from "../output.js"; import { formatJson } from "../output.js";
const { brand, accent, dim } = colors; const { brand, accent, dim } = colors;
@@ -13,127 +19,138 @@ const { brand, accent, dim } = colors;
const VALID_TYPES = new Set(["users", "agents", "apps", "runs"]); const VALID_TYPES = new Set(["users", "agents", "apps", "runs"]);
export async function cmdEntitiesList( export async function cmdEntitiesList(
backend: Backend, backend: Backend,
entityType: string, entityType: string,
opts: { output: string }, opts: { output: string },
): Promise<void> { ): Promise<void> {
if (!VALID_TYPES.has(entityType)) { if (!VALID_TYPES.has(entityType)) {
printError(`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`); printError(
process.exit(1); `Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`,
} );
process.exit(1);
}
const start = performance.now(); const start = performance.now();
let results: Record<string, unknown>[]; let results: Record<string, unknown>[];
try { try {
results = await timedStatus(`Fetching ${entityType}...`, async () => { results = await timedStatus(`Fetching ${entityType}...`, async () => {
return backend.entities(entityType); return backend.entities(entityType);
}); });
} catch (e) { } catch (e) {
printError( printError(
e instanceof Error ? e.message : String(e), e instanceof Error ? e.message : String(e),
"This feature may require the mem0 Platform.", "This feature may require the mem0 Platform.",
); );
process.exit(1); process.exit(1);
} }
const elapsed = (performance.now() - start) / 1000; const elapsed = (performance.now() - start) / 1000;
if (opts.output === "json") { if (opts.output === "json") {
formatJson(results); formatJson(results);
return; return;
} }
if (!results.length) { if (!results.length) {
printInfo(`No ${entityType} found.`); printInfo(`No ${entityType} found.`);
return; return;
} }
const table = new Table({ const table = new Table({
head: [accent("Name / ID"), accent("Created")], head: [accent("Name / ID"), accent("Created")],
style: { head: [], border: [] }, style: { head: [], border: [] },
}); });
for (const entity of results) { for (const entity of results) {
const name = String(entity.name ?? entity.id ?? "—"); const name = String(entity.name ?? entity.id ?? "—");
const created = String(entity.created_at ?? "—").slice(0, 10); const created = String(entity.created_at ?? "—").slice(0, 10);
table.push([name, created]); table.push([name, created]);
} }
console.log(); console.log();
console.log(table.toString()); console.log(table.toString());
console.log(` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`); console.log(
console.log(); ` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`,
);
console.log();
} }
export async function cmdEntitiesDelete( export async function cmdEntitiesDelete(
backend: Backend, backend: Backend,
opts: { opts: {
userId?: string; userId?: string;
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
dryRun?: boolean; dryRun?: boolean;
force: boolean; force: boolean;
output: string; output: string;
}, },
): Promise<void> { ): Promise<void> {
if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) { if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) {
printError("Provide at least one of --user-id, --agent-id, --app-id, --run-id."); printError(
process.exit(1); "Provide at least one of --user-id, --agent-id, --app-id, --run-id.",
} );
process.exit(1);
}
if (opts.dryRun) { if (opts.dryRun) {
const scopeParts: string[] = []; const scopeParts: string[] = [];
if (opts.userId) scopeParts.push(`user=${opts.userId}`); if (opts.userId) scopeParts.push(`user=${opts.userId}`);
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
if (opts.appId) scopeParts.push(`app=${opts.appId}`); if (opts.appId) scopeParts.push(`app=${opts.appId}`);
if (opts.runId) scopeParts.push(`run=${opts.runId}`); if (opts.runId) scopeParts.push(`run=${opts.runId}`);
printInfo(`Would delete entity ${scopeParts.join(", ")} and all its memories.`); printInfo(
printInfo("No changes made."); `Would delete entity ${scopeParts.join(", ")} and all its memories.`,
return; );
} printInfo("No changes made.");
return;
}
if (!opts.force) { if (!opts.force) {
const scopeParts: string[] = []; const scopeParts: string[] = [];
if (opts.userId) scopeParts.push(`user=${opts.userId}`); if (opts.userId) scopeParts.push(`user=${opts.userId}`);
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
if (opts.appId) scopeParts.push(`app=${opts.appId}`); if (opts.appId) scopeParts.push(`app=${opts.appId}`);
if (opts.runId) scopeParts.push(`run=${opts.runId}`); if (opts.runId) scopeParts.push(`run=${opts.runId}`);
const scope = scopeParts.join(", "); const scope = scopeParts.join(", ");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({
const answer = await new Promise<string>((resolve) => { input: process.stdin,
rl.question( output: process.stdout,
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `, });
resolve, const answer = await new Promise<string>((resolve) => {
); rl.question(
}); `\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
rl.close(); resolve,
if (answer.toLowerCase() !== "y") { );
printInfo("Cancelled."); });
process.exit(0); rl.close();
} if (answer.toLowerCase() !== "y") {
} printInfo("Cancelled.");
process.exit(0);
}
}
const start = performance.now(); const start = performance.now();
let result: Record<string, unknown>; let result: Record<string, unknown>;
try { try {
result = await timedStatus("Deleting entity...", async () => { result = await timedStatus("Deleting entity...", async () => {
return backend.deleteEntities({ return backend.deleteEntities({
userId: opts.userId, userId: opts.userId,
agentId: opts.agentId, agentId: opts.agentId,
appId: opts.appId, appId: opts.appId,
runId: opts.runId, runId: opts.runId,
}); });
}); });
} catch (e) { } catch (e) {
printError(e instanceof Error ? e.message : String(e)); printError(e instanceof Error ? e.message : String(e));
process.exit(1); process.exit(1);
} }
const elapsed = (performance.now() - start) / 1000; const elapsed = (performance.now() - start) / 1000;
if (opts.output === "json") { if (opts.output === "json") {
formatJson(result); formatJson(result);
} else if (opts.output !== "quiet") { } else if (opts.output !== "quiet") {
printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`); printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`);
} }
} }
+344 -143
View File
@@ -3,180 +3,381 @@
*/ */
import readline from "node:readline"; import readline from "node:readline";
import {
printBanner,
printError,
printInfo,
printSuccess,
colors,
} from "../branding.js";
import { type Mem0Config, createDefaultConfig, saveConfig } from "../config.js";
import { PlatformBackend } from "../backend/platform.js"; import { PlatformBackend } from "../backend/platform.js";
import {
colors,
printBanner,
printError,
printInfo,
printSuccess,
} from "../branding.js";
import {
DEFAULT_BASE_URL,
type Mem0Config,
createDefaultConfig,
loadConfig,
saveConfig,
} from "../config.js";
const { brand, dim } = colors; 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> { function promptSecret(label: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
process.stdout.write(label); process.stdout.write(label);
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
process.stdin.setRawMode(true); process.stdin.setRawMode(true);
} }
process.stdin.resume(); process.stdin.resume();
process.stdin.setEncoding("utf-8"); process.stdin.setEncoding("utf-8");
const chars: string[] = []; const chars: string[] = [];
const onData = (key: string) => { const onData = (key: string) => {
for (const ch of key) { for (const ch of key) {
if (ch === "\r" || ch === "\n") { if (ch === "\r" || ch === "\n") {
cleanup(); cleanup();
process.stdout.write("\n"); process.stdout.write("\n");
resolve(chars.join("")); resolve(chars.join(""));
return; return;
} }
if (ch === "\x03") { if (ch === "\x03") {
cleanup(); cleanup();
reject(new Error("Interrupted")); reject(new Error("Interrupted"));
return; return;
} }
if (ch === "\x7f" || ch === "\x08") { if (ch === "\x7f" || ch === "\x08") {
// backspace // backspace
if (chars.length > 0) { if (chars.length > 0) {
chars.pop(); chars.pop();
process.stdout.write("\b \b"); process.stdout.write("\b \b");
} }
} else if (ch === "\x15") { } else if (ch === "\x15") {
// Ctrl+U — clear line // Ctrl+U — clear line
process.stdout.write("\b \b".repeat(chars.length)); process.stdout.write("\b \b".repeat(chars.length));
chars.length = 0; chars.length = 0;
} else if (ch >= " ") { } else if (ch >= " ") {
chars.push(ch); chars.push(ch);
process.stdout.write("*"); process.stdout.write("*");
} }
} }
}; };
const cleanup = () => { const cleanup = () => {
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
process.stdin.setRawMode(false); process.stdin.setRawMode(false);
} }
process.stdin.pause(); process.stdin.pause();
}; };
process.stdin.on("data", onData); process.stdin.on("data", onData);
}); });
} }
function promptLine(label: string, defaultValue?: string): Promise<string> { function promptLine(label: string, defaultValue?: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `; input: process.stdin,
return new Promise((resolve) => { output: process.stdout,
rl.question(prompt, (answer) => { });
rl.close(); const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
resolve(answer.trim() || defaultValue || ""); return new Promise((resolve) => {
}); rl.question(prompt, (answer) => {
}); rl.close();
resolve(answer.trim() || defaultValue || "");
});
});
} }
async function setupPlatform(config: Mem0Config): Promise<void> { async function setupPlatform(config: Mem0Config): Promise<void> {
console.log(); console.log();
console.log(` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`); 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")}: `); process.stdout.write(` ${brand("API Key")}: `);
const apiKey = await promptSecret(""); const apiKey = await promptSecret("");
if (!apiKey) { if (!apiKey) {
printError("API key is required."); printError("API key is required.");
process.exit(1); process.exit(1);
} }
config.platform.apiKey = apiKey; config.platform.apiKey = apiKey;
} }
async function setupDefaults(config: Mem0Config): Promise<void> { async function setupDefaults(config: Mem0Config): Promise<void> {
console.log(); console.log();
printInfo("Set default entity IDs (press Enter to skip).\n"); printInfo("Set default entity IDs (press Enter to skip).\n");
const userId = await promptLine(` ${brand("Default User ID")} ${dim("(recommended)")}`, "mem0-cli"); const userId = await promptLine(
if (userId) config.defaults.userId = userId; ` ${brand("Default User ID")} ${dim("(recommended)")}`,
"mem0-cli",
);
if (userId) config.defaults.userId = userId;
} }
async function validatePlatform(config: Mem0Config): Promise<void> { async function validatePlatform(config: Mem0Config): Promise<void> {
console.log(); console.log();
printInfo("Validating connection..."); printInfo("Validating connection...");
try { try {
const backend = new PlatformBackend(config.platform); const backend = new PlatformBackend(config.platform);
const status = await backend.status({ const status = await backend.status({
userId: config.defaults.userId || undefined, userId: config.defaults.userId || undefined,
agentId: config.defaults.agentId || undefined, agentId: config.defaults.agentId || undefined,
}); });
if (status.connected) { if (status.connected) {
printSuccess("Connected to mem0 Platform!"); printSuccess("Connected to mem0 Platform!");
} else { } else {
printError( printError(
`Could not connect: ${status.error ?? "Unknown error"}`, `Could not connect: ${status.error ?? "Unknown error"}`,
"Check your API key and try again.", "Check your API key and try again.",
); );
} }
} catch (e) { } catch (e) {
printError(`Connection test failed: ${e instanceof Error ? e.message : e}`); printError(`Connection test failed: ${e instanceof Error ? e.message : e}`);
} }
} }
export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise<void> { export async function runInit(
const config = createDefaultConfig(); opts: {
apiKey?: string;
userId?: string;
email?: string;
code?: string;
} = {},
): Promise<void> {
const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env.MEM0_BASE_URL ||
savedConfig.platform.baseUrl ||
DEFAULT_BASE_URL;
// Non-interactive: both flags provided // Guards
if (opts.apiKey && opts.userId) { if (opts.code && !opts.email) {
config.platform.apiKey = opts.apiKey; printError("--code requires --email.");
config.defaults.userId = opts.userId; process.exit(1);
await validatePlatform(config); }
saveConfig(config); if (opts.email && opts.apiKey) {
printSuccess("Configuration saved to ~/.mem0/config.json"); printError("Cannot use both --api-key and --email.");
return; process.exit(1);
} }
// Non-TTY without full flags: error with usage hint // ── Email login flow ──────────────────────────────────────────────────────
if (!process.stdin.isTTY && (!opts.apiKey || !opts.userId)) { if (opts.email) {
printError( const email = opts.email.trim().toLowerCase();
"Non-interactive terminal detected and missing required flags.", validateEmail(email);
"Usage: mem0 init --api-key <key> --user-id <id>",
);
process.exit(1);
}
printBanner(); printBanner();
console.log(); console.log();
printInfo("Welcome! Let's set up your mem0 CLI.\n"); printInfo(`Logging in as ${email}...\n`);
// Use provided API key or prompt const result = await emailLogin(email, opts.code, baseUrl);
if (opts.apiKey) {
config.platform.apiKey = opts.apiKey;
} else {
await setupPlatform(config);
}
// Use provided user ID or prompt const apiKeyVal = result.api_key as string | undefined;
if (opts.userId) { if (!apiKeyVal) {
config.defaults.userId = opts.userId; printError(
} else { "Auth succeeded but no API key was returned. Contact support.",
await setupDefaults(config); );
} process.exit(1);
}
await validatePlatform(config); config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.defaults.userId = opts.userId || "mem0-cli";
saveConfig(config); saveConfig(config);
console.log(); console.log();
printSuccess("Configuration saved to ~/.mem0/config.json"); printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
console.log(); console.log();
console.log(` ${dim("Get started:")}`); console.log(` ${dim("Get started:")}`);
if (config.defaults.userId) { console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`); console.log(` ${dim(' mem0 search "preferences"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`); console.log();
} else { return;
console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`); }
console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`);
} // ── API key flow ──────────────────────────────────────────────────────────
console.log();
// 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;
}
// Non-TTY without full flags: error with usage hint
if (!process.stdin.isTTY && (!opts.apiKey || !opts.userId)) {
printError(
"Non-interactive terminal detected and missing required flags.",
"Usage: mem0 init --api-key <key> --user-id <id>",
);
process.exit(1);
}
printBanner();
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 || "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();
} }
File diff suppressed because it is too large Load Diff
+115 -109
View File
@@ -3,137 +3,143 @@
*/ */
import fs from "node:fs"; import fs from "node:fs";
import { createRequire } from "node:module";
import { printError, printSuccess, timedStatus, colors } from "../branding.js";
import type { Backend } from "../backend/base.js";
import { formatJsonEnvelope } from "../output.js";
import boxen from "boxen"; import boxen from "boxen";
import type { Backend } from "../backend/base.js";
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
import { formatJsonEnvelope } from "../output.js";
import { CLI_VERSION } from "../version.js";
const { brand, dim, success, error: errorColor } = colors; const { brand, dim, success, error: errorColor } = colors;
const _require = createRequire(import.meta.url);
const VERSION: string = _require("../../package.json").version;
export async function cmdStatus( export async function cmdStatus(
backend: Backend, backend: Backend,
opts: { userId?: string; agentId?: string; output?: string } = {}, opts: { userId?: string; agentId?: string; output?: string } = {},
): Promise<void> { ): Promise<void> {
const start = performance.now(); const start = performance.now();
let result: Record<string, unknown>; let result: Record<string, unknown>;
try { try {
result = await timedStatus("Checking connection...", async () => { result = await timedStatus("Checking connection...", async () => {
return backend.status({ userId: opts.userId, agentId: opts.agentId }); return backend.status({ userId: opts.userId, agentId: opts.agentId });
}); });
} catch (e) { } catch (e) {
result = { connected: false, error: e instanceof Error ? e.message : String(e) }; result = {
} connected: false,
const elapsed = (performance.now() - start) / 1000; error: e instanceof Error ? e.message : String(e),
};
}
const elapsed = (performance.now() - start) / 1000;
if (opts.output === "json") { if (opts.output === "json") {
formatJsonEnvelope({ formatJsonEnvelope({
command: "status", command: "status",
data: { data: {
connected: result.connected, connected: result.connected,
backend: result.backend ?? null, backend: result.backend ?? null,
base_url: result.base_url ?? null, base_url: result.base_url ?? null,
latency_ms: Math.round(elapsed * 1000), latency_ms: Math.round(elapsed * 1000),
}, },
durationMs: Math.round(elapsed * 1000), durationMs: Math.round(elapsed * 1000),
}); });
return; return;
} }
const lines: string[] = []; const lines: string[] = [];
if (result.connected) { if (result.connected) {
lines.push(` ${success("\u25cf")} Connected`); lines.push(` ${success("\u25cf")} Connected`);
} else { } else {
lines.push(` ${errorColor("\u25cf")} Disconnected`); lines.push(` ${errorColor("\u25cf")} Disconnected`);
} }
lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`); lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`);
if (result.base_url) { if (result.base_url) {
lines.push(` ${dim("API URL:")} ${result.base_url}`); lines.push(` ${dim("API URL:")} ${result.base_url}`);
} }
if (result.error) { if (result.error) {
lines.push(` ${errorColor("Error:")} ${result.error}`); lines.push(` ${errorColor("Error:")} ${result.error}`);
} }
lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`); lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`);
const content = lines.join("\n"); const content = lines.join("\n");
console.log(); console.log();
console.log( console.log(
boxen(content, { boxen(content, {
title: brand("Connection Status"), title: brand("Connection Status"),
titleAlignment: "left", titleAlignment: "left",
borderColor: "magenta", borderColor: "magenta",
padding: 1, padding: 1,
}), }),
); );
console.log(); console.log();
} }
export function cmdVersion(): void { export function cmdVersion(): void {
console.log(` ${brand("◆ Mem0")} CLI v${VERSION}`); console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
} }
export async function cmdImport( export async function cmdImport(
backend: Backend, backend: Backend,
filePath: string, filePath: string,
opts: { userId?: string; agentId?: string; output?: string }, opts: { userId?: string; agentId?: string; output?: string },
): Promise<void> { ): Promise<void> {
let data: Record<string, unknown>[]; let data: Record<string, unknown>[];
try { try {
const raw = fs.readFileSync(filePath, "utf-8"); const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
data = Array.isArray(parsed) ? parsed : [parsed]; data = Array.isArray(parsed) ? parsed : [parsed];
} catch (e) { } catch (e) {
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`); printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
process.exit(1); process.exit(1);
} }
let added = 0; let added = 0;
let failed = 0; let failed = 0;
const start = performance.now(); const start = performance.now();
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const item = data[i]; const item = data[i];
const content = (item.memory ?? item.text ?? item.content ?? "") as string; const content = (item.memory ?? item.text ?? item.content ?? "") as string;
if (!content) { if (!content) {
failed++; failed++;
continue; continue;
} }
try { try {
await backend.add(content, undefined, { await backend.add(content, undefined, {
userId: opts.userId ?? (item.user_id as string | undefined), userId: opts.userId ?? (item.user_id as string | undefined),
agentId: opts.agentId ?? (item.agent_id as string | undefined), agentId: opts.agentId ?? (item.agent_id as string | undefined),
metadata: item.metadata as Record<string, unknown> | undefined, metadata: item.metadata as Record<string, unknown> | undefined,
}); });
added++; added++;
} catch { } catch {
failed++; failed++;
} }
// Simple progress indicator // Simple progress indicator
if ((i + 1) % 10 === 0 || i === data.length - 1) { if ((i + 1) % 10 === 0 || i === data.length - 1) {
process.stdout.write(`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`); process.stdout.write(
} `\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`,
} );
}
}
const elapsed = (performance.now() - start) / 1000; const elapsed = (performance.now() - start) / 1000;
console.log(); // Clear progress line console.log(); // Clear progress line
if (opts.output === "json") { if (opts.output === "json") {
formatJsonEnvelope({ formatJsonEnvelope({
command: "import", command: "import",
data: { added, failed, duration_s: parseFloat(elapsed.toFixed(2)) }, data: {
durationMs: Math.round(elapsed * 1000), added,
}); failed,
return; duration_s: Number.parseFloat(elapsed.toFixed(2)),
} },
durationMs: Math.round(elapsed * 1000),
});
return;
}
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`); printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
if (failed > 0) { if (failed > 0) {
printError(`${failed} memories failed to import.`); printError(`${failed} memories failed to import.`);
} }
} }
+115 -99
View File
@@ -18,142 +18,158 @@ export const DEFAULT_BASE_URL = "https://api.mem0.ai";
export const CONFIG_VERSION = 1; export const CONFIG_VERSION = 1;
export interface PlatformConfig { export interface PlatformConfig {
apiKey: string; apiKey: string;
baseUrl: string; baseUrl: string;
} }
export interface DefaultsConfig { export interface DefaultsConfig {
userId: string; userId: string;
agentId: string; agentId: string;
appId: string; appId: string;
runId: string; runId: string;
enableGraph: boolean; enableGraph: boolean;
} }
export interface Mem0Config { export interface Mem0Config {
version: number; version: number;
defaults: DefaultsConfig; defaults: DefaultsConfig;
platform: PlatformConfig; platform: PlatformConfig;
} }
export function createDefaultConfig(): Mem0Config { export function createDefaultConfig(): Mem0Config {
return { return {
version: CONFIG_VERSION, version: CONFIG_VERSION,
defaults: { defaults: {
userId: "", userId: "",
agentId: "", agentId: "",
appId: "", appId: "",
runId: "", runId: "",
enableGraph: false, enableGraph: false,
}, },
platform: { platform: {
apiKey: "", apiKey: "",
baseUrl: DEFAULT_BASE_URL, baseUrl: DEFAULT_BASE_URL,
}, },
}; };
} }
export function ensureConfigDir(): string { export function ensureConfigDir(): string {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
return CONFIG_DIR; return CONFIG_DIR;
} }
export function loadConfig(): Mem0Config { export function loadConfig(): Mem0Config {
const config = createDefaultConfig(); const config = createDefaultConfig();
if (fs.existsSync(CONFIG_FILE)) { if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
const data = JSON.parse(raw); const data = JSON.parse(raw);
config.version = data.version ?? CONFIG_VERSION; config.version = data.version ?? CONFIG_VERSION;
const plat = data.platform ?? {}; const plat = data.platform ?? {};
config.platform.apiKey = plat.api_key ?? ""; config.platform.apiKey = plat.api_key ?? "";
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL; config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
const defaults = data.defaults ?? {}; const defaults = data.defaults ?? {};
config.defaults.userId = defaults.user_id ?? ""; config.defaults.userId = defaults.user_id ?? "";
config.defaults.agentId = defaults.agent_id ?? ""; config.defaults.agentId = defaults.agent_id ?? "";
config.defaults.appId = defaults.app_id ?? ""; config.defaults.appId = defaults.app_id ?? "";
config.defaults.runId = defaults.run_id ?? ""; config.defaults.runId = defaults.run_id ?? "";
config.defaults.enableGraph = defaults.enable_graph ?? false; config.defaults.enableGraph = defaults.enable_graph ?? false;
} }
// Environment variable overrides // Environment variable overrides
if (process.env.MEM0_API_KEY) config.platform.apiKey = process.env.MEM0_API_KEY; if (process.env.MEM0_API_KEY)
if (process.env.MEM0_BASE_URL) config.platform.baseUrl = process.env.MEM0_BASE_URL; config.platform.apiKey = process.env.MEM0_API_KEY;
if (process.env.MEM0_USER_ID) config.defaults.userId = process.env.MEM0_USER_ID; if (process.env.MEM0_BASE_URL)
if (process.env.MEM0_AGENT_ID) config.defaults.agentId = process.env.MEM0_AGENT_ID; config.platform.baseUrl = process.env.MEM0_BASE_URL;
if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID; if (process.env.MEM0_USER_ID)
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID; config.defaults.userId = process.env.MEM0_USER_ID;
if (process.env.MEM0_ENABLE_GRAPH) { if (process.env.MEM0_AGENT_ID)
config.defaults.enableGraph = ["true", "1", "yes"].includes( config.defaults.agentId = process.env.MEM0_AGENT_ID;
process.env.MEM0_ENABLE_GRAPH.toLowerCase(), 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 { export function saveConfig(config: Mem0Config): void {
ensureConfigDir(); ensureConfigDir();
const data = { const data = {
version: config.version, version: config.version,
defaults: { defaults: {
user_id: config.defaults.userId, user_id: config.defaults.userId,
agent_id: config.defaults.agentId, agent_id: config.defaults.agentId,
app_id: config.defaults.appId, app_id: config.defaults.appId,
run_id: config.defaults.runId, run_id: config.defaults.runId,
enable_graph: config.defaults.enableGraph, enable_graph: config.defaults.enableGraph,
}, },
platform: { platform: {
api_key: config.platform.apiKey, api_key: config.platform.apiKey,
base_url: config.platform.baseUrl, base_url: config.platform.baseUrl,
}, },
}; };
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.chmodSync(CONFIG_FILE, 0o600); fs.chmodSync(CONFIG_FILE, 0o600);
} }
export function redactKey(key: string): string { export function redactKey(key: string): string {
if (!key) return "(not set)"; if (!key) return "(not set)";
if (key.length <= 8) return key.slice(0, 2) + "***"; if (key.length <= 8) return `${key.slice(0, 2)}***`;
return key.slice(0, 4) + "..." + key.slice(-4); return `${key.slice(0, 4)}...${key.slice(-4)}`;
} }
/** Key map from dotted config path to the config object fields. */ /** Key map from dotted config path to the config object fields. */
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = { const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
"platform.api_key": ["platform", "apiKey"], "platform.api_key": ["platform", "apiKey"],
"platform.base_url": ["platform", "baseUrl"], "platform.base_url": ["platform", "baseUrl"],
"defaults.user_id": ["defaults", "userId"], "defaults.user_id": ["defaults", "userId"],
"defaults.agent_id": ["defaults", "agentId"], "defaults.agent_id": ["defaults", "agentId"],
"defaults.app_id": ["defaults", "appId"], "defaults.app_id": ["defaults", "appId"],
"defaults.run_id": ["defaults", "runId"], "defaults.run_id": ["defaults", "runId"],
"defaults.enable_graph": ["defaults", "enableGraph"], "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"],
}; };
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown { export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
const mapping = KEY_MAP[dottedKey]; const mapping = KEY_MAP[dottedKey];
if (!mapping) return undefined; if (!mapping) return undefined;
const [section, field] = mapping; const [section, field] = mapping;
return (config[section] as unknown as Record<string, unknown>)[field]; return (config[section] as unknown as Record<string, unknown>)[field];
} }
export function setNestedValue(config: Mem0Config, dottedKey: string, value: string): boolean { export function setNestedValue(
const mapping = KEY_MAP[dottedKey]; config: Mem0Config,
if (!mapping) return false; dottedKey: string,
const [section, field] = mapping; value: string,
const obj = config[section] as unknown as Record<string, unknown>; ): 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]; const current = obj[field];
if (typeof current === "boolean") { if (typeof current === "boolean") {
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase()); obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
} else if (typeof current === "number") { } else if (typeof current === "number") {
obj[field] = parseInt(value, 10); obj[field] = Number.parseInt(value, 10);
} else { } else {
obj[field] = value; obj[field] = value;
} }
return true; return true;
} }
+2
View File
@@ -0,0 +1,2 @@
/** Injected by tsup at build time from package.json version field. Undefined in dev/test. */
declare const __CLI_VERSION__: string | undefined;
+284 -280
View File
@@ -4,40 +4,40 @@
*/ */
import chalk from "chalk"; import chalk from "chalk";
import type { Command, Help, Option, Argument } from "commander"; import type { Argument, Command, Help, Option } from "commander";
// Colors imported from chalk directly to match Typer/Rich defaults // Colors imported from chalk directly to match Typer/Rich defaults
// ── Colors (matching Typer/Rich defaults) ──────────────────────────────── // ── Colors (matching Typer/Rich defaults) ────────────────────────────────
const cyanBold = chalk.cyan.bold; // option flags, command names const cyanBold = chalk.cyan.bold; // option flags, command names
const greenBold = chalk.green.bold; // switch flags (boolean --force etc) const greenBold = chalk.green.bold; // switch flags (boolean --force etc)
const yellowBold = chalk.yellow.bold; // metavar <value> const yellowBold = chalk.yellow.bold; // metavar <value>
const yellow = chalk.yellow; // "Usage:" label const yellow = chalk.yellow; // "Usage:" label
const bold = chalk.bold; // command name in usage const bold = chalk.bold; // command name in usage
const dim = chalk.dim; // defaults, descriptions const dim = chalk.dim; // defaults, descriptions
const dimBorder = chalk.dim; // panel borders const dimBorder = chalk.dim; // panel borders
// ── Strip ANSI ─────────────────────────────────────────────────────────── // ── Strip ANSI ───────────────────────────────────────────────────────────
// eslint-disable-next-line no-control-regex // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence is intentional
const ANSI_RE = /\x1b\[[0-9;]*m/g; const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(str: string): number { function stripAnsi(str: string): number {
return str.replace(ANSI_RE, "").length; return str.replace(ANSI_RE, "").length;
} }
// ── Command display order (matches Python CLI) ────────────────────────── // ── Command display order (matches Python CLI) ──────────────────────────
/** Commands grouped into panels, matching Python CLI's rich_help_panel. */ /** Commands grouped into panels, matching Python CLI's rich_help_panel. */
const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [ const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [
{ {
panel: "Memory", panel: "Memory",
commands: ["add", "search", "get", "list", "update", "delete"], commands: ["add", "search", "get", "list", "update", "delete"],
}, },
{ {
panel: "Management", panel: "Management",
commands: ["init", "status", "import", "help", "entity", "config"], commands: ["init", "status", "import", "help", "entity", "config"],
}, },
]; ];
/** Flat order derived from COMMAND_GROUPS. */ /** 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) ───── // ── Option-to-panel mapping (derived from Python's rich_help_panel) ─────
const OPTION_PANELS: Record<string, Record<string, string>> = { const OPTION_PANELS: Record<string, Record<string, string>> = {
add: { add: {
"--user-id": "Scope", "--user-id": "Scope",
"--agent-id": "Scope", "--agent-id": "Scope",
"--app-id": "Scope", "--app-id": "Scope",
"--run-id": "Scope", "--run-id": "Scope",
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
search: { search: {
"--user-id": "Scope", "--user-id": "Scope",
"--agent-id": "Scope", "--agent-id": "Scope",
"--app-id": "Scope", "--app-id": "Scope",
"--run-id": "Scope", "--run-id": "Scope",
"--top-k": "Search", "--top-k": "Search",
"--threshold": "Search", "--threshold": "Search",
"--rerank": "Search", "--rerank": "Search",
"--keyword": "Search", "--keyword": "Search",
"--filter": "Search", "--filter": "Search",
"--fields": "Search", "--fields": "Search",
"--graph": "Search", "--graph": "Search",
"--no-graph": "Search", "--no-graph": "Search",
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
get: { get: {
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
list: { list: {
"--user-id": "Scope", "--user-id": "Scope",
"--agent-id": "Scope", "--agent-id": "Scope",
"--app-id": "Scope", "--app-id": "Scope",
"--run-id": "Scope", "--run-id": "Scope",
"--page": "Pagination", "--page": "Pagination",
"--page-size": "Pagination", "--page-size": "Pagination",
"--category": "Filters", "--category": "Filters",
"--after": "Filters", "--after": "Filters",
"--before": "Filters", "--before": "Filters",
"--graph": "Filters", "--graph": "Filters",
"--no-graph": "Filters", "--no-graph": "Filters",
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
update: { update: {
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
delete: { delete: {
"--user-id": "Scope", "--user-id": "Scope",
"--agent-id": "Scope", "--agent-id": "Scope",
"--app-id": "Scope", "--app-id": "Scope",
"--run-id": "Scope", "--run-id": "Scope",
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
status: { status: {
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
import: { import: {
"--user-id": "Scope", "--user-id": "Scope",
"--agent-id": "Scope", "--agent-id": "Scope",
"--output": "Output", "--output": "Output",
"--api-key": "Connection", "--api-key": "Connection",
"--base-url": "Connection", "--base-url": "Connection",
}, },
}; };
const PANEL_ORDER: string[] = [ const PANEL_ORDER: string[] = [
"Scope", "Scope",
"Search", "Search",
"Pagination", "Pagination",
"Filters", "Filters",
"Output", "Output",
"Connection", "Connection",
]; ];
// ── Panel rendering ───────────────────────────────────────────────────── // ── Panel rendering ─────────────────────────────────────────────────────
@@ -141,234 +141,238 @@ const PANEL_ORDER: string[] = [
* ╰────────────────────────────────╯ * ╰────────────────────────────────╯
* ``` * ```
*/ */
function renderPanel( function renderPanel(title: string, rows: string[], width: number): string {
title: string, if (rows.length === 0) return "";
rows: string[],
width: number,
): string {
if (rows.length === 0) return "";
// Inner width is total width minus the two border chars // Inner width is total width minus the two border chars
const inner = width - 2; const inner = width - 2;
// Top border: ╭─ Title ─...─╮ // Top border: ╭─ Title ─...─╮
const titleStr = ` ${title} `; const titleStr = ` ${title} `;
const fillLen = Math.max(0, inner - 1 - titleStr.length); const fillLen = Math.max(0, inner - 1 - titleStr.length);
const topLine = const topLine =
dimBorder("╭─") + dimBorder("╭─") +
dimBorder(titleStr) + dimBorder(titleStr) +
dimBorder("─".repeat(fillLen)) + dimBorder("─".repeat(fillLen)) +
dimBorder("╮"); dimBorder("╮");
// Bottom border: ╰─...─╯ // Bottom border: ╰─...─╯
const bottomLine = dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯"); const bottomLine =
dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
// Content rows // Content rows
const contentLines = rows.map((row) => { const contentLines = rows.map((row) => {
const visLen = stripAnsi(row); const visLen = stripAnsi(row);
const pad = Math.max(0, inner - 1 - visLen); const pad = Math.max(0, inner - 1 - visLen);
return dimBorder("│") + " " + row + " ".repeat(pad) + dimBorder("│"); return `${dimBorder("│")} ${row}${" ".repeat(pad)}${dimBorder("│")}`;
}); });
return [topLine, ...contentLines, bottomLine].join("\n"); return [topLine, ...contentLines, bottomLine].join("\n");
} }
// ── Format an option term (short + long) ──────────────────────────────── // ── Format an option term (short + long) ────────────────────────────────
function formatOptionTerm(opt: Option): string { function formatOptionTerm(opt: Option): string {
const parts: string[] = []; const parts: string[] = [];
if (opt.short) parts.push(opt.short); if (opt.short) parts.push(opt.short);
if (opt.long) parts.push(opt.long); if (opt.long) parts.push(opt.long);
let term = parts.join(", "); let term = parts.join(", ");
// Append value placeholder for non-boolean options // Append value placeholder for non-boolean options
if (opt.flags) { if (opt.flags) {
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/); const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
if (match) { if (match) {
term += " " + match[0]; term += ` ${match[0]}`;
} }
} }
return term; return term;
} }
// ── Get the long flag name for panel lookup ───────────────────────────── // ── Get the long flag name for panel lookup ─────────────────────────────
function getLongFlag(opt: Option): string { function getLongFlag(opt: Option): string {
if (opt.long) return opt.long; if (opt.long) return opt.long;
return opt.short || ""; return opt.short || "";
} }
// ── Format a default value ────────────────────────────────────────────── // ── Format a default value ──────────────────────────────────────────────
function formatDefault(opt: Option): string { function formatDefault(opt: Option): string {
if (opt.defaultValue !== undefined && opt.defaultValue !== false) { if (opt.defaultValue !== undefined && opt.defaultValue !== false) {
return dim(` [default: ${opt.defaultValue}]`); return dim(` [default: ${opt.defaultValue}]`);
} }
return ""; return "";
} }
// ── The main help formatter ───────────────────────────────────────────── // ── The main help formatter ─────────────────────────────────────────────
export function richFormatHelp(cmd: Command, helper: Help): string { export function richFormatHelp(cmd: Command, helper: Help): string {
const width = process.stdout.columns || 80; const width = process.stdout.columns || 80;
const lines: string[] = []; const lines: string[] = [];
const isRoot = !cmd.parent; const isRoot = !cmd.parent;
// ── Usage line ── // ── Usage line ──
const usage = helper.commandUsage(cmd); const usage = helper.commandUsage(cmd);
lines.push(""); lines.push("");
if (isRoot) { if (isRoot) {
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold // Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
lines.push(` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`); lines.push(
} else { ` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`,
// Subcommands: split into command path (bold) and args (yellow) );
const usageParts = usage.split(" "); } else {
const cmdPath: string[] = []; // Subcommands: split into command path (bold) and args (yellow)
const argParts: string[] = []; const usageParts = usage.split(" ");
let pastCmd = false; const cmdPath: string[] = [];
for (const part of usageParts) { const argParts: string[] = [];
if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) { let pastCmd = false;
cmdPath.push(part); for (const part of usageParts) {
} else { if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) {
pastCmd = true; cmdPath.push(part);
argParts.push(part); } else {
} pastCmd = true;
} argParts.push(part);
lines.push(` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`); }
} }
lines.push(""); lines.push(
` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`,
);
}
lines.push("");
// ── Description ── // ── Description ──
const desc = helper.commandDescription(cmd); const desc = helper.commandDescription(cmd);
if (desc) { if (desc) {
// Split multi-line descriptions (e.g., title + tagline) // Split multi-line descriptions (e.g., title + tagline)
const descLines = desc.split("\n"); const descLines = desc.split("\n");
for (let i = 0; i < descLines.length; i++) { for (let i = 0; i < descLines.length; i++) {
const dLine = descLines[i]; const dLine = descLines[i];
// First line is the title, subsequent non-empty lines are tagline (dimmed) // First line is the title, subsequent non-empty lines are tagline (dimmed)
if (i === 0 || dLine.trim() === "") { if (i === 0 || dLine.trim() === "") {
lines.push(` ${dLine}`); lines.push(` ${dLine}`);
} else { } else {
lines.push(` ${dim(dLine)}`); lines.push(` ${dim(dLine)}`);
} }
} }
lines.push(""); lines.push("");
} }
// ── Arguments panel (subcommands only) ── // ── Arguments panel (subcommands only) ──
if (!isRoot) { if (!isRoot) {
const visibleArgs = helper.visibleArguments(cmd); const visibleArgs = helper.visibleArguments(cmd);
if (visibleArgs.length > 0) { if (visibleArgs.length > 0) {
const maxLen = Math.max(...visibleArgs.map((a: Argument) => a.name().length)); const maxLen = Math.max(
const argRows = visibleArgs.map((a: Argument) => { ...visibleArgs.map((a: Argument) => a.name().length),
const name = cyanBold(a.name().padEnd(maxLen)); );
const description = helper.argumentDescription(a); const argRows = visibleArgs.map((a: Argument) => {
return ` ${name} ${description}`; const name = cyanBold(a.name().padEnd(maxLen));
}); const description = helper.argumentDescription(a);
const panel = renderPanel("Arguments", argRows, width); return ` ${name} ${description}`;
if (panel) lines.push(panel); });
} const panel = renderPanel("Arguments", argRows, width);
} if (panel) lines.push(panel);
}
}
// ── Collect options (grouped into panels for subcommands) ── // ── Collect options (grouped into panels for subcommands) ──
const visibleOpts = helper.visibleOptions(cmd); const visibleOpts = helper.visibleOptions(cmd);
const cmdName = cmd.name(); const cmdName = cmd.name();
const panelMap = (!isRoot && OPTION_PANELS[cmdName]) ? OPTION_PANELS[cmdName] : {}; const panelMap =
!isRoot && OPTION_PANELS[cmdName] ? OPTION_PANELS[cmdName] : {};
const grouped: Record<string, Option[]> = { Options: [] }; const grouped: Record<string, Option[]> = { Options: [] };
for (const panelName of PANEL_ORDER) { for (const panelName of PANEL_ORDER) {
grouped[panelName] = []; grouped[panelName] = [];
} }
for (const opt of visibleOpts) { for (const opt of visibleOpts) {
const flag = getLongFlag(opt); const flag = getLongFlag(opt);
const panel = panelMap[flag]; const panel = panelMap[flag];
if (panel && PANEL_ORDER.includes(panel)) { if (panel && PANEL_ORDER.includes(panel)) {
grouped[panel].push(opt); grouped[panel].push(opt);
} else { } else {
grouped["Options"].push(opt); grouped.Options.push(opt);
} }
} }
// ── Collect commands ── // ── Collect commands ──
const visibleCmds = helper.visibleCommands(cmd); const visibleCmds = helper.visibleCommands(cmd);
if (isRoot) { if (isRoot) {
// ROOT: Options first, then command groups (matches Python/Typer ordering) // ROOT: Options first, then command groups (matches Python/Typer ordering)
if (grouped["Options"].length > 0) { if (grouped.Options.length > 0) {
const optRows = formatOptionRows(grouped["Options"]); const optRows = formatOptionRows(grouped.Options);
const panel = renderPanel("Options", optRows, width); const panel = renderPanel("Options", optRows, width);
if (panel) lines.push(panel); if (panel) lines.push(panel);
} }
if (visibleCmds.length > 0) { if (visibleCmds.length > 0) {
const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c])); const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c]));
for (const group of COMMAND_GROUPS) { for (const group of COMMAND_GROUPS) {
const groupCmds = group.commands const groupCmds = group.commands
.map((name) => cmdMap.get(name)) .map((name) => cmdMap.get(name))
.filter((c): c is Command => c !== undefined); .filter((c): c is Command => c !== undefined);
if (groupCmds.length === 0) continue; if (groupCmds.length === 0) continue;
const maxLen = Math.max(...groupCmds.map((c) => c.name().length)); const maxLen = Math.max(...groupCmds.map((c) => c.name().length));
const cmdRows = groupCmds.map((c) => { const cmdRows = groupCmds.map((c) => {
const name = cyanBold(c.name().padEnd(maxLen)); const name = cyanBold(c.name().padEnd(maxLen));
const description = helper.subcommandDescription(c); const description = helper.subcommandDescription(c);
return ` ${name} ${description}`; return ` ${name} ${description}`;
}); });
const panel = renderPanel(group.panel, cmdRows, width); const panel = renderPanel(group.panel, cmdRows, width);
if (panel) lines.push(panel); if (panel) lines.push(panel);
} }
} }
} else { } else {
// SUBCOMMANDS: Options/panels first, then sub-subcommands // SUBCOMMANDS: Options/panels first, then sub-subcommands
const panelSequence = ["Options", ...PANEL_ORDER]; const panelSequence = ["Options", ...PANEL_ORDER];
for (const panelName of panelSequence) { for (const panelName of panelSequence) {
const opts = grouped[panelName]; const opts = grouped[panelName];
if (opts && opts.length > 0) { if (opts && opts.length > 0) {
const optRows = formatOptionRows(opts); const optRows = formatOptionRows(opts);
const panel = renderPanel(panelName, optRows, width); const panel = renderPanel(panelName, optRows, width);
if (panel) lines.push(panel); if (panel) lines.push(panel);
} }
} }
// Sub-subcommands (e.g., config show/get/set, entity list/delete) // Sub-subcommands (e.g., config show/get/set, entity list/delete)
if (visibleCmds.length > 0) { if (visibleCmds.length > 0) {
const maxLen = Math.max(...visibleCmds.map((c) => c.name().length)); const maxLen = Math.max(...visibleCmds.map((c) => c.name().length));
const cmdRows = visibleCmds.map((c) => { const cmdRows = visibleCmds.map((c) => {
const name = cyanBold(c.name().padEnd(maxLen)); const name = cyanBold(c.name().padEnd(maxLen));
const description = helper.subcommandDescription(c); const description = helper.subcommandDescription(c);
return ` ${name} ${description}`; return ` ${name} ${description}`;
}); });
const panel = renderPanel("Commands", cmdRows, width); const panel = renderPanel("Commands", cmdRows, width);
if (panel) lines.push(panel); if (panel) lines.push(panel);
} }
} }
lines.push(""); lines.push("");
return lines.join("\n"); return lines.join("\n");
} }
// ── Format option rows with aligned columns ───────────────────────────── // ── Format option rows with aligned columns ─────────────────────────────
function formatOptionRows(opts: Option[]): string[] { function formatOptionRows(opts: Option[]): string[] {
const terms = opts.map((o) => formatOptionTerm(o)); const terms = opts.map((o) => formatOptionTerm(o));
const maxTermLen = Math.max(...terms.map((t) => t.length)); const maxTermLen = Math.max(...terms.map((t) => t.length));
return opts.map((opt, i) => { return opts.map((opt, i) => {
const term = cyanBold(terms[i].padEnd(maxTermLen)); const term = cyanBold(terms[i].padEnd(maxTermLen));
const desc = opt.description || ""; const desc = opt.description || "";
const def = formatDefault(opt); const def = formatDefault(opt);
return ` ${term} ${desc}${def}`; return ` ${term} ${desc}${def}`;
}); });
} }
// ── Sort commands by COMMAND_ORDER ────────────────────────────────────── // ── Sort commands by COMMAND_ORDER ──────────────────────────────────────
function sortCommands(cmds: Command[]): Command[] { function sortCommands(cmds: Command[]): Command[] {
return [...cmds].sort((a, b) => { return [...cmds].sort((a, b) => {
const ai = COMMAND_ORDER.indexOf(a.name()); const ai = COMMAND_ORDER.indexOf(a.name());
const bi = COMMAND_ORDER.indexOf(b.name()); const bi = COMMAND_ORDER.indexOf(b.name());
// Unknown commands go to end, preserving original order // Unknown commands go to end, preserving original order
const aIdx = ai === -1 ? COMMAND_ORDER.length : ai; const aIdx = ai === -1 ? COMMAND_ORDER.length : ai;
const bIdx = bi === -1 ? COMMAND_ORDER.length : bi; const bIdx = bi === -1 ? COMMAND_ORDER.length : bi;
return aIdx - bIdx; return aIdx - bIdx;
}); });
} }
+519 -389
View File
File diff suppressed because it is too large Load Diff
+207 -177
View File
@@ -2,229 +2,259 @@
* Output formatting for mem0 CLI — text, JSON, table, quiet modes. * Output formatting for mem0 CLI — text, JSON, table, quiet modes.
*/ */
import Table from "cli-table3";
import boxen from "boxen"; import boxen from "boxen";
import Table from "cli-table3";
import { colors, sym } from "./branding.js"; import { colors, sym } from "./branding.js";
const { brand, accent, success, error: errorColor, dim } = colors; const { brand, accent, success, error: errorColor, dim } = colors;
function formatDate(dtStr?: string): string | undefined { function formatDate(dtStr?: string): string | undefined {
if (!dtStr) return undefined; if (!dtStr) return undefined;
try { try {
const dt = new Date(dtStr.replace("Z", "+00:00")); const dt = new Date(dtStr.replace("Z", "+00:00"));
return dt.toISOString().slice(0, 10); return dt.toISOString().slice(0, 10);
} catch { } catch {
return dtStr?.slice(0, 10); return dtStr?.slice(0, 10);
} }
} }
export function formatMemoriesText( export function formatMemoriesText(
memories: Record<string, unknown>[], memories: Record<string, unknown>[],
title = "memories", title = "memories",
): void { ): void {
const count = memories.length; const count = memories.length;
console.log(`\n${brand(`Found ${count} ${title}:`)}\n`); console.log(`\n${brand(`Found ${count} ${title}:`)}\n`);
for (let i = 0; i < memories.length; i++) { for (let i = 0; i < memories.length; i++) {
const mem = memories[i]; const mem = memories[i];
const memoryText = (mem.memory ?? mem.text ?? "") as string; const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = ((mem.id as string) ?? "").slice(0, 8); const memId = ((mem.id as string) ?? "").slice(0, 8);
const score = mem.score as number | undefined; const score = mem.score as number | undefined;
const created = formatDate(mem.created_at as string | undefined); const created = formatDate(mem.created_at as string | undefined);
let category: string | undefined; let category: string | undefined;
const cats = mem.categories; const cats = mem.categories;
if (Array.isArray(cats)) { if (Array.isArray(cats)) {
category = cats[0] as string | undefined; category = cats[0] as string | undefined;
} }
console.log(` ${i + 1}. ${memoryText}`); console.log(` ${i + 1}. ${memoryText}`);
const details: string[] = []; const details: string[] = [];
if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`); if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`);
if (memId) details.push(`ID: ${memId}`); if (memId) details.push(`ID: ${memId}`);
if (created) details.push(`Created: ${created}`); if (created) details.push(`Created: ${created}`);
if (category) details.push(`Category: ${category}`); if (category) details.push(`Category: ${category}`);
if (details.length > 0) { if (details.length > 0) {
console.log(` ${dim(details.join(" · "))}`); console.log(` ${dim(details.join(" · "))}`);
} }
console.log(); console.log();
} }
} }
export function formatMemoriesTable(memories: Record<string, unknown>[]): void { export function formatMemoriesTable(
const table = new Table({ memories: Record<string, unknown>[],
head: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")], opts: { showScore?: boolean } = {},
colWidths: [12, 52, 16, 14], ): void {
wordWrap: true, const head = opts.showScore
style: { head: [], border: [] }, ? [
}); accent("ID"),
accent("Score"),
accent("Memory"),
accent("Category"),
accent("Created"),
]
: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")];
const colWidths = opts.showScore ? [38, 8, 40, 16, 14] : [38, 40, 16, 14];
const table = new Table({
head,
colWidths,
wordWrap: true,
style: { head: [], border: [] },
});
for (const mem of memories) { for (const mem of memories) {
const memId = ((mem.id as string) ?? "").slice(0, 8); const memId = (mem.id as string) ?? "";
let memoryText = (mem.memory ?? mem.text ?? "") as string; let memoryText = (mem.memory ?? mem.text ?? "") as string;
if (memoryText.length > 60) { if (memoryText.length > 60) {
memoryText = memoryText.slice(0, 57) + "..."; memoryText = `${memoryText.slice(0, 57)}...`;
} }
const categories = mem.categories; const categories = mem.categories;
const cat = const cat =
Array.isArray(categories) && categories.length > 0 Array.isArray(categories) && categories.length > 0
? (categories[0] as string) ? categories.length > 1
: "—"; ? `${categories[0]} (+${categories.length - 1})`
const created = formatDate(mem.created_at as string | undefined) ?? "—"; : (categories[0] as string)
table.push([dim(memId), memoryText, cat, created]); : "—";
} const created = formatDate(mem.created_at as string | undefined) ?? "—";
if (opts.showScore) {
const score = mem.score as number | undefined;
const scoreStr = score !== undefined ? score.toFixed(2) : "—";
table.push([dim(memId), scoreStr, memoryText, cat, created]);
} else {
table.push([dim(memId), memoryText, cat, created]);
}
}
console.log(); console.log();
console.log(table.toString()); console.log(table.toString());
console.log(); console.log();
} }
export function formatJson(data: unknown): void { export function formatJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2)); console.log(JSON.stringify(data, null, 2));
} }
export function formatSingleMemory( export function formatSingleMemory(
mem: Record<string, unknown>, mem: Record<string, unknown>,
output = "text", output = "text",
): void { ): void {
if (output === "json") { if (output === "json") {
formatJson(mem); formatJson(mem);
return; return;
} }
const memoryText = (mem.memory ?? mem.text ?? "") as string; const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = (mem.id ?? "") as string; const memId = (mem.id ?? "") as string;
const lines: string[] = []; const lines: string[] = [];
lines.push(` ${memoryText}`); lines.push(` ${memoryText}`);
lines.push(""); lines.push("");
if (memId) lines.push(` ${dim("ID:")} ${memId}`); if (memId) lines.push(` ${dim("ID:")} ${memId}`);
const created = formatDate(mem.created_at as string | undefined); const created = formatDate(mem.created_at as string | undefined);
if (created) lines.push(` ${dim("Created:")} ${created}`); if (created) lines.push(` ${dim("Created:")} ${created}`);
const updated = formatDate(mem.updated_at as string | undefined); const updated = formatDate(mem.updated_at as string | undefined);
if (updated) lines.push(` ${dim("Updated:")} ${updated}`); if (updated) lines.push(` ${dim("Updated:")} ${updated}`);
const meta = mem.metadata; const meta = mem.metadata;
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`); if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
const categories = mem.categories; const categories = mem.categories;
if (categories) { if (categories) {
const catStr = Array.isArray(categories) ? categories.join(", ") : String(categories); const catStr = Array.isArray(categories)
lines.push(` ${dim("Categories:")} ${catStr}`); ? categories.join(", ")
} : String(categories);
lines.push(` ${dim("Categories:")} ${catStr}`);
}
const content = lines.join("\n"); const content = lines.join("\n");
console.log(); console.log();
console.log( console.log(
boxen(content, { boxen(content, {
title: brand("Memory"), title: brand("Memory"),
titleAlignment: "left", titleAlignment: "left",
borderColor: "magenta", borderColor: "magenta",
padding: 1, padding: 1,
}), }),
); );
console.log(); console.log();
} }
export function formatAddResult( export function formatAddResult(
result: Record<string, unknown> | Record<string, unknown>[], result: Record<string, unknown> | Record<string, unknown>[],
output = "text", output = "text",
): void { ): void {
if (output === "json") { if (output === "json") {
formatJson(result); formatJson(result);
return; return;
} }
if (output === "quiet") return; if (output === "quiet") return;
const results: Record<string, unknown>[] = Array.isArray(result) const results: Record<string, unknown>[] = Array.isArray(result)
? result ? result
: ((result.results as Record<string, unknown>[]) ?? [result]); : ((result.results as Record<string, unknown>[]) ?? [result]);
if (!results.length) { if (!results.length) {
console.log(` ${dim("No memories extracted.")}`); console.log(` ${dim("No memories extracted.")}`);
return; return;
} }
console.log(); console.log();
for (const r of results) { for (const r of results) {
// Detect async PENDING response // Detect async PENDING response
if (r.status === "PENDING") { if (r.status === "PENDING") {
const eventId = ((r.event_id as string) ?? "").slice(0, 8); const eventId = ((r.event_id as string) ?? "").slice(0, 8);
const icon = accent(sym("⧗", "...")); const icon = accent(sym("⧗", "..."));
const parts = [` ${icon} ${dim("Queued".padEnd(10))}`, "Processing in background"]; const parts = [
if (eventId) parts.push(dim(`(event ${eventId})`)); ` ${icon} ${dim("Queued".padEnd(10))}`,
console.log(parts.join(" ")); "Processing in background",
continue; ];
} if (eventId) parts.push(dim(`(event ${eventId})`));
console.log(parts.join(" "));
continue;
}
const event = (r.event ?? "ADD") as string; const event = (r.event ?? "ADD") as string;
const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") 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 memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(
0,
8,
);
let icon: string; let icon: string;
let label: string; let label: string;
if (event === "ADD") { if (event === "ADD") {
icon = success("+"); icon = success("+");
label = "Added"; label = "Added";
} else if (event === "UPDATE") { } else if (event === "UPDATE") {
icon = accent("~"); icon = accent("~");
label = "Updated"; label = "Updated";
} else if (event === "DELETE") { } else if (event === "DELETE") {
icon = errorColor("-"); icon = errorColor("-");
label = "Deleted"; label = "Deleted";
} else if (event === "NOOP") { } else if (event === "NOOP") {
icon = dim("·"); icon = dim("·");
label = "No change"; label = "No change";
} else { } else {
icon = dim("?"); icon = dim("?");
label = event; label = event;
} }
const parts = [` ${icon} ${dim(label.padEnd(10))}`]; const parts = [` ${icon} ${dim(label.padEnd(10))}`];
if (memory) parts.push(memory); if (memory) parts.push(memory);
if (memId) parts.push(dim(`(${memId})`)); if (memId) parts.push(dim(`(${memId})`));
console.log(parts.join(" ")); console.log(parts.join(" "));
} }
console.log(); console.log();
} }
export function formatJsonEnvelope(opts: { export function formatJsonEnvelope(opts: {
command: string; command: string;
data: unknown; data: unknown;
durationMs?: number; durationMs?: number;
scope?: Record<string, string | undefined>; scope?: Record<string, string | undefined>;
count?: number; count?: number;
status?: string; status?: string;
error?: string; error?: string;
}): void { }): void {
const envelope: Record<string, unknown> = { const envelope: Record<string, unknown> = {
status: opts.status ?? "success", status: opts.status ?? "success",
command: opts.command, command: opts.command,
}; };
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs; if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
if (opts.scope !== undefined) envelope.scope = opts.scope; if (opts.scope !== undefined) envelope.scope = opts.scope;
if (opts.count !== undefined) envelope.count = opts.count; if (opts.count !== undefined) envelope.count = opts.count;
if (opts.error) envelope.error = opts.error; if (opts.error) envelope.error = opts.error;
envelope.data = opts.data; envelope.data = opts.data;
console.log(JSON.stringify(envelope, null, 2)); console.log(JSON.stringify(envelope, null, 2));
} }
export function printResultSummary(opts: { export function printResultSummary(opts: {
count: number; count: number;
durationSecs?: number; durationSecs?: number;
page?: number; page?: number;
scopeIds?: Record<string, string | undefined>; scopeIds?: Record<string, string | undefined>;
}): void { }): void {
const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`]; const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`];
if (opts.page !== undefined) parts.push(`page ${opts.page}`); if (opts.page !== undefined) parts.push(`page ${opts.page}`);
if (opts.scopeIds) { if (opts.scopeIds) {
const scopeParts = Object.entries(opts.scopeIds) const scopeParts = Object.entries(opts.scopeIds)
.filter(([, v]) => v) .filter(([, v]) => v)
.map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`); .map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`);
if (scopeParts.length > 0) parts.push(scopeParts.join(", ")); if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
} }
if (opts.durationSecs !== undefined) parts.push(`${opts.durationSecs.toFixed(2)}s`); if (opts.durationSecs !== undefined)
parts.push(`${opts.durationSecs.toFixed(2)}s`);
console.log(` ${dim(parts.join(" · "))}`); console.log(` ${dim(parts.join(" · "))}`);
console.log(); console.log();
} }
+10
View File
@@ -0,0 +1,10 @@
import { createRequire } from "node:module";
// __CLI_VERSION__ is replaced at build time by tsup (see tsup.config.ts).
// When running via tsx in dev/test mode, fall back to reading package.json.
// typeof is safe to use on undeclared identifiers — it returns 'undefined' without throwing.
export const CLI_VERSION: string =
typeof __CLI_VERSION__ !== "undefined"
? (__CLI_VERSION__ as string)
: (createRequire(import.meta.url)("../package.json") as { version: string })
.version;
+4 -4
View File
@@ -80,19 +80,19 @@ describe("printWarning", () => {
describe("printInfo", () => { describe("printInfo", () => {
it("prints info message", () => { it("prints info message", () => {
printInfo("Important note"); printInfo("Important note");
expect(output).toContain("Important note"); expect(errOutput).toContain("Important note");
}); });
}); });
describe("printScope", () => { describe("printScope", () => {
it("prints scope when IDs present", () => { it("prints scope when IDs present", () => {
printScope({ user_id: "alice", agent_id: "bot" }); printScope({ user_id: "alice", agent_id: "bot" });
expect(output).toContain("alice"); expect(errOutput).toContain("alice");
expect(output).toContain("bot"); expect(errOutput).toContain("bot");
}); });
it("prints nothing when no IDs", () => { it("prints nothing when no IDs", () => {
printScope({}); printScope({});
expect(output).toBe(""); expect(errOutput).toBe("");
}); });
}); });
+2 -2
View File
@@ -125,7 +125,7 @@ describe("cmdSearch", () => {
enableGraph: false, enableGraph: false,
output: "text", output: "text",
}); });
expect(output).toContain("No memories found"); expect(errOutput).toContain("No memories found");
}); });
}); });
@@ -166,7 +166,7 @@ describe("cmdList", () => {
enableGraph: false, enableGraph: false,
output: "text", output: "text",
}); });
expect(output).toContain("No memories found"); expect(errOutput).toContain("No memories found");
}); });
}); });
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'tsup';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
const pkg = _require('./package.json');
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
});
+11
View File
@@ -0,0 +1,11 @@
import { createRequire } from "node:module";
import { defineConfig } from "vitest/config";
const _require = createRequire(import.meta.url);
const pkg = _require("./package.json") as { version: string };
export default defineConfig({
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
});
+49 -20
View File
@@ -253,8 +253,12 @@ def search(
help="Specific fields to return (comma-separated).", help="Specific fields to return (comma-separated).",
rich_help_panel="Search", rich_help_panel="Search",
), ),
graph: bool = typer.Option(False, "--graph", help="Enable graph in search.", rich_help_panel="Search"), graph: bool = typer.Option(
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"), False, "--graph", help="Enable graph in search.", rich_help_panel="Search"
),
no_graph: bool = typer.Option(
False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"
),
output: str = typer.Option( output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
), ),
@@ -281,8 +285,8 @@ def search(
# STEP 7: stdin fallback for query # STEP 7: stdin fallback for query
if query is None: if query is None:
query = _read_stdin() query = _read_stdin()
if query is None: if not query or not query.strip():
print_error(err_console, "No query provided. Pass a query argument or pipe via stdin.") print_error(err_console, "Search query cannot be empty.")
raise typer.Exit(1) raise typer.Exit(1)
backend, config = _get_backend_and_config(api_key, base_url) backend, config = _get_backend_and_config(api_key, base_url)
@@ -372,8 +376,12 @@ def list_cmd(
before: str | None = typer.Option( before: str | None = typer.Option(
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters" None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
), ),
graph: bool = typer.Option(False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"), graph: bool = typer.Option(
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"), False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"
),
no_graph: bool = typer.Option(
False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"
),
output: str = typer.Option( output: str = typer.Option(
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
), ),
@@ -463,11 +471,19 @@ def update(
@app.command(rich_help_panel="Memory") @app.command(rich_help_panel="Memory")
def delete( 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."), 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)."), entity: bool = typer.Option(
project: bool = typer.Option(False, "--project", help="With --all: delete ALL memories project-wide."), False, "--entity", help="Delete the entity itself and all its memories (cascade)."
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."), ),
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."), force: bool = typer.Option(False, "--force", help="Skip confirmation."),
user_id: str | None = typer.Option( user_id: str | None = typer.Option(
None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope"
@@ -641,14 +657,12 @@ def entity_delete(
agent_id: str | None = typer.Option( agent_id: str | None = typer.Option(
None, "--agent-id", help="Agent ID.", rich_help_panel="Scope" None, "--agent-id", help="Agent ID.", rich_help_panel="Scope"
), ),
app_id: str | None = typer.Option( app_id: str | None = typer.Option(None, "--app-id", help="App ID.", rich_help_panel="Scope"),
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"),
),
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."), 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."), dry_run: bool = typer.Option(
False, "--dry-run", help="Show what would be deleted without deleting."
),
output: str = typer.Option( output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
), ),
@@ -694,17 +708,25 @@ app.add_typer(entity_app, name="entity", rich_help_panel="Management")
@app.command(rich_help_panel="Management") @app.command(rich_help_panel="Management")
def init( def init(
api_key: str | None = typer.Option(None, "--api-key", help="API key (skip prompt)."), 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)."), 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)."
),
) -> None: ) -> None:
"""Interactive setup wizard for mem0 CLI. """Interactive setup wizard for mem0 CLI.
Examples: Examples:
mem0 init mem0 init
mem0 init --api-key m0-xxx --user-id alice mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
""" """
from mem0_cli.commands.init_cmd import run_init from mem0_cli.commands.init_cmd import run_init
run_init(api_key=api_key, user_id=user_id) run_init(api_key=api_key, user_id=user_id, email=email, code=code)
# (entity_app registered at module level, below sub-group definitions) # (entity_app registered at module level, below sub-group definitions)
@@ -743,7 +765,6 @@ def status(
) )
@app.command("import", rich_help_panel="Management") @app.command("import", rich_help_panel="Management")
def import_cmd( def import_cmd(
file_path: str = typer.Argument(..., help="JSON file to import."), file_path: str = typer.Argument(..., help="JSON file to import."),
@@ -1010,6 +1031,14 @@ def help(
console.print() console.print()
@app.command(rich_help_panel="Utility")
def version() -> None:
"""Show version and exit."""
from mem0_cli.commands.utils import cmd_version
cmd_version()
# Register config subgroup here so it appears after help in Management panel # Register config subgroup here so it appears after help in Management panel
app.add_typer(config_app, name="config", rich_help_panel="Management") app.add_typer(config_app, name="config", rich_help_panel="Management")
-1
View File
@@ -105,7 +105,6 @@ class Backend(ABC):
def entities(self, entity_type: str) -> list[dict]: ... def entities(self, entity_type: str) -> list[dict]: ...
def get_backend(config: Mem0Config) -> Backend: def get_backend(config: Mem0Config) -> Backend:
"""Return the Platform backend.""" """Return the Platform backend."""
from mem0_cli.backend.platform import PlatformBackend from mem0_cli.backend.platform import PlatformBackend
@@ -312,7 +312,6 @@ class PlatformBackend(Backend):
return items return items
class AuthError(Exception): class AuthError(Exception):
pass pass
+6 -2
View File
@@ -28,7 +28,9 @@ def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> Non
"""List entities of a given type.""" """List entities of a given type."""
valid_types = {"users", "agents", "apps", "runs"} valid_types = {"users", "agents", "apps", "runs"}
if entity_type not in valid_types: if entity_type not in valid_types:
print_error(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) raise typer.Exit(1)
_start = _time.perf_counter() _start = _time.perf_counter()
@@ -76,7 +78,9 @@ def cmd_entities_delete(
) -> None: ) -> None:
"""Delete an entity and all its memories (cascade delete).""" """Delete an entity and all its memories (cascade delete)."""
if not any([user_id, agent_id, app_id, run_id]): 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) raise typer.Exit(1)
if dry_run: if dry_run:
+172 -3
View File
@@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
import os
import re
import sys import sys
import httpx
import typer import typer
from rich.console import Console from rich.console import Console
from rich.prompt import Prompt from rich.prompt import Prompt
@@ -16,7 +19,7 @@ from mem0_cli.branding import (
print_info, print_info,
print_success, print_success,
) )
from mem0_cli.config import Mem0Config, save_config from mem0_cli.config import DEFAULT_BASE_URL, Mem0Config, save_config
console = Console() console = Console()
err_console = Console(stderr=True) err_console = Console(stderr=True)
@@ -84,7 +87,89 @@ def _prompt_secret(label: str) -> str:
return "".join(chars) return "".join(chars)
def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None: _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,
) -> None:
"""Interactive setup wizard for mem0 CLI. """Interactive setup wizard for mem0 CLI.
When both *api_key* and *user_id* are supplied, all prompts are skipped When both *api_key* and *user_id* are supplied, all prompts are skipped
@@ -93,6 +178,48 @@ def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None:
""" """
config = Mem0Config() 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)
# ── 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 "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) ───────────────────────────────────────
# Fully non-interactive when both flags provided # Fully non-interactive when both flags provided
if api_key and user_id: if api_key and user_id:
config.platform.api_key = api_key config.platform.api_key = api_key
@@ -115,7 +242,49 @@ def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None:
console.print() console.print()
print_info(console, "Welcome! Let's set up your mem0 CLI.\n") print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
# Use provided flags or prompt # 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 "mem0-cli"
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# API key flow
if api_key: if api_key:
config.platform.api_key = api_key config.platform.api_key = api_key
else: else:
+46 -3
View File
@@ -95,6 +95,21 @@ def cmd_add(
except json.JSONDecodeError: except json.JSONDecodeError:
cats = [c.strip() for c in categories.split(",")] 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: with timed_status(err_console, "Adding memory...") as ts:
try: try:
result = backend.add( result = backend.add(
@@ -113,7 +128,6 @@ def cmd_add(
) )
except Exception as e: except Exception as e:
ts.error_msg = str(e) ts.error_msg = str(e)
print_error(err_console, str(e))
raise typer.Exit(1) from None raise typer.Exit(1) from None
if output == "quiet": if output == "quiet":
@@ -164,6 +178,13 @@ def cmd_search(
if fields: if fields:
field_list = [f.strip() for f in fields.split(",")] 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() _start = _time.perf_counter()
with timed_status(err_console, "Searching memories...") as _ts: with timed_status(err_console, "Searching memories...") as _ts:
try: try:
@@ -186,11 +207,14 @@ def cmd_search(
raise typer.Exit(1) from None raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start _elapsed = _time.perf_counter() - _start
if output == "quiet":
return
if output == "json": if output == "json":
format_json(console, results) format_json(console, results)
elif output == "table": elif output == "table":
if results: if results:
format_memories_table(console, results) format_memories_table(console, results, show_score=True)
print_result_summary( print_result_summary(
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
) )
@@ -238,6 +262,13 @@ def cmd_list(
output: str = "table", output: str = "table",
) -> None: ) -> None:
"""List memories.""" """List memories."""
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() _start = _time.perf_counter()
with timed_status(err_console, "Listing memories...") as _ts: with timed_status(err_console, "Listing memories...") as _ts:
try: try:
@@ -258,8 +289,19 @@ def cmd_list(
raise typer.Exit(1) from None raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start _elapsed = _time.perf_counter() - _start
if output == "quiet":
return
if output == "json": if output == "json":
format_json(console, results) from mem0_cli.output import format_json_envelope
format_json_envelope(
console,
command="list",
data=results,
count=len(results),
scope={k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v},
)
elif output == "table": elif output == "table":
if results: if results:
format_memories_table(console, results) format_memories_table(console, results)
@@ -376,6 +418,7 @@ def cmd_delete_all(
# Project-wide wipe using wildcard entity IDs # Project-wide wipe using wildcard entity IDs
if dry_run: if dry_run:
print_info(console, "Would delete ALL memories project-wide.") print_info(console, "Would delete ALL memories project-wide.")
print_info(console, "Run without --dry-run to see the actual count.")
print_info(console, "No changes made (dry run).") print_info(console, "No changes made (dry run).")
return return
+3 -1
View File
@@ -110,7 +110,9 @@ def cmd_import(
added = 0 added = 0
failed = 0 failed = 0
_start = _time.perf_counter() _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", ""))) content = item.get("memory", item.get("text", item.get("content", "")))
if not content: if not content:
failed += 1 failed += 1
+14 -1
View File
@@ -45,6 +45,17 @@ class Mem0Config:
platform: PlatformConfig = field(default_factory=PlatformConfig) 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: def ensure_config_dir() -> Path:
"""Create ~/.mem0 directory with secure permissions if it doesn't exist.""" """Create ~/.mem0 directory with secure permissions if it doesn't exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True) CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@@ -140,7 +151,8 @@ def redact_key(key: str) -> str:
def get_nested_value(config: Mem0Config, dotted_key: str) -> Any: def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
"""Get a config value by dotted path, e.g. 'platform.api_key'.""" """Get a config value by dotted path, e.g. 'platform.api_key' or short form 'api_key'."""
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
parts = dotted_key.split(".") parts = dotted_key.split(".")
obj: Any = config obj: Any = config
for part in parts: for part in parts:
@@ -153,6 +165,7 @@ def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool: def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool:
"""Set a config value by dotted path. Returns True on success.""" """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(".") parts = dotted_key.split(".")
obj: Any = config obj: Any = config
for part in parts[:-1]: for part in parts[:-1]:
+21 -5
View File
@@ -49,7 +49,9 @@ def format_memories_text(console: Console, memories: list[dict], title: str = "m
console.print() console.print()
def format_memories_table(console: Console, memories: list[dict]) -> None: def format_memories_table(
console: Console, memories: list[dict], *, show_score: bool = False
) -> None:
"""Render memories in a rich table.""" """Render memories in a rich table."""
table = Table( table = Table(
border_style=BRAND_COLOR, border_style=BRAND_COLOR,
@@ -57,20 +59,34 @@ def format_memories_table(console: Console, memories: list[dict]) -> None:
row_styles=["", "dim"], row_styles=["", "dim"],
padding=(0, 1), padding=(0, 1),
) )
table.add_column("ID", style="dim", max_width=10) table.add_column("ID", style="dim", max_width=38, no_wrap=True)
if show_score:
table.add_column("Score", max_width=7, justify="right")
table.add_column("Memory", max_width=50, no_wrap=False) table.add_column("Memory", max_width=50, no_wrap=False)
table.add_column("Category", max_width=14) table.add_column("Category", max_width=14)
table.add_column("Created", max_width=12) table.add_column("Created", max_width=12)
for mem in memories: for mem in memories:
mem_id = mem.get("id", "")[:8] mem_id = mem.get("id", "")
memory_text = mem.get("memory", mem.get("text", "")) memory_text = mem.get("memory", mem.get("text", ""))
if len(memory_text) > 60: if len(memory_text) > 60:
memory_text = memory_text[:57] + "..." memory_text = memory_text[:57] + "..."
categories = mem.get("categories", []) categories = mem.get("categories", [])
cat = categories[0] if isinstance(categories, list) and categories else "—" if isinstance(categories, list) and categories:
cat = (
categories[0]
if len(categories) == 1
else f"{categories[0]} (+{len(categories) - 1})"
)
else:
cat = "—"
created = _format_date(mem.get("created_at")) or "—" created = _format_date(mem.get("created_at")) or "—"
table.add_row(mem_id, memory_text, cat, created) if show_score:
score = mem.get("score")
score_str = f"{score:.2f}" if score is not None else "—"
table.add_row(mem_id, score_str, memory_text, cat, created)
else:
table.add_row(mem_id, memory_text, cat, created)
console.print() console.print()
console.print(table) console.print(table)
+28 -3
View File
@@ -8,11 +8,19 @@ boundaries).
from __future__ import annotations from __future__ import annotations
import os import os
import re
import subprocess import subprocess
import sys import sys
import pytest 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( def _run(
args: list[str], args: list[str],
@@ -28,22 +36,35 @@ def _run(
reads config from ``<home_dir>/.mem0/config.json`` instead reads config from ``<home_dir>/.mem0/config.json`` instead
of the user's real config. This is critical for tests that of the user's real config. This is critical for tests that
depend on a clean (no API key) or custom config state. 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() env = os.environ.copy()
# Strip all MEM0_ env vars so tests start clean # Strip all MEM0_ env vars so tests start clean
for key in list(env.keys()): for key in list(env.keys()):
if key.startswith("MEM0_"): if key.startswith("MEM0_"):
del env[key] del env[key]
env.pop("FORCE_COLOR", None)
if home_dir: if home_dir:
env["HOME"] = home_dir env["HOME"] = home_dir
if env_override: if env_override:
env.update(env_override) env.update(env_override)
return subprocess.run( result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args], [sys.executable, "-m", "mem0_cli", *args],
capture_output=True, capture_output=True,
text=True, text=True,
env=env, env=env,
) )
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture @pytest.fixture
@@ -67,7 +88,6 @@ class TestCLIIntegration:
assert result.returncode == 0 assert result.returncode == 0
assert "0.1.0" in result.stdout assert "0.1.0" in result.stdout
def test_add_help(self): def test_add_help(self):
result = _run(["add", "--help"]) result = _run(["add", "--help"])
assert result.returncode == 0 assert result.returncode == 0
@@ -161,7 +181,12 @@ class TestCLIIsolated:
) )
assert result.returncode != 0 assert result.returncode != 0
combined = result.stderr + result.stdout 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): def test_config_show_clean(self, clean_home):
"""config show with no config should still work.""" """config show with no config should still work."""
+9 -9
View File
@@ -62,7 +62,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="text", output="text",
) )
@@ -88,7 +88,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="text", output="text",
) )
@@ -113,7 +113,7 @@ class TestAddCommand:
metadata='{"source": "test"}', metadata='{"source": "test"}',
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="text", output="text",
) )
@@ -139,7 +139,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="json", output="json",
) )
@@ -165,7 +165,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="quiet", output="quiet",
) )
@@ -196,7 +196,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="text", output="text",
) )
@@ -221,7 +221,7 @@ class TestAddCommand:
metadata="not-json", metadata="not-json",
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="text", output="text",
) )
@@ -247,7 +247,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories=None, categories=None,
output="text", output="text",
) )
@@ -272,7 +272,7 @@ class TestAddCommand:
metadata=None, metadata=None,
immutable=False, immutable=False,
no_infer=False, no_infer=False,
expires=None, expires=None,
categories="health,prefs", categories="health,prefs",
output="text", output="text",
) )
+2
View File
@@ -99,6 +99,8 @@ class DeepSeekLLM(LLMBase):
} }
) )
if response_format:
params["response_format"] = response_format
if tools: if tools:
params["tools"] = tools params["tools"] = tools
params["tool_choice"] = tool_choice params["tool_choice"] = tool_choice
+2
View File
@@ -99,6 +99,8 @@ class VllmLLM(LLMBase):
} }
) )
if response_format:
params["response_format"] = response_format
if tools: if tools:
params["tools"] = tools params["tools"] = tools
params["tool_choice"] = tool_choice params["tool_choice"] = tool_choice
+2 -2
View File
@@ -51,7 +51,7 @@ class MongoDB(VectorStoreBase):
"""Create new collection with vector search index.""" """Create new collection with vector search index."""
try: try:
database = self.client[self.db_name] database = self.client[self.db_name]
collection_names = database.list_collection_names() collection_names = database.list_collection_names(authorizedCollections=True)
if self.collection_name not in collection_names: if self.collection_name not in collection_names:
logger.info(f"Collection '{self.collection_name}' does not exist. Creating it now.") logger.info(f"Collection '{self.collection_name}' does not exist. Creating it now.")
collection = database[self.collection_name] collection = database[self.collection_name]
@@ -240,7 +240,7 @@ class MongoDB(VectorStoreBase):
List[str]: List of collection names. List[str]: List of collection names.
""" """
try: try:
collections = self.db.list_collection_names() collections = self.db.list_collection_names(authorizedCollections=True)
logger.info(f"Listing collections in database '{self.db_name}': {collections}") logger.info(f"Listing collections in database '{self.db_name}': {collections}")
return collections return collections
except PyMongoError as e: except PyMongoError as e:
+26
View File
@@ -0,0 +1,26 @@
# OpenClaw + Mem0 Docker Setup
# Copy this file to .env and fill in your values:
# cp .env.example .env
# ── Required (one of these depending on mode) ───────────────────────────────
# Platform mode: Mem0 Cloud API key from https://app.mem0.ai
MEM0_API_KEY=
# OSS mode: OpenAI key for embeddings, LLM, and the OpenClaw agent
# Platform mode: only needed if you want a specific LLM for the agent itself
OPENAI_API_KEY=
# ── Optional ─────────────────────────────────────────────────────────────────
# User ID for memory scoping (defaults to your OS username)
MEM0_USER_ID=
# Web UI port (default: 18789)
OPENCLAW_PORT=18789
# Config directory on host (default: ~/.openclaw)
# OPENCLAW_CONFIG_DIR=~/.openclaw
# Timezone (default: UTC)
TZ=UTC
+102
View File
@@ -0,0 +1,102 @@
# OpenClaw + Mem0 One-Click Docker Setup
Get a working OpenClaw agent with Mem0 persistent memory in under 2 minutes.
## Prerequisites
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine with Compose v2
- One of:
- **Platform mode**: A Mem0 API key from [app.mem0.ai](https://app.mem0.ai)
- **OSS mode**: An OpenAI API key
## Quick Start
```bash
git clone https://github.com/mem0ai/mem0 && cd mem0/openclaw/docker
./setup.sh
```
The setup script will:
1. Ask you to choose Platform or Open-Source mode
2. Prompt for your API key(s)
3. Start the containers automatically
Then open **http://localhost:18789** to start chatting.
## What Gets Deployed
### Platform Mode
```
┌─────────────────────────┐
│ OpenClaw (port 18789) │
│ + Mem0 Plugin │──→ Mem0 Cloud (app.mem0.ai)
└─────────────────────────┘
```
### OSS Mode
```
┌─────────────────────────┐ ┌─────────────────┐
│ OpenClaw (port 18789) │ │ Qdrant │
│ + Mem0 Plugin │────→│ (port 6333) │
└─────────────────────────┘ │ Vector Storage │
└─────────────────┘
```
## Modes Explained
| Feature | Platform (Mem0 Cloud) | Open-Source (Self-Hosted) |
|---------|----------------------|--------------------------|
| Setup | API key only | API key + Qdrant container |
| Vector storage | Managed by Mem0 | Local Qdrant |
| LLM for extraction | Managed by Mem0 | Your OpenAI key |
| Graph memory | Supported | Not yet |
| Dashboard | app.mem0.ai | CLI commands |
## Configuration
After setup, your config lives at `~/.openclaw/openclaw.json`. You can edit it directly.
Key settings in the `openclaw-mem0` plugin section:
| Setting | Default | Description |
|---------|---------|-------------|
| `autoCapture` | `true` | Auto-store conversation facts |
| `autoRecall` | `true` | Auto-retrieve relevant memories |
| `searchThreshold` | `0.5` | Minimum similarity score (0-1) |
| `topK` | `5` | Max memories to retrieve |
To change the port or other Docker settings, edit `.env` in this directory.
## Troubleshooting
**Port 18789 already in use**
```bash
lsof -i :18789
# Kill the process or change OPENCLAW_PORT in .env
```
**API key errors**
- Platform: Verify your key at [app.mem0.ai](https://app.mem0.ai)
- OSS: Ensure `OPENAI_API_KEY` is valid and has credits
**Containers won't start**
```bash
docker compose logs
# or for OSS mode:
docker compose -f docker-compose.yml -f docker-compose.oss.yml logs
```
**Docker not found**
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) or follow the [engine install guide](https://docs.docker.com/engine/install/).
**Plugin not loading**
```bash
# Reinstall the plugin manually
docker compose run --rm openclaw openclaw plugins install @mem0/openclaw-mem0
```
## Links
- [Mem0 Documentation](https://docs.mem0.ai)
- [Mem0 OpenClaw Plugin](https://github.com/mem0ai/mem0/tree/main/openclaw)
- [OpenClaw Documentation](https://openclaw.com/docs)
+21
View File
@@ -0,0 +1,21 @@
{
"plugins": {
"openclaw-mem0": {
"mode": "open-source",
"userId": "__MEM0_USER_ID__",
"autoCapture": true,
"autoRecall": true,
"searchThreshold": 0.5,
"topK": 5,
"oss": {
"vectorStore": {
"provider": "qdrant",
"config": {
"host": "qdrant",
"port": 6333
}
}
}
}
}
}
@@ -0,0 +1,13 @@
{
"plugins": {
"openclaw-mem0": {
"mode": "platform",
"apiKey": "${MEM0_API_KEY}",
"userId": "__MEM0_USER_ID__",
"autoCapture": true,
"autoRecall": true,
"searchThreshold": 0.5,
"topK": 5
}
}
}
+23
View File
@@ -0,0 +1,23 @@
services:
qdrant:
image: qdrant/qdrant:latest
volumes:
- qdrant_data:/qdrant/storage
# No host port exposure — only accessible via Docker network (openclaw → qdrant:6333)
# Uncomment the next line for debugging: ports: ["127.0.0.1:6333:6333"]
expose:
- "6333"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "bash -c ':> /dev/tcp/localhost/6333' || exit 1"]
interval: 10s
timeout: 3s
retries: 5
openclaw:
depends_on:
qdrant:
condition: service_healthy
volumes:
qdrant_data:
+22
View File
@@ -0,0 +1,22 @@
services:
openclaw:
image: ghcr.io/openclaw/openclaw:latest
environment:
HOME: /home/node
TERM: xterm-256color
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
MEM0_API_KEY: ${MEM0_API_KEY:-}
TZ: ${TZ:-UTC}
volumes:
- ${OPENCLAW_CONFIG_DIR:-~/.openclaw}:/home/node/.openclaw
ports:
# Bound to localhost only for security — change to "18789:18789" for LAN access
- "127.0.0.1:${OPENCLAW_PORT:-18789}:18789"
restart: unless-stopped
command: ["node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18789"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:18789/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# OpenClaw + Mem0 One-Click Setup
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
OPENCLAW_PORT="${OPENCLAW_PORT:-18789}"
# Colors (disabled if not a terminal)
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
fi
info() { echo -e "${BLUE}ℹ${NC} $*"; }
ok() { echo -e "${GREEN}✔${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
error() { echo -e "${RED}✖${NC} $*" >&2; }
header() { echo -e "\n${BOLD}$*${NC}"; }
# ============================================================================
# Step 1: Check prerequisites
# ============================================================================
header "Checking prerequisites..."
if ! command -v docker &>/dev/null; then
error "Docker is not installed."
echo " Install Docker Desktop: https://www.docker.com/products/docker-desktop/"
echo " Or Docker Engine: https://docs.docker.com/engine/install/"
exit 1
fi
if ! docker compose version &>/dev/null; then
error "Docker Compose v2 is not available."
echo " Ensure you have Docker Compose v2 (comes with Docker Desktop)."
echo " Docs: https://docs.docker.com/compose/install/"
exit 1
fi
if ! docker info &>/dev/null 2>&1; then
error "Docker daemon is not running. Please start Docker and try again."
exit 1
fi
ok "Docker and Docker Compose are available."
# Check for port conflict
if command -v lsof &>/dev/null && lsof -i :"$OPENCLAW_PORT" &>/dev/null; then
warn "Port $OPENCLAW_PORT is already in use."
echo " Check what's using it: lsof -i :$OPENCLAW_PORT"
echo " Or set a different port: OPENCLAW_PORT=18790 ./setup.sh"
exit 1
fi
# ============================================================================
# Step 2: Check for existing config
# ============================================================================
if [ -f "$OPENCLAW_CONFIG_DIR/openclaw.json" ]; then
warn "Existing config found at $OPENCLAW_CONFIG_DIR/openclaw.json"
read -rp " Overwrite with new config? [y/N] " overwrite
if [[ ! "$overwrite" =~ ^[Yy]$ ]]; then
info "Keeping existing config. Skipping config generation."
SKIP_CONFIG=true
else
SKIP_CONFIG=false
fi
else
SKIP_CONFIG=false
fi
# ============================================================================
# Step 3: Choose mode
# ============================================================================
header "Choose your setup mode:"
echo ""
echo " 1) Platform — Uses Mem0 Cloud (managed storage, dashboard at app.mem0.ai)"
echo " 2) OSS — Self-hosted with local Qdrant (fully offline-capable)"
echo ""
while true; do
read -rp "Enter 1 or 2: " mode_choice
case "$mode_choice" in
1) MODE="platform"; break ;;
2) MODE="oss"; break ;;
*) echo " Please enter 1 or 2." ;;
esac
done
ok "Mode: $MODE"
# ============================================================================
# Step 4: Collect API keys
# ============================================================================
header "API Key Configuration"
MEM0_API_KEY=""
OPENAI_API_KEY=""
if [ "$MODE" = "platform" ]; then
echo " Platform mode requires a Mem0 API key from https://app.mem0.ai"
echo ""
while true; do
read -rp " Mem0 API Key: " MEM0_API_KEY
if [ -n "$MEM0_API_KEY" ]; then break; fi
warn "API key is required for platform mode."
done
echo ""
read -rp " OpenAI API Key (optional, for agent LLM — press Enter to skip): " OPENAI_API_KEY
else
echo " OSS mode requires an OpenAI API key for embeddings, LLM, and the agent."
echo ""
while true; do
read -rp " OpenAI API Key: " OPENAI_API_KEY
if [ -n "$OPENAI_API_KEY" ]; then break; fi
warn "API key is required for OSS mode."
done
fi
ok "API keys configured."
# ============================================================================
# Step 5: User ID
# ============================================================================
DEFAULT_USER_ID="$(whoami)"
read -rp " User ID for memory scoping [${DEFAULT_USER_ID}]: " MEM0_USER_ID
MEM0_USER_ID="${MEM0_USER_ID:-$DEFAULT_USER_ID}"
ok "User ID: $MEM0_USER_ID"
# ============================================================================
# Step 6: Generate .env
# ============================================================================
header "Generating configuration..."
cat > "$SCRIPT_DIR/.env" <<EOF
# Generated by setup.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
MEM0_API_KEY=${MEM0_API_KEY}
OPENAI_API_KEY=${OPENAI_API_KEY}
MEM0_USER_ID=${MEM0_USER_ID}
OPENCLAW_PORT=${OPENCLAW_PORT}
OPENCLAW_CONFIG_DIR=${OPENCLAW_CONFIG_DIR}
TZ=${TZ:-UTC}
EOF
ok "Created .env"
# ============================================================================
# Step 7: Generate OpenClaw config
# ============================================================================
if [ "$SKIP_CONFIG" = false ]; then
mkdir -p "$OPENCLAW_CONFIG_DIR"
if [ "$MODE" = "platform" ]; then
TEMPLATE="$SCRIPT_DIR/config/openclaw.platform.json"
else
TEMPLATE="$SCRIPT_DIR/config/openclaw.oss.json"
fi
# Substitute the user ID placeholder (apiKey uses env var resolution at runtime)
sed "s/__MEM0_USER_ID__/${MEM0_USER_ID}/g" "$TEMPLATE" > "$OPENCLAW_CONFIG_DIR/openclaw.json"
ok "Created $OPENCLAW_CONFIG_DIR/openclaw.json"
fi
# ============================================================================
# Step 8: Install Mem0 plugin
# ============================================================================
header "Installing Mem0 plugin..."
COMPOSE_CMD="docker compose"
if [ "$MODE" = "oss" ]; then
COMPOSE_CMD="docker compose -f docker-compose.yml -f docker-compose.oss.yml"
fi
cd "$SCRIPT_DIR"
if $COMPOSE_CMD run --rm openclaw openclaw plugins install @mem0/openclaw-mem0 2>/dev/null; then
ok "Mem0 plugin installed."
else
warn "Plugin install via Docker failed. This may be normal on first run."
echo " You can install manually later:"
echo " $COMPOSE_CMD run --rm openclaw openclaw plugins install @mem0/openclaw-mem0"
fi
# ============================================================================
# Step 9: Copy onboarding skill
# ============================================================================
SKILL_DIR="$OPENCLAW_CONFIG_DIR/workspace/skills/mem0-onboarding"
mkdir -p "$SKILL_DIR"
cp "$SCRIPT_DIR/skills/mem0-onboarding/SKILL.md" "$SKILL_DIR/SKILL.md"
ok "Onboarding skill installed."
# ============================================================================
# Step 10: Start containers
# ============================================================================
header "Starting OpenClaw..."
$COMPOSE_CMD up -d
# ============================================================================
# Step 11: Health check
# ============================================================================
info "Waiting for OpenClaw to be ready..."
HEALTH_URL="http://127.0.0.1:${OPENCLAW_PORT}/healthz"
TIMEOUT=90
ELAPSED=0
INTERVAL=3
while [ $ELAPSED -lt $TIMEOUT ]; do
if curl -sf "$HEALTH_URL" &>/dev/null; then
echo ""
ok "OpenClaw is ready!"
break
fi
printf "."
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
done
if [ $ELAPSED -ge $TIMEOUT ]; then
echo ""
warn "Health check timed out after ${TIMEOUT}s."
echo " OpenClaw may still be starting. Check logs:"
echo " cd $SCRIPT_DIR && $COMPOSE_CMD logs -f"
exit 1
fi
# ============================================================================
# Done!
# ============================================================================
echo ""
header "Setup complete!"
echo ""
echo " Open the web UI: ${BOLD}http://localhost:${OPENCLAW_PORT}${NC}"
echo " Mode: ${MODE}"
echo " User ID: ${MEM0_USER_ID}"
echo " Config: ${OPENCLAW_CONFIG_DIR}/openclaw.json"
echo ""
echo " ${BLUE}Tip:${NC} Your first chat will trigger the Mem0 onboarding skill."
echo " Try telling the agent something about yourself to test memory!"
echo ""
echo " Useful commands:"
echo " View logs: cd $SCRIPT_DIR && $COMPOSE_CMD logs -f"
echo " Stop: cd $SCRIPT_DIR && $COMPOSE_CMD down"
echo " Restart: cd $SCRIPT_DIR && $COMPOSE_CMD restart"
echo ""
@@ -0,0 +1,37 @@
# Mem0 Memory Onboarding
Welcome! This OpenClaw agent has **persistent memory** powered by Mem0. It remembers things across sessions so you never have to repeat yourself.
## How It Works
- **Auto-Capture**: After each conversation turn, important facts are automatically extracted and stored.
- **Auto-Recall**: Before each turn, relevant memories are retrieved to give context.
## Quick Demo — Try These 3 Steps
### Step 1: Tell me something about yourself
Share a preference, a project you're working on, or a fact about yourself. For example:
> "I prefer TypeScript over JavaScript and I'm building a SaaS app called Acme."
### Step 2: Confirm storage
After you share, I'll confirm that the information was captured. You can also check with:
> "What do you remember about me?"
### Step 3: Verify persistence
Start a **new session** (close this chat and open a new one), then ask:
> "What do you know about me?"
Your memories carry over — that's the magic of persistent memory.
## Power User Commands
If your OpenClaw setup includes the Mem0 CLI commands:
- `openclaw mem0 search "query"` — Search your memories
- `openclaw mem0 stats` — View memory statistics
- `openclaw mem0 list` — List recent memories
## Learn More
- [Mem0 Documentation](https://docs.mem0.ai)
- [OpenClaw Plugin README](https://github.com/mem0ai/mem0/tree/main/openclaw)
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Test script for OpenClaw + Mem0 Docker setup
# Validates everything works without needing real API keys
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PASS=0
FAIL=0
TESTS=()
pass() { PASS=$((PASS + 1)); TESTS+=("✔ $1"); echo " ✔ $1"; }
fail() { FAIL=$((FAIL + 1)); TESTS+=("✖ $1: $2"); echo " ✖ $1: $2"; }
echo ""
echo "═══════════════════════════════════════════════════"
echo " OpenClaw + Mem0 Docker Setup — Test Suite"
echo "═══════════════════════════════════════════════════"
# ── Prerequisites ────────────────────────────────────────────────────────────
echo ""
echo "▸ Prerequisites"
if command -v docker &>/dev/null; then
pass "Docker installed"
else
fail "Docker installed" "docker not found"
fi
if docker compose version &>/dev/null; then
pass "Docker Compose v2 available"
else
fail "Docker Compose v2 available" "docker compose not found"
fi
if docker info &>/dev/null 2>&1; then
pass "Docker daemon running"
else
fail "Docker daemon running" "daemon not responding"
fi
# ── File structure ───────────────────────────────────────────────────────────
echo ""
echo "▸ File structure"
EXPECTED_FILES=(
"setup.sh"
"docker-compose.yml"
"docker-compose.oss.yml"
".env.example"
"config/openclaw.platform.json"
"config/openclaw.oss.json"
"skills/mem0-onboarding/SKILL.md"
"README.md"
)
for f in "${EXPECTED_FILES[@]}"; do
if [ -f "$SCRIPT_DIR/$f" ]; then
pass "File exists: $f"
else
fail "File exists: $f" "missing"
fi
done
if [ -x "$SCRIPT_DIR/setup.sh" ]; then
pass "setup.sh is executable"
else
fail "setup.sh is executable" "not executable"
fi
# ── Bash syntax ──────────────────────────────────────────────────────────────
echo ""
echo "▸ Script syntax"
if bash -n "$SCRIPT_DIR/setup.sh" 2>&1; then
pass "setup.sh syntax valid"
else
fail "setup.sh syntax valid" "syntax errors"
fi
# ── JSON validity ────────────────────────────────────────────────────────────
echo ""
echo "▸ JSON configs"
for json_file in config/openclaw.platform.json config/openclaw.oss.json; do
if python3 -c "import json; json.load(open('$SCRIPT_DIR/$json_file'))" 2>/dev/null; then
pass "$json_file is valid JSON"
else
fail "$json_file is valid JSON" "parse error"
fi
done
# Check platform config has expected fields
if python3 -c "
import json, sys
c = json.load(open('$SCRIPT_DIR/config/openclaw.platform.json'))
p = c['plugins']['openclaw-mem0']
assert p['mode'] == 'platform', 'wrong mode'
assert p['apiKey'] == '\${MEM0_API_KEY}', 'apiKey not using env var'
assert p['userId'] == '__MEM0_USER_ID__', 'userId placeholder missing'
assert p['autoCapture'] is True
assert p['autoRecall'] is True
" 2>/dev/null; then
pass "Platform config has correct structure"
else
fail "Platform config has correct structure" "missing or wrong fields"
fi
# Check OSS config has Qdrant vector store
if python3 -c "
import json
c = json.load(open('$SCRIPT_DIR/config/openclaw.oss.json'))
p = c['plugins']['openclaw-mem0']
assert p['mode'] == 'open-source', 'wrong mode'
vs = p['oss']['vectorStore']
assert vs['provider'] == 'qdrant'
assert vs['config']['host'] == 'qdrant'
assert vs['config']['port'] == 6333
" 2>/dev/null; then
pass "OSS config has Qdrant vector store"
else
fail "OSS config has Qdrant vector store" "missing or wrong fields"
fi
# ── Docker Compose validation ────────────────────────────────────────────────
echo ""
echo "▸ Docker Compose validation"
cd "$SCRIPT_DIR"
# Create a temporary .env so compose can resolve variables
TEMP_ENV=false
if [ ! -f .env ]; then
cat > .env <<'EOF'
MEM0_API_KEY=test-key-for-validation
OPENAI_API_KEY=test-key-for-validation
MEM0_USER_ID=testuser
OPENCLAW_PORT=18789
OPENCLAW_CONFIG_DIR=/tmp/openclaw-test
TZ=UTC
EOF
TEMP_ENV=true
fi
if docker compose config --quiet 2>/dev/null; then
pass "docker-compose.yml is valid"
else
fail "docker-compose.yml is valid" "$(docker compose config 2>&1 | tail -1)"
fi
if docker compose -f docker-compose.yml -f docker-compose.oss.yml config --quiet 2>/dev/null; then
pass "docker-compose.oss.yml override is valid"
else
fail "docker-compose.oss.yml override is valid" "$(docker compose -f docker-compose.yml -f docker-compose.oss.yml config 2>&1 | tail -1)"
fi
# Verify services are correctly defined
PLATFORM_SERVICES=$(docker compose config --services 2>/dev/null)
if echo "$PLATFORM_SERVICES" | grep -q "openclaw"; then
pass "Platform mode defines 'openclaw' service"
else
fail "Platform mode defines 'openclaw' service" "service not found"
fi
OSS_SERVICES=$(docker compose -f docker-compose.yml -f docker-compose.oss.yml config --services 2>/dev/null)
if echo "$OSS_SERVICES" | grep -q "qdrant"; then
pass "OSS mode adds 'qdrant' service"
else
fail "OSS mode adds 'qdrant' service" "service not found"
fi
# Verify port binding is localhost-only
PORT_CONFIG=$(docker compose config 2>/dev/null | grep -A5 "published" | head -10)
if docker compose config 2>/dev/null | grep -q "127.0.0.1"; then
pass "Port is bound to 127.0.0.1 (localhost only)"
else
fail "Port is bound to 127.0.0.1 (localhost only)" "binding not found"
fi
# Verify healthcheck is defined
if docker compose config 2>/dev/null | grep -q "healthz"; then
pass "Healthcheck is configured"
else
fail "Healthcheck is configured" "healthcheck not found"
fi
# ── Docker image pull test ───────────────────────────────────────────────────
echo ""
echo "▸ Docker image availability"
# Just check if the image reference is parseable (don't actually pull — too slow for a test)
IMAGE=$(docker compose config 2>/dev/null | grep "image:" | head -1 | awk '{print $2}')
if [ -n "$IMAGE" ]; then
pass "OpenClaw image reference: $IMAGE"
else
fail "OpenClaw image reference" "could not extract image name"
fi
QDRANT_IMAGE=$(docker compose -f docker-compose.yml -f docker-compose.oss.yml config 2>/dev/null | grep "image:" | grep qdrant | awk '{print $2}')
if [ -n "$QDRANT_IMAGE" ]; then
pass "Qdrant image reference: $QDRANT_IMAGE"
else
fail "Qdrant image reference" "could not extract image name"
fi
# ── setup.sh simulation ─────────────────────────────────────────────────────
echo ""
echo "▸ setup.sh config generation simulation"
# Test the sed substitution that setup.sh does
TEST_CONFIG_DIR=$(mktemp -d)
sed "s/__MEM0_USER_ID__/testuser/g" "$SCRIPT_DIR/config/openclaw.platform.json" > "$TEST_CONFIG_DIR/openclaw.json"
if python3 -c "
import json
c = json.load(open('$TEST_CONFIG_DIR/openclaw.json'))
p = c['plugins']['openclaw-mem0']
assert p['userId'] == 'testuser', f'expected testuser, got {p[\"userId\"]}'
assert p['apiKey'] == '\${MEM0_API_KEY}', 'apiKey should remain as env var template'
" 2>/dev/null; then
pass "sed substitution works correctly (userId baked, apiKey templated)"
else
fail "sed substitution" "userId not replaced or apiKey was wrongly replaced"
fi
rm -rf "$TEST_CONFIG_DIR"
# OSS config substitution
TEST_CONFIG_DIR=$(mktemp -d)
sed "s/__MEM0_USER_ID__/testuser/g" "$SCRIPT_DIR/config/openclaw.oss.json" > "$TEST_CONFIG_DIR/openclaw.json"
if python3 -c "
import json
c = json.load(open('$TEST_CONFIG_DIR/openclaw.json'))
p = c['plugins']['openclaw-mem0']
assert p['userId'] == 'testuser'
assert p['oss']['vectorStore']['config']['host'] == 'qdrant'
" 2>/dev/null; then
pass "OSS config sed substitution works correctly"
else
fail "OSS config sed substitution" "fields incorrect after substitution"
fi
rm -rf "$TEST_CONFIG_DIR"
# ── Cleanup ──────────────────────────────────────────────────────────────────
if [ "$TEMP_ENV" = true ]; then
rm -f "$SCRIPT_DIR/.env"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════"
TOTAL=$((PASS + FAIL))
if [ $FAIL -eq 0 ]; then
echo " All $TOTAL tests passed ✔"
else
echo " $PASS/$TOTAL passed, $FAIL failed"
echo ""
for t in "${TESTS[@]}"; do
if [[ "$t" == "✖"* ]]; then
echo " $t"
fi
done
fi
echo "═══════════════════════════════════════════════════"
echo ""
exit $FAIL
+44
View File
@@ -113,3 +113,47 @@ def test_generate_response_with_tools(mock_deepseek_client):
assert len(response["tool_calls"]) == 1 assert len(response["tool_calls"]) == 1
assert response["tool_calls"][0]["name"] == "add_memory" assert response["tool_calls"][0]["name"] == "add_memory"
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."} assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_generate_response_with_response_format(mock_deepseek_client):
config = BaseLlmConfig(model="deepseek-chat", temperature=0.7, max_tokens=100, top_p=1.0)
llm = DeepSeekLLM(config)
messages = [
{"role": "system", "content": "You are a memory extraction assistant."},
{"role": "user", "content": "I like hiking on weekends."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"facts": ["User likes hiking on weekends"]}'))]
mock_deepseek_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, response_format={"type": "json_object"})
mock_deepseek_client.chat.completions.create.assert_called_once_with(
model="deepseek-chat",
messages=messages,
temperature=0.7,
max_tokens=100,
top_p=1.0,
response_format={"type": "json_object"},
)
assert response == '{"facts": ["User likes hiking on weekends"]}'
def test_generate_response_without_response_format(mock_deepseek_client):
config = BaseLlmConfig(model="deepseek-chat", temperature=0.7, max_tokens=100, top_p=1.0)
llm = DeepSeekLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a joke."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Why did the chicken cross the road?"))]
mock_deepseek_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_deepseek_client.chat.completions.create.call_args[1]
assert "response_format" not in call_kwargs
assert response == "Why did the chicken cross the road?"
+44
View File
@@ -88,6 +88,50 @@ def test_generate_response_with_tools(mock_vllm_client):
def test_generate_response_with_response_format(mock_vllm_client):
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
llm = VllmLLM(config)
messages = [
{"role": "system", "content": "You are a memory extraction assistant."},
{"role": "user", "content": "I like hiking on weekends."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"facts": ["User likes hiking on weekends"]}'))]
mock_vllm_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, response_format={"type": "json_object"})
mock_vllm_client.chat.completions.create.assert_called_once_with(
model="Qwen/Qwen2.5-32B-Instruct",
messages=messages,
temperature=0.7,
max_tokens=100,
top_p=1.0,
response_format={"type": "json_object"},
)
assert response == '{"facts": ["User likes hiking on weekends"]}'
def test_generate_response_without_response_format(mock_vllm_client):
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
llm = VllmLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a joke."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Why did the chicken cross the road?"))]
mock_vllm_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_vllm_client.chat.completions.create.call_args[1]
assert "response_format" not in call_kwargs
assert response == "Why did the chicken cross the road?"
def create_mocked_memory(): def create_mocked_memory():
"""Create a fully mocked Memory instance for testing.""" """Create a fully mocked Memory instance for testing."""
with patch('mem0.utils.factory.LlmFactory.create') as mock_llm_factory, \ with patch('mem0.utils.factory.LlmFactory.create') as mock_llm_factory, \