feat: add official mem0 CLI (Python & TypeScript) (#4575)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# mem0 CLI (Node.js)
|
||||
|
||||
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. TypeScript implementation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **18+**
|
||||
- pnpm (`npm install -g pnpm`)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g @mem0/cli
|
||||
```
|
||||
|
||||
Or from source:
|
||||
|
||||
```bash
|
||||
cd node
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm link --global
|
||||
|
||||
# Now use it like a normal CLI
|
||||
mem0 --help
|
||||
```
|
||||
|
||||
## Running during development
|
||||
|
||||
```bash
|
||||
cd node
|
||||
pnpm install
|
||||
|
||||
# Development mode (runs TypeScript directly, no build needed)
|
||||
pnpm dev --help
|
||||
pnpm dev add "test memory" --user-id alice
|
||||
pnpm dev search "test" --user-id alice
|
||||
|
||||
# Or build first, then run the compiled JS
|
||||
pnpm build
|
||||
node dist/index.js --help
|
||||
node dist/index.js add "test memory" --user-id alice
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set up your configuration
|
||||
mem0 init
|
||||
|
||||
# Add a memory
|
||||
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
|
||||
|
||||
# Search memories
|
||||
mem0 search "What are Alice's preferences?" --user-id alice
|
||||
|
||||
# List all memories
|
||||
mem0 list --user-id alice
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MEM0_API_KEY` | API key (overrides config file) |
|
||||
| `MEM0_BASE_URL` | API base URL |
|
||||
| `MEM0_USER_ID` | Default user ID |
|
||||
| `MEM0_AGENT_ID` | Default agent ID |
|
||||
| `MEM0_APP_ID` | Default app ID |
|
||||
| `MEM0_RUN_ID` | Default run ID |
|
||||
| `MEM0_ENABLE_GRAPH` | Enable graph memory (true/false) |
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,91 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **18+**
|
||||
- pnpm (`npm install -g pnpm`)
|
||||
|
||||
## Setup
|
||||
|
||||
From the `node/` directory:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Running the CLI
|
||||
|
||||
There are two ways to run the CLI during development:
|
||||
|
||||
### Option 1: Development mode (no build needed)
|
||||
|
||||
Uses `tsx` to run TypeScript directly. Pass CLI arguments after `pnpm dev`:
|
||||
|
||||
```bash
|
||||
pnpm dev --help
|
||||
pnpm dev version
|
||||
pnpm dev add "test memory" --user-id alice
|
||||
pnpm dev search "test" --user-id alice
|
||||
pnpm dev config show
|
||||
```
|
||||
|
||||
> **Note:** Do NOT use `pnpm dev -- --help`. With pnpm, arguments pass through directly — adding `--` inserts a literal `--` that breaks the CLI parser.
|
||||
|
||||
### Option 2: Build and run compiled JS
|
||||
|
||||
```bash
|
||||
# Build first
|
||||
pnpm build
|
||||
|
||||
# Run the compiled CLI
|
||||
node dist/index.js --help
|
||||
node dist/index.js version
|
||||
node dist/index.js add "test memory" --user-id alice
|
||||
```
|
||||
|
||||
### Option 3: Link globally (makes `mem0` available system-wide)
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm link --global
|
||||
|
||||
# Now use it like a normal CLI
|
||||
mem0 --help
|
||||
mem0 version
|
||||
```
|
||||
|
||||
> **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
The compiled output is in `dist/`.
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Watch mode
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
# Check
|
||||
pnpm lint
|
||||
|
||||
# Auto-fix
|
||||
pnpm lint:fix
|
||||
```
|
||||
|
||||
## Type checking
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mem0": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check --write src/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "mem0.ai <founders@mem0.ai>",
|
||||
"keywords": ["mem0", "memory", "ai", "agents", "cli"],
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.4",
|
||||
"ora": "^8.0.0",
|
||||
"boxen": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vitest": "^1.5.0",
|
||||
"@biomejs/biome": "^1.7.0",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+2066
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Abstract backend interface and factory.
|
||||
*/
|
||||
|
||||
import type { Mem0Config } from "../config.js";
|
||||
import { PlatformBackend } from "./platform.js";
|
||||
|
||||
export interface AddOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
immutable?: boolean;
|
||||
infer?: boolean;
|
||||
expires?: string;
|
||||
categories?: string[];
|
||||
enableGraph?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
topK?: number;
|
||||
threshold?: number;
|
||||
rerank?: boolean;
|
||||
keyword?: boolean;
|
||||
filters?: Record<string, unknown>;
|
||||
fields?: string[];
|
||||
enableGraph?: boolean;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
enableGraph?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteOptions {
|
||||
all?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface EntityIds {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts?: AddOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
search(query: string, opts?: SearchOptions): Promise<Record<string, unknown>[]>;
|
||||
|
||||
get(memoryId: string): Promise<Record<string, unknown>>;
|
||||
|
||||
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
|
||||
|
||||
update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
delete(memoryId?: string, opts?: DeleteOptions): Promise<Record<string, unknown>>;
|
||||
|
||||
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
||||
|
||||
status(opts?: { userId?: string; agentId?: string }): Promise<Record<string, unknown>>;
|
||||
|
||||
entities(entityType: string): Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(message = "Authentication failed. Your API key may be invalid or expired.") {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Resource not found: ${path}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(path: string, detail: string) {
|
||||
super(`Bad request to ${path}: ${detail}`);
|
||||
this.name = "APIError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackend(config: Mem0Config): Backend {
|
||||
return new PlatformBackend(config.platform);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Backend factory re-export.
|
||||
*/
|
||||
|
||||
export { getBackend } from "./base.js";
|
||||
export type { Backend, AddOptions, SearchOptions, ListOptions, DeleteOptions, EntityIds } from "./base.js";
|
||||
export { AuthError, NotFoundError, APIError } from "./base.js";
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Platform (SaaS) backend — communicates with api.mem0.ai.
|
||||
*/
|
||||
|
||||
import type { PlatformConfig } from "../config.js";
|
||||
import {
|
||||
type AddOptions,
|
||||
APIError,
|
||||
AuthError,
|
||||
type Backend,
|
||||
type DeleteOptions,
|
||||
type EntityIds,
|
||||
type ListOptions,
|
||||
NotFoundError,
|
||||
type SearchOptions,
|
||||
} from "./base.js";
|
||||
|
||||
export class PlatformBackend implements Backend {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(config: PlatformConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.headers = {
|
||||
Authorization: `Token ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
private async _request(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { json?: unknown; params?: Record<string, string> },
|
||||
): Promise<unknown> {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (opts?.params) {
|
||||
const qs = new URLSearchParams(opts.params).toString();
|
||||
url += `?${qs}`;
|
||||
}
|
||||
|
||||
const fetchOpts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
};
|
||||
if (opts?.json) {
|
||||
fetchOpts.body = JSON.stringify(opts.json);
|
||||
}
|
||||
|
||||
const resp = await fetch(url, fetchOpts);
|
||||
|
||||
if (resp.status === 401) {
|
||||
throw new AuthError();
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
throw new NotFoundError(path);
|
||||
}
|
||||
if (resp.status === 400) {
|
||||
let detail: string;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
detail = (body as Record<string, string>).detail ?? resp.statusText;
|
||||
} catch {
|
||||
detail = resp.statusText;
|
||||
}
|
||||
throw new APIError(path, detail);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
if (resp.status === 204) {
|
||||
return {};
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts: AddOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (messages) {
|
||||
payload.messages = messages;
|
||||
} else if (content) {
|
||||
payload.messages = [{ role: "user", content }];
|
||||
}
|
||||
|
||||
if (opts.userId) payload.user_id = opts.userId;
|
||||
if (opts.agentId) payload.agent_id = opts.agentId;
|
||||
if (opts.appId) payload.app_id = opts.appId;
|
||||
if (opts.runId) payload.run_id = opts.runId;
|
||||
if (opts.metadata) payload.metadata = opts.metadata;
|
||||
if (opts.immutable) payload.immutable = true;
|
||||
if (opts.infer === false) payload.infer = false;
|
||||
if (opts.expires) payload.expiration_date = opts.expires;
|
||||
if (opts.categories) payload.categories = opts.categories;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
|
||||
return (await this._request("POST", "/v1/memories/", { json: payload })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
private _buildFilters(opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
extraFilters?: Record<string, unknown>;
|
||||
}): Record<string, unknown> | undefined {
|
||||
// If caller passed a pre-built filter structure, use it directly
|
||||
if (opts.extraFilters && ("AND" in opts.extraFilters || "OR" in opts.extraFilters)) {
|
||||
return opts.extraFilters;
|
||||
}
|
||||
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (opts.userId) andConditions.push({ user_id: opts.userId });
|
||||
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
|
||||
if (opts.appId) andConditions.push({ app_id: opts.appId });
|
||||
if (opts.runId) andConditions.push({ run_id: opts.runId });
|
||||
|
||||
if (opts.extraFilters) {
|
||||
for (const [k, v] of Object.entries(opts.extraFilters)) {
|
||||
andConditions.push({ [k]: v });
|
||||
}
|
||||
}
|
||||
|
||||
if (andConditions.length === 1) return andConditions[0];
|
||||
if (andConditions.length > 1) return { AND: andConditions };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {
|
||||
query,
|
||||
top_k: opts.topK ?? 10,
|
||||
threshold: opts.threshold ?? 0.3,
|
||||
};
|
||||
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: opts.filters,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.rerank) payload.rerank = true;
|
||||
if (opts.keyword) payload.keyword_search = true;
|
||||
if (opts.fields) payload.fields = opts.fields;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
|
||||
const result = (await this._request("POST", "/v2/memories/search/", {
|
||||
json: payload,
|
||||
})) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async listMemories(opts: ListOptions = {}): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const params: Record<string, string> = {
|
||||
page: String(opts.page ?? 1),
|
||||
page_size: String(opts.pageSize ?? 100),
|
||||
};
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (opts.category) {
|
||||
extra.categories = { contains: opts.category };
|
||||
}
|
||||
if (opts.after) {
|
||||
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), gte: opts.after };
|
||||
}
|
||||
if (opts.before) {
|
||||
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), lte: opts.before };
|
||||
}
|
||||
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.enableGraph) payload.enable_graph = true;
|
||||
|
||||
const result = (await this._request("POST", "/v2/memories/", { json: payload, params })) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (content) payload.text = content;
|
||||
if (metadata) payload.metadata = metadata;
|
||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async delete(
|
||||
memoryId?: string,
|
||||
opts: DeleteOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (opts.all) {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.userId) params.user_id = opts.userId;
|
||||
if (opts.agentId) params.agent_id = opts.agentId;
|
||||
if (opts.appId) params.app_id = opts.appId;
|
||||
if (opts.runId) params.run_id = opts.runId;
|
||||
return (await this._request("DELETE", "/v1/memories/", { params })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
if (memoryId) {
|
||||
return (await this._request("DELETE", `/v1/memories/${memoryId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
throw new Error("Either memoryId or --all is required");
|
||||
}
|
||||
|
||||
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.userId) params.user_id = opts.userId;
|
||||
if (opts.agentId) params.agent_id = opts.agentId;
|
||||
if (opts.appId) params.app_id = opts.appId;
|
||||
if (opts.runId) params.run_id = opts.runId;
|
||||
if (Object.keys(params).length === 0) {
|
||||
throw new Error("At least one entity ID is required for deleteEntities.");
|
||||
}
|
||||
return (await this._request("DELETE", "/v1/entities/", { params })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
async status(
|
||||
opts: { userId?: string; agentId?: string } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
if (opts.userId || opts.agentId) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const statusParams: Record<string, string> = { page: "1", page_size: "1" };
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
await this._request("POST", "/v2/memories/", { json: payload, params: statusParams });
|
||||
} else {
|
||||
await this._request("GET", "/v1/entities/");
|
||||
}
|
||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
connected: false,
|
||||
backend: "platform",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async entities(entityType: string): Promise<Record<string, unknown>[]> {
|
||||
const result = (await this._request("GET", "/v1/entities/")) as unknown;
|
||||
let items: Record<string, unknown>[];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else {
|
||||
items = ((result as Record<string, unknown>).results ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
users: "user",
|
||||
agents: "agent",
|
||||
apps: "app",
|
||||
runs: "run",
|
||||
};
|
||||
const targetType = typeMap[entityType];
|
||||
if (targetType) {
|
||||
items = items.filter(
|
||||
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Branding and ASCII art for mem0 CLI.
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import ora, { type Ora } from "ora";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const PKG_VERSION: string = _require("../package.json").version;
|
||||
|
||||
export const LOGO = `
|
||||
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
|
||||
████╗ ████║██╔════╝████╗ ████║██╔═████╗ ██╔════╝██║ ██║
|
||||
██╔████╔██║█████╗ ██╔████╔██║██║██╔██║ ██║ ██║ ██║
|
||||
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║████╔╝██║ ██║ ██║ ██║
|
||||
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝ ╚██████╗███████╗██║
|
||||
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
|
||||
`;
|
||||
|
||||
export const LOGO_MINI = "◆ mem0";
|
||||
export const TAGLINE = "The Memory Layer for AI Agents";
|
||||
|
||||
export const BRAND_COLOR = "#8b5cf6";
|
||||
export const ACCENT_COLOR = "#a78bfa";
|
||||
export const SUCCESS_COLOR = "#22c55e";
|
||||
export const ERROR_COLOR = "#ef4444";
|
||||
export const WARNING_COLOR = "#f59e0b";
|
||||
export const DIM_COLOR = "#6b7280";
|
||||
|
||||
const brand = chalk.hex(BRAND_COLOR);
|
||||
const accent = chalk.hex(ACCENT_COLOR);
|
||||
const success = chalk.hex(SUCCESS_COLOR);
|
||||
const error = chalk.hex(ERROR_COLOR);
|
||||
const warning = chalk.hex(WARNING_COLOR);
|
||||
const dim = chalk.hex(DIM_COLOR);
|
||||
|
||||
/**
|
||||
* Choose a symbol based on TTY/NO_COLOR. Fancy for interactive terminals,
|
||||
* plain-text for piped/non-TTY or NO_COLOR environments.
|
||||
*/
|
||||
export function sym(fancy: string, plain: string): string {
|
||||
if (!process.stdout.isTTY || process.env.NO_COLOR) return plain;
|
||||
return fancy;
|
||||
}
|
||||
|
||||
export function printBanner(): void {
|
||||
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
|
||||
const logoLines = LOGO.trimEnd().split("\n");
|
||||
const tagline = ` ${TAGLINE}`;
|
||||
const subtitle = `Node.js SDK · v${PKG_VERSION}`;
|
||||
const contentLines = ["", ...logoLines, "", tagline, ""];
|
||||
|
||||
// Compute inner width from longest content line + padding both sides
|
||||
const maxContent = Math.max(...contentLines.map((l) => l.length));
|
||||
const innerWidth = maxContent + pad * 2;
|
||||
const totalWidth = innerWidth + 2; // + 2 for │ borders
|
||||
|
||||
const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`);
|
||||
const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after
|
||||
const bottomBorder = brand(`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`);
|
||||
|
||||
const body = contentLines.map((line) => {
|
||||
const rightPad = innerWidth - pad - line.length;
|
||||
return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`;
|
||||
});
|
||||
// Re-color tagline line with accent instead of brand.bold
|
||||
const taglineIdx = body.length - 2; // second-to-last (before trailing empty line)
|
||||
const taglineRightPad = innerWidth - pad - tagline.length;
|
||||
body[taglineIdx] = `${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
|
||||
|
||||
console.log(topBorder);
|
||||
for (const line of body) console.log(line);
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
export function printSuccess(message: string): void {
|
||||
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
|
||||
}
|
||||
|
||||
export function printError(message: string, hint?: string): void {
|
||||
console.error(`${error(sym("✗", "[error]") + " Error:")} ${message}`);
|
||||
if (hint) {
|
||||
console.error(` ${dim(hint)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printWarning(message: string): void {
|
||||
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
|
||||
}
|
||||
|
||||
export function printInfo(message: string): void {
|
||||
console.log(`${brand(sym("◆", "*"))} ${message}`);
|
||||
}
|
||||
|
||||
export function printScope(ids: Record<string, string | undefined>): void {
|
||||
const parts: string[] = [];
|
||||
for (const [key, val] of Object.entries(ids)) {
|
||||
if (val) {
|
||||
const label = key.replace(/_/g, " ").replace("id", "ID").trim();
|
||||
parts.push(`${label}=${val}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
console.log(` ${dim(`Scope: ${parts.join(", ")}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimedStatusContext {
|
||||
successMsg: string;
|
||||
errorMsg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async function with a spinner, timing the operation.
|
||||
* Equivalent to Python's timed_status context manager.
|
||||
*/
|
||||
export async function timedStatus<T>(
|
||||
message: string,
|
||||
fn: (ctx: TimedStatusContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
||||
const spinner = ora({ text: dim(message), color: "magenta", stream: process.stderr }).start();
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const result = await fn(ctx);
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
spinner.stop();
|
||||
if (ctx.successMsg) {
|
||||
console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
spinner.stop();
|
||||
if (ctx.errorMsg) {
|
||||
console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format helpers using brand colors for external use. */
|
||||
export const colors = { brand, accent, success, error, warning, dim };
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Config management commands: show, set, get.
|
||||
*/
|
||||
|
||||
import Table from "cli-table3";
|
||||
import { printError, printSuccess, colors } from "../branding.js";
|
||||
import {
|
||||
getNestedValue,
|
||||
loadConfig,
|
||||
redactKey,
|
||||
saveConfig,
|
||||
setNestedValue,
|
||||
} from "../config.js";
|
||||
import { formatJsonEnvelope } from "../output.js";
|
||||
|
||||
const { brand, accent, dim } = colors;
|
||||
|
||||
export function cmdConfigShow(opts: { output?: string } = {}): void {
|
||||
const config = loadConfig();
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJsonEnvelope({
|
||||
command: "config show",
|
||||
data: {
|
||||
defaults: {
|
||||
user_id: config.defaults.userId || null,
|
||||
agent_id: config.defaults.agentId || null,
|
||||
app_id: config.defaults.appId || null,
|
||||
run_id: config.defaults.runId || null,
|
||||
enable_graph: config.defaults.enableGraph,
|
||||
},
|
||||
platform: {
|
||||
api_key: redactKey(config.platform.apiKey),
|
||||
base_url: config.platform.baseUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${brand("◆ mem0 Configuration")}\n`);
|
||||
|
||||
const table = new Table({
|
||||
head: [accent("Key"), accent("Value")],
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
// Defaults
|
||||
table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]);
|
||||
table.push(["defaults.agent_id", config.defaults.agentId || dim("(not set)")]);
|
||||
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
|
||||
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
|
||||
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]);
|
||||
table.push(["", ""]);
|
||||
|
||||
// Platform
|
||||
table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
|
||||
table.push(["platform.base_url", config.platform.baseUrl]);
|
||||
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function cmdConfigGet(key: string): void {
|
||||
const config = loadConfig();
|
||||
const value = getNestedValue(config, key);
|
||||
|
||||
if (value === undefined) {
|
||||
printError(`Unknown config key: ${key}`);
|
||||
} else {
|
||||
// Redact secrets
|
||||
if (key.includes("api_key") || key.split(".").pop() === "key") {
|
||||
console.log(redactKey(String(value)));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cmdConfigSet(key: string, value: string): void {
|
||||
const config = loadConfig();
|
||||
if (setNestedValue(config, key, value)) {
|
||||
saveConfig(config);
|
||||
const display = key.includes("key") ? redactKey(value) : value;
|
||||
printSuccess(`${key} = ${display}`);
|
||||
} else {
|
||||
printError(`Unknown config key: ${key}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Entity management commands.
|
||||
*/
|
||||
|
||||
import readline from "node:readline";
|
||||
import Table from "cli-table3";
|
||||
import { printError, printInfo, printSuccess, timedStatus, colors } from "../branding.js";
|
||||
import type { Backend } from "../backend/base.js";
|
||||
import { formatJson } from "../output.js";
|
||||
|
||||
const { brand, accent, dim } = colors;
|
||||
|
||||
const VALID_TYPES = new Set(["users", "agents", "apps", "runs"]);
|
||||
|
||||
export async function cmdEntitiesList(
|
||||
backend: Backend,
|
||||
entityType: string,
|
||||
opts: { output: string },
|
||||
): Promise<void> {
|
||||
if (!VALID_TYPES.has(entityType)) {
|
||||
printError(`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let results: Record<string, unknown>[];
|
||||
try {
|
||||
results = await timedStatus(`Fetching ${entityType}...`, async () => {
|
||||
return backend.entities(entityType);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"This feature may require the mem0 Platform.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(results);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
printInfo(`No ${entityType} found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: [accent("Name / ID"), accent("Created")],
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
for (const entity of results) {
|
||||
const name = String(entity.name ?? entity.id ?? "—");
|
||||
const created = String(entity.created_at ?? "—").slice(0, 10);
|
||||
table.push([name, created]);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log(` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function cmdEntitiesDelete(
|
||||
backend: Backend,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
dryRun?: boolean;
|
||||
force: boolean;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) {
|
||||
printError("Provide at least one of --user-id, --agent-id, --app-id, --run-id.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
const scopeParts: string[] = [];
|
||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
||||
printInfo(`Would delete entity ${scopeParts.join(", ")} and all its memories.`);
|
||||
printInfo("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.force) {
|
||||
const scopeParts: string[] = [];
|
||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
||||
const scope = scopeParts.join(", ");
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
printInfo("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Deleting entity...", async () => {
|
||||
return backend.deleteEntities({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* mem0 init — interactive setup wizard.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
const { brand, dim } = colors;
|
||||
|
||||
function promptSecret(label: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(label);
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf-8");
|
||||
|
||||
const chars: string[] = [];
|
||||
|
||||
const onData = (key: string) => {
|
||||
for (const ch of key) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
cleanup();
|
||||
process.stdout.write("\n");
|
||||
resolve(chars.join(""));
|
||||
return;
|
||||
}
|
||||
if (ch === "\x03") {
|
||||
cleanup();
|
||||
reject(new Error("Interrupted"));
|
||||
return;
|
||||
}
|
||||
if (ch === "\x7f" || ch === "\x08") {
|
||||
// backspace
|
||||
if (chars.length > 0) {
|
||||
chars.pop();
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U — clear line
|
||||
process.stdout.write("\b \b".repeat(chars.length));
|
||||
chars.length = 0;
|
||||
} else if (ch >= " ") {
|
||||
chars.push(ch);
|
||||
process.stdout.write("*");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.pause();
|
||||
};
|
||||
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
function promptLine(label: string, defaultValue?: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim() || defaultValue || "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function setupPlatform(config: Mem0Config): Promise<void> {
|
||||
console.log();
|
||||
console.log(` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`);
|
||||
console.log();
|
||||
|
||||
process.stdout.write(` ${brand("API Key")}: `);
|
||||
const apiKey = await promptSecret("");
|
||||
if (!apiKey) {
|
||||
printError("API key is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
config.platform.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async function setupDefaults(config: Mem0Config): Promise<void> {
|
||||
console.log();
|
||||
printInfo("Set default entity IDs (press Enter to skip).\n");
|
||||
|
||||
const userId = await promptLine(` ${brand("Default User ID")} ${dim("(recommended)")}`, "mem0-cli");
|
||||
if (userId) config.defaults.userId = userId;
|
||||
}
|
||||
|
||||
async function validatePlatform(config: Mem0Config): Promise<void> {
|
||||
console.log();
|
||||
printInfo("Validating connection...");
|
||||
try {
|
||||
const backend = new PlatformBackend(config.platform);
|
||||
const status = await backend.status({
|
||||
userId: config.defaults.userId || undefined,
|
||||
agentId: config.defaults.agentId || undefined,
|
||||
});
|
||||
if (status.connected) {
|
||||
printSuccess("Connected to mem0 Platform!");
|
||||
} else {
|
||||
printError(
|
||||
`Could not connect: ${status.error ?? "Unknown error"}`,
|
||||
"Check your API key and try again.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
printError(`Connection test failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise<void> {
|
||||
const config = createDefaultConfig();
|
||||
|
||||
// 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 {
|
||||
await setupPlatform(config);
|
||||
}
|
||||
|
||||
// Use provided user ID or prompt
|
||||
if (opts.userId) {
|
||||
config.defaults.userId = opts.userId;
|
||||
} else {
|
||||
await setupDefaults(config);
|
||||
}
|
||||
|
||||
await validatePlatform(config);
|
||||
|
||||
saveConfig(config);
|
||||
console.log();
|
||||
printSuccess("Configuration saved to ~/.mem0/config.json");
|
||||
console.log();
|
||||
console.log(` ${dim("Get started:")}`);
|
||||
if (config.defaults.userId) {
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences"')}`);
|
||||
} else {
|
||||
console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`);
|
||||
console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Memory CRUD commands: add, search, get, list, update, delete.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import { printError, printInfo, printScope, printSuccess, timedStatus } from "../branding.js";
|
||||
import type { Backend } from "../backend/base.js";
|
||||
import {
|
||||
formatAddResult,
|
||||
formatJson,
|
||||
formatMemoriesTable,
|
||||
formatMemoriesText,
|
||||
formatSingleMemory,
|
||||
printResultSummary,
|
||||
} from "../output.js";
|
||||
|
||||
export async function cmdAdd(
|
||||
backend: Backend,
|
||||
text: string | undefined,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
messages?: string;
|
||||
file?: string;
|
||||
metadata?: string;
|
||||
immutable: boolean;
|
||||
noInfer: boolean;
|
||||
expires?: string;
|
||||
categories?: string;
|
||||
enableGraph: boolean;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
let msgs: Record<string, unknown>[] | undefined;
|
||||
let content = text;
|
||||
|
||||
// Read from file
|
||||
if (opts.file) {
|
||||
try {
|
||||
const raw = fs.readFileSync(opts.file, "utf-8");
|
||||
msgs = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// Parse messages JSON
|
||||
else if (opts.messages) {
|
||||
try {
|
||||
msgs = JSON.parse(opts.messages);
|
||||
} catch (e) {
|
||||
printError(`Invalid JSON in --messages: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// Read from stdin if piped
|
||||
else if (!content && !process.stdin.isTTY) {
|
||||
content = fs.readFileSync(0, "utf-8").trim();
|
||||
}
|
||||
|
||||
if (!content && !msgs) {
|
||||
printError("No content provided. Pass text, --messages, --file, or pipe via stdin.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let meta: Record<string, unknown> | undefined;
|
||||
if (opts.metadata) {
|
||||
try {
|
||||
meta = JSON.parse(opts.metadata);
|
||||
} catch {
|
||||
printError("Invalid JSON in --metadata.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let cats: string[] | undefined;
|
||||
if (opts.categories) {
|
||||
try {
|
||||
cats = JSON.parse(opts.categories);
|
||||
} catch {
|
||||
cats = opts.categories.split(",").map((c) => c.trim());
|
||||
}
|
||||
}
|
||||
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Adding memory...", async () => {
|
||||
return backend.add(content ?? undefined, msgs, {
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
metadata: meta,
|
||||
immutable: opts.immutable,
|
||||
infer: !opts.noInfer,
|
||||
expires: opts.expires,
|
||||
categories: cats,
|
||||
enableGraph: opts.enableGraph,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.output === "quiet") return;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatAddResult(result, opts.output);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
printScope({ user_id: opts.userId, agent_id: opts.agentId, app_id: opts.appId, run_id: opts.runId });
|
||||
const results = Array.isArray(result) ? result : ((result.results as unknown[]) ?? [result]);
|
||||
const count = results.length;
|
||||
printSuccess(`Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`);
|
||||
formatAddResult(result, opts.output);
|
||||
}
|
||||
|
||||
export async function cmdSearch(
|
||||
backend: Backend,
|
||||
query: string | undefined,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
topK: number;
|
||||
threshold: number;
|
||||
rerank: boolean;
|
||||
keyword: boolean;
|
||||
filterJson?: string;
|
||||
fields?: string;
|
||||
enableGraph: boolean;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!query) {
|
||||
printError("No query provided. Pass a query argument or pipe via stdin.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let filters: Record<string, unknown> | undefined;
|
||||
if (opts.filterJson) {
|
||||
try {
|
||||
filters = JSON.parse(opts.filterJson);
|
||||
} catch {
|
||||
printError("Invalid JSON in --filter.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldList = opts.fields ? opts.fields.split(",").map((f) => f.trim()) : undefined;
|
||||
|
||||
const start = performance.now();
|
||||
let results: Record<string, unknown>[];
|
||||
try {
|
||||
results = await timedStatus("Searching memories...", async () => {
|
||||
return backend.search(query!, {
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
topK: opts.topK,
|
||||
threshold: opts.threshold,
|
||||
rerank: opts.rerank,
|
||||
keyword: opts.keyword,
|
||||
filters,
|
||||
fields: fieldList,
|
||||
enableGraph: opts.enableGraph,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(results);
|
||||
} else if (opts.output === "table") {
|
||||
if (results.length > 0) {
|
||||
formatMemoriesTable(results);
|
||||
printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } });
|
||||
} else {
|
||||
console.log();
|
||||
printInfo("No memories found matching your query.");
|
||||
console.log();
|
||||
}
|
||||
} else {
|
||||
if (results.length > 0) {
|
||||
formatMemoriesText(results);
|
||||
printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } });
|
||||
} else {
|
||||
console.log();
|
||||
printInfo("No memories found matching your query.");
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdGet(
|
||||
backend: Backend,
|
||||
memoryId: string,
|
||||
opts: { output: string },
|
||||
): Promise<void> {
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Fetching memory...", async () => {
|
||||
return backend.get(memoryId);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
formatSingleMemory(result, opts.output);
|
||||
}
|
||||
|
||||
export async function cmdList(
|
||||
backend: Backend,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
category?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
enableGraph: boolean;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const start = performance.now();
|
||||
let results: Record<string, unknown>[];
|
||||
try {
|
||||
results = await timedStatus("Listing memories...", async () => {
|
||||
return backend.listMemories({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
page: opts.page,
|
||||
pageSize: opts.pageSize,
|
||||
category: opts.category,
|
||||
after: opts.after,
|
||||
before: opts.before,
|
||||
enableGraph: opts.enableGraph,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(results);
|
||||
} else if (opts.output === "table") {
|
||||
if (results.length > 0) {
|
||||
formatMemoriesTable(results);
|
||||
printResultSummary({ count: results.length, durationSecs: elapsed, page: opts.page, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } });
|
||||
} else {
|
||||
console.log();
|
||||
printInfo("No memories found.");
|
||||
console.log();
|
||||
}
|
||||
} else {
|
||||
if (results.length > 0) {
|
||||
formatMemoriesText(results, "memories");
|
||||
printResultSummary({ count: results.length, durationSecs: elapsed, page: opts.page, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } });
|
||||
} else {
|
||||
console.log();
|
||||
printInfo("No memories found.");
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdUpdate(
|
||||
backend: Backend,
|
||||
memoryId: string,
|
||||
text: string | undefined,
|
||||
opts: { metadata?: string; output: string },
|
||||
): Promise<void> {
|
||||
let meta: Record<string, unknown> | undefined;
|
||||
if (opts.metadata) {
|
||||
try {
|
||||
meta = JSON.parse(opts.metadata);
|
||||
} catch {
|
||||
printError("Invalid JSON in --metadata.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Updating memory...", async () => {
|
||||
return backend.update(memoryId, text, meta);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
printSuccess(`Memory ${memoryId.slice(0, 8)} updated (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdDelete(
|
||||
backend: Backend,
|
||||
memoryId: string,
|
||||
opts: { output: string; dryRun?: boolean; force?: boolean },
|
||||
): Promise<void> {
|
||||
if (opts.dryRun) {
|
||||
let mem: Record<string, unknown>;
|
||||
try {
|
||||
mem = await backend.get(memoryId);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const text = (mem.memory ?? mem.text ?? "") as string;
|
||||
printInfo(`Would delete memory ${memoryId.slice(0, 8)}: ${text}`);
|
||||
printInfo("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Deleting...", async () => {
|
||||
return backend.delete(memoryId);
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
printSuccess(`Memory ${memoryId.slice(0, 8)} deleted (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdDeleteAll(
|
||||
backend: Backend,
|
||||
opts: {
|
||||
force: boolean;
|
||||
dryRun?: boolean;
|
||||
all?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.all) {
|
||||
// Project-wide wipe using wildcard entity IDs
|
||||
if (opts.dryRun) {
|
||||
printInfo("Would delete ALL memories project-wide.");
|
||||
printInfo("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.force) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(`\n \u26a0 Delete ALL memories across the ENTIRE project? This cannot be undone. [y/N] `, resolve);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
printInfo("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Deleting all memories project-wide...", async () => {
|
||||
return backend.delete(undefined, {
|
||||
all: true,
|
||||
userId: "*",
|
||||
agentId: "*",
|
||||
appId: "*",
|
||||
runId: "*",
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
if (result.message) {
|
||||
printInfo("Deletion started. Memories will be removed in the background.");
|
||||
} else {
|
||||
printSuccess(`All project memories deleted (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
let memories: Record<string, unknown>[];
|
||||
try {
|
||||
memories = await backend.listMemories({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
printInfo(`Would delete ${memories.length} memories.`);
|
||||
printInfo("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.force) {
|
||||
const scopeParts: string[] = [];
|
||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
||||
const scope = scopeParts.length > 0 ? scopeParts.join(", ") : "ALL entities";
|
||||
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(`\n \u26a0 Delete ALL memories for ${scope}? This cannot be undone. [y/N] `, resolve);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
printInfo("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Deleting all memories...", async () => {
|
||||
return backend.delete(undefined, {
|
||||
all: true,
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJson(result);
|
||||
} else if (opts.output !== "quiet") {
|
||||
if (result.message) {
|
||||
printInfo("Deletion started. Memories will be removed in the background.");
|
||||
} else {
|
||||
printSuccess(`All matching memories deleted (${elapsed.toFixed(2)}s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Utility commands: status, version, import.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
const { brand, dim, success, error: errorColor } = colors;
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const VERSION: string = _require("../../package.json").version;
|
||||
|
||||
export async function cmdStatus(
|
||||
backend: Backend,
|
||||
opts: { userId?: string; agentId?: string; output?: string } = {},
|
||||
): Promise<void> {
|
||||
const start = performance.now();
|
||||
let result: Record<string, unknown>;
|
||||
try {
|
||||
result = await timedStatus("Checking connection...", async () => {
|
||||
return backend.status({ userId: opts.userId, agentId: opts.agentId });
|
||||
});
|
||||
} catch (e) {
|
||||
result = { connected: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJsonEnvelope({
|
||||
command: "status",
|
||||
data: {
|
||||
connected: result.connected,
|
||||
backend: result.backend ?? null,
|
||||
base_url: result.base_url ?? null,
|
||||
latency_ms: Math.round(elapsed * 1000),
|
||||
},
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (result.connected) {
|
||||
lines.push(` ${success("\u25cf")} Connected`);
|
||||
} else {
|
||||
lines.push(` ${errorColor("\u25cf")} Disconnected`);
|
||||
}
|
||||
|
||||
lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`);
|
||||
if (result.base_url) {
|
||||
lines.push(` ${dim("API URL:")} ${result.base_url}`);
|
||||
}
|
||||
if (result.error) {
|
||||
lines.push(` ${errorColor("Error:")} ${result.error}`);
|
||||
}
|
||||
lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`);
|
||||
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Connection Status"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function cmdVersion(): void {
|
||||
console.log(` ${brand("◆ Mem0")} CLI v${VERSION}`);
|
||||
}
|
||||
|
||||
export async function cmdImport(
|
||||
backend: Backend,
|
||||
filePath: string,
|
||||
opts: { userId?: string; agentId?: string; output?: string },
|
||||
): Promise<void> {
|
||||
let data: Record<string, unknown>[];
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
data = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (e) {
|
||||
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let failed = 0;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
const content = (item.memory ?? item.text ?? item.content ?? "") as string;
|
||||
if (!content) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await backend.add(content, undefined, {
|
||||
userId: opts.userId ?? (item.user_id as string | undefined),
|
||||
agentId: opts.agentId ?? (item.agent_id as string | undefined),
|
||||
metadata: item.metadata as Record<string, unknown> | undefined,
|
||||
});
|
||||
added++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Simple progress indicator
|
||||
if ((i + 1) % 10 === 0 || i === data.length - 1) {
|
||||
process.stdout.write(`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
console.log(); // Clear progress line
|
||||
|
||||
if (opts.output === "json") {
|
||||
formatJsonEnvelope({
|
||||
command: "import",
|
||||
data: { added, failed, duration_s: parseFloat(elapsed.toFixed(2)) },
|
||||
durationMs: Math.round(elapsed * 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
|
||||
if (failed > 0) {
|
||||
printError(`${failed} memories failed to import.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Configuration management for mem0 CLI.
|
||||
*
|
||||
* Config precedence (highest to lowest):
|
||||
* 1. CLI flags (--api-key, --base-url, etc.)
|
||||
* 2. Environment variables (MEM0_API_KEY, etc.)
|
||||
* 3. Config file (~/.mem0/config.json)
|
||||
* 4. Defaults
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const CONFIG_DIR = path.join(os.homedir(), ".mem0");
|
||||
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
||||
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
|
||||
export const CONFIG_VERSION = 1;
|
||||
|
||||
export interface PlatformConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
userId: string;
|
||||
agentId: string;
|
||||
appId: string;
|
||||
runId: string;
|
||||
enableGraph: boolean;
|
||||
}
|
||||
|
||||
export interface Mem0Config {
|
||||
version: number;
|
||||
defaults: DefaultsConfig;
|
||||
platform: PlatformConfig;
|
||||
}
|
||||
|
||||
export function createDefaultConfig(): Mem0Config {
|
||||
return {
|
||||
version: CONFIG_VERSION,
|
||||
defaults: {
|
||||
userId: "",
|
||||
agentId: "",
|
||||
appId: "",
|
||||
runId: "",
|
||||
enableGraph: false,
|
||||
},
|
||||
platform: {
|
||||
apiKey: "",
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureConfigDir(): string {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
|
||||
export function loadConfig(): Mem0Config {
|
||||
const config = createDefaultConfig();
|
||||
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
config.version = data.version ?? CONFIG_VERSION;
|
||||
|
||||
const plat = data.platform ?? {};
|
||||
config.platform.apiKey = plat.api_key ?? "";
|
||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||
|
||||
const defaults = data.defaults ?? {};
|
||||
config.defaults.userId = defaults.user_id ?? "";
|
||||
config.defaults.agentId = defaults.agent_id ?? "";
|
||||
config.defaults.appId = defaults.app_id ?? "";
|
||||
config.defaults.runId = defaults.run_id ?? "";
|
||||
config.defaults.enableGraph = defaults.enable_graph ?? false;
|
||||
}
|
||||
|
||||
// Environment variable overrides
|
||||
if (process.env.MEM0_API_KEY) config.platform.apiKey = process.env.MEM0_API_KEY;
|
||||
if (process.env.MEM0_BASE_URL) config.platform.baseUrl = process.env.MEM0_BASE_URL;
|
||||
if (process.env.MEM0_USER_ID) config.defaults.userId = process.env.MEM0_USER_ID;
|
||||
if (process.env.MEM0_AGENT_ID) config.defaults.agentId = process.env.MEM0_AGENT_ID;
|
||||
if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID;
|
||||
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
|
||||
if (process.env.MEM0_ENABLE_GRAPH) {
|
||||
config.defaults.enableGraph = ["true", "1", "yes"].includes(
|
||||
process.env.MEM0_ENABLE_GRAPH.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function saveConfig(config: Mem0Config): void {
|
||||
ensureConfigDir();
|
||||
|
||||
const data = {
|
||||
version: config.version,
|
||||
defaults: {
|
||||
user_id: config.defaults.userId,
|
||||
agent_id: config.defaults.agentId,
|
||||
app_id: config.defaults.appId,
|
||||
run_id: config.defaults.runId,
|
||||
enable_graph: config.defaults.enableGraph,
|
||||
},
|
||||
platform: {
|
||||
api_key: config.platform.apiKey,
|
||||
base_url: config.platform.baseUrl,
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
}
|
||||
|
||||
export function redactKey(key: string): string {
|
||||
if (!key) return "(not set)";
|
||||
if (key.length <= 8) return key.slice(0, 2) + "***";
|
||||
return key.slice(0, 4) + "..." + key.slice(-4);
|
||||
}
|
||||
|
||||
/** Key map from dotted config path to the config object fields. */
|
||||
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
||||
"platform.api_key": ["platform", "apiKey"],
|
||||
"platform.base_url": ["platform", "baseUrl"],
|
||||
"defaults.user_id": ["defaults", "userId"],
|
||||
"defaults.agent_id": ["defaults", "agentId"],
|
||||
"defaults.app_id": ["defaults", "appId"],
|
||||
"defaults.run_id": ["defaults", "runId"],
|
||||
"defaults.enable_graph": ["defaults", "enableGraph"],
|
||||
};
|
||||
|
||||
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
|
||||
const mapping = KEY_MAP[dottedKey];
|
||||
if (!mapping) return undefined;
|
||||
const [section, field] = mapping;
|
||||
return (config[section] as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
export function setNestedValue(config: Mem0Config, dottedKey: string, value: string): boolean {
|
||||
const mapping = KEY_MAP[dottedKey];
|
||||
if (!mapping) return false;
|
||||
const [section, field] = mapping;
|
||||
const obj = config[section] as unknown as Record<string, unknown>;
|
||||
|
||||
const current = obj[field];
|
||||
if (typeof current === "boolean") {
|
||||
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
|
||||
} else if (typeof current === "number") {
|
||||
obj[field] = parseInt(value, 10);
|
||||
} else {
|
||||
obj[field] = value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Rich-style help formatter for Commander.js that matches the Python CLI's
|
||||
* Typer + Rich output (rounded box panels, brand purple, grouped options).
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import type { Command, Help, Option, Argument } from "commander";
|
||||
// Colors imported from chalk directly to match Typer/Rich defaults
|
||||
|
||||
// ── Colors (matching Typer/Rich defaults) ────────────────────────────────
|
||||
|
||||
const cyanBold = chalk.cyan.bold; // option flags, command names
|
||||
const greenBold = chalk.green.bold; // switch flags (boolean --force etc)
|
||||
const yellowBold = chalk.yellow.bold; // metavar <value>
|
||||
const yellow = chalk.yellow; // "Usage:" label
|
||||
const bold = chalk.bold; // command name in usage
|
||||
const dim = chalk.dim; // defaults, descriptions
|
||||
const dimBorder = chalk.dim; // panel borders
|
||||
|
||||
// ── Strip ANSI ───────────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
function stripAnsi(str: string): number {
|
||||
return str.replace(ANSI_RE, "").length;
|
||||
}
|
||||
|
||||
// ── Command display order (matches Python CLI) ──────────────────────────
|
||||
|
||||
/** Commands grouped into panels, matching Python CLI's rich_help_panel. */
|
||||
const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [
|
||||
{
|
||||
panel: "Memory",
|
||||
commands: ["add", "search", "get", "list", "update", "delete"],
|
||||
},
|
||||
{
|
||||
panel: "Management",
|
||||
commands: ["init", "status", "import", "help", "entity", "config"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat order derived from COMMAND_GROUPS. */
|
||||
const COMMAND_ORDER: string[] = COMMAND_GROUPS.flatMap((g) => g.commands);
|
||||
|
||||
// ── Option-to-panel mapping (derived from Python's rich_help_panel) ─────
|
||||
|
||||
const OPTION_PANELS: Record<string, Record<string, string>> = {
|
||||
add: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
search: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--top-k": "Search",
|
||||
"--threshold": "Search",
|
||||
"--rerank": "Search",
|
||||
"--keyword": "Search",
|
||||
"--filter": "Search",
|
||||
"--fields": "Search",
|
||||
"--graph": "Search",
|
||||
"--no-graph": "Search",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
get: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
list: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--page": "Pagination",
|
||||
"--page-size": "Pagination",
|
||||
"--category": "Filters",
|
||||
"--after": "Filters",
|
||||
"--before": "Filters",
|
||||
"--graph": "Filters",
|
||||
"--no-graph": "Filters",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
update: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
delete: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--app-id": "Scope",
|
||||
"--run-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
status: {
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
import: {
|
||||
"--user-id": "Scope",
|
||||
"--agent-id": "Scope",
|
||||
"--output": "Output",
|
||||
"--api-key": "Connection",
|
||||
"--base-url": "Connection",
|
||||
},
|
||||
};
|
||||
|
||||
const PANEL_ORDER: string[] = [
|
||||
"Scope",
|
||||
"Search",
|
||||
"Pagination",
|
||||
"Filters",
|
||||
"Output",
|
||||
"Connection",
|
||||
];
|
||||
|
||||
// ── Panel rendering ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a Rich-style ROUNDED box panel.
|
||||
*
|
||||
* ```
|
||||
* ╭─ Title ────────────────────────╮
|
||||
* │ row content padded │
|
||||
* ╰────────────────────────────────╯
|
||||
* ```
|
||||
*/
|
||||
function renderPanel(
|
||||
title: string,
|
||||
rows: string[],
|
||||
width: number,
|
||||
): string {
|
||||
if (rows.length === 0) return "";
|
||||
|
||||
// Inner width is total width minus the two border chars
|
||||
const inner = width - 2;
|
||||
|
||||
// Top border: ╭─ Title ─...─╮
|
||||
const titleStr = ` ${title} `;
|
||||
const fillLen = Math.max(0, inner - 1 - titleStr.length);
|
||||
const topLine =
|
||||
dimBorder("╭─") +
|
||||
dimBorder(titleStr) +
|
||||
dimBorder("─".repeat(fillLen)) +
|
||||
dimBorder("╮");
|
||||
|
||||
// Bottom border: ╰─...─╯
|
||||
const bottomLine = dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
|
||||
|
||||
// Content rows
|
||||
const contentLines = rows.map((row) => {
|
||||
const visLen = stripAnsi(row);
|
||||
const pad = Math.max(0, inner - 1 - visLen);
|
||||
return dimBorder("│") + " " + row + " ".repeat(pad) + dimBorder("│");
|
||||
});
|
||||
|
||||
return [topLine, ...contentLines, bottomLine].join("\n");
|
||||
}
|
||||
|
||||
// ── Format an option term (short + long) ────────────────────────────────
|
||||
|
||||
function formatOptionTerm(opt: Option): string {
|
||||
const parts: string[] = [];
|
||||
if (opt.short) parts.push(opt.short);
|
||||
if (opt.long) parts.push(opt.long);
|
||||
let term = parts.join(", ");
|
||||
|
||||
// Append value placeholder for non-boolean options
|
||||
if (opt.flags) {
|
||||
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
|
||||
if (match) {
|
||||
term += " " + match[0];
|
||||
}
|
||||
}
|
||||
return term;
|
||||
}
|
||||
|
||||
// ── Get the long flag name for panel lookup ─────────────────────────────
|
||||
|
||||
function getLongFlag(opt: Option): string {
|
||||
if (opt.long) return opt.long;
|
||||
return opt.short || "";
|
||||
}
|
||||
|
||||
// ── Format a default value ──────────────────────────────────────────────
|
||||
|
||||
function formatDefault(opt: Option): string {
|
||||
if (opt.defaultValue !== undefined && opt.defaultValue !== false) {
|
||||
return dim(` [default: ${opt.defaultValue}]`);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── The main help formatter ─────────────────────────────────────────────
|
||||
|
||||
export function richFormatHelp(cmd: Command, helper: Help): string {
|
||||
const width = process.stdout.columns || 80;
|
||||
const lines: string[] = [];
|
||||
|
||||
const isRoot = !cmd.parent;
|
||||
|
||||
// ── Usage line ──
|
||||
const usage = helper.commandUsage(cmd);
|
||||
lines.push("");
|
||||
if (isRoot) {
|
||||
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
|
||||
lines.push(` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`);
|
||||
} else {
|
||||
// Subcommands: split into command path (bold) and args (yellow)
|
||||
const usageParts = usage.split(" ");
|
||||
const cmdPath: string[] = [];
|
||||
const argParts: string[] = [];
|
||||
let pastCmd = false;
|
||||
for (const part of usageParts) {
|
||||
if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) {
|
||||
cmdPath.push(part);
|
||||
} else {
|
||||
pastCmd = true;
|
||||
argParts.push(part);
|
||||
}
|
||||
}
|
||||
lines.push(` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// ── Description ──
|
||||
const desc = helper.commandDescription(cmd);
|
||||
if (desc) {
|
||||
// Split multi-line descriptions (e.g., title + tagline)
|
||||
const descLines = desc.split("\n");
|
||||
for (let i = 0; i < descLines.length; i++) {
|
||||
const dLine = descLines[i];
|
||||
// First line is the title, subsequent non-empty lines are tagline (dimmed)
|
||||
if (i === 0 || dLine.trim() === "") {
|
||||
lines.push(` ${dLine}`);
|
||||
} else {
|
||||
lines.push(` ${dim(dLine)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Arguments panel (subcommands only) ──
|
||||
if (!isRoot) {
|
||||
const visibleArgs = helper.visibleArguments(cmd);
|
||||
if (visibleArgs.length > 0) {
|
||||
const maxLen = Math.max(...visibleArgs.map((a: Argument) => a.name().length));
|
||||
const argRows = visibleArgs.map((a: Argument) => {
|
||||
const name = cyanBold(a.name().padEnd(maxLen));
|
||||
const description = helper.argumentDescription(a);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel("Arguments", argRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect options (grouped into panels for subcommands) ──
|
||||
const visibleOpts = helper.visibleOptions(cmd);
|
||||
const cmdName = cmd.name();
|
||||
const panelMap = (!isRoot && OPTION_PANELS[cmdName]) ? OPTION_PANELS[cmdName] : {};
|
||||
|
||||
const grouped: Record<string, Option[]> = { Options: [] };
|
||||
for (const panelName of PANEL_ORDER) {
|
||||
grouped[panelName] = [];
|
||||
}
|
||||
|
||||
for (const opt of visibleOpts) {
|
||||
const flag = getLongFlag(opt);
|
||||
const panel = panelMap[flag];
|
||||
if (panel && PANEL_ORDER.includes(panel)) {
|
||||
grouped[panel].push(opt);
|
||||
} else {
|
||||
grouped["Options"].push(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect commands ──
|
||||
const visibleCmds = helper.visibleCommands(cmd);
|
||||
|
||||
if (isRoot) {
|
||||
// ROOT: Options first, then command groups (matches Python/Typer ordering)
|
||||
if (grouped["Options"].length > 0) {
|
||||
const optRows = formatOptionRows(grouped["Options"]);
|
||||
const panel = renderPanel("Options", optRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
if (visibleCmds.length > 0) {
|
||||
const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c]));
|
||||
for (const group of COMMAND_GROUPS) {
|
||||
const groupCmds = group.commands
|
||||
.map((name) => cmdMap.get(name))
|
||||
.filter((c): c is Command => c !== undefined);
|
||||
if (groupCmds.length === 0) continue;
|
||||
const maxLen = Math.max(...groupCmds.map((c) => c.name().length));
|
||||
const cmdRows = groupCmds.map((c) => {
|
||||
const name = cyanBold(c.name().padEnd(maxLen));
|
||||
const description = helper.subcommandDescription(c);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel(group.panel, cmdRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// SUBCOMMANDS: Options/panels first, then sub-subcommands
|
||||
const panelSequence = ["Options", ...PANEL_ORDER];
|
||||
for (const panelName of panelSequence) {
|
||||
const opts = grouped[panelName];
|
||||
if (opts && opts.length > 0) {
|
||||
const optRows = formatOptionRows(opts);
|
||||
const panel = renderPanel(panelName, optRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
// Sub-subcommands (e.g., config show/get/set, entity list/delete)
|
||||
if (visibleCmds.length > 0) {
|
||||
const maxLen = Math.max(...visibleCmds.map((c) => c.name().length));
|
||||
const cmdRows = visibleCmds.map((c) => {
|
||||
const name = cyanBold(c.name().padEnd(maxLen));
|
||||
const description = helper.subcommandDescription(c);
|
||||
return ` ${name} ${description}`;
|
||||
});
|
||||
const panel = renderPanel("Commands", cmdRows, width);
|
||||
if (panel) lines.push(panel);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Format option rows with aligned columns ─────────────────────────────
|
||||
|
||||
function formatOptionRows(opts: Option[]): string[] {
|
||||
const terms = opts.map((o) => formatOptionTerm(o));
|
||||
const maxTermLen = Math.max(...terms.map((t) => t.length));
|
||||
|
||||
return opts.map((opt, i) => {
|
||||
const term = cyanBold(terms[i].padEnd(maxTermLen));
|
||||
const desc = opt.description || "";
|
||||
const def = formatDefault(opt);
|
||||
return ` ${term} ${desc}${def}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort commands by COMMAND_ORDER ──────────────────────────────────────
|
||||
|
||||
function sortCommands(cmds: Command[]): Command[] {
|
||||
return [...cmds].sort((a, b) => {
|
||||
const ai = COMMAND_ORDER.indexOf(a.name());
|
||||
const bi = COMMAND_ORDER.indexOf(b.name());
|
||||
// Unknown commands go to end, preserving original order
|
||||
const aIdx = ai === -1 ? COMMAND_ORDER.length : ai;
|
||||
const bIdx = bi === -1 ? COMMAND_ORDER.length : bi;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Main CLI application — the entrypoint for `mem0`.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import { Command } from "commander";
|
||||
import type { Mem0Config } from "./config.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { getBackend, type Backend } from "./backend/index.js";
|
||||
import { printError, colors } from "./branding.js";
|
||||
import { richFormatHelp } from "./help.js";
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const VERSION: string = _require("../package.json").version;
|
||||
|
||||
const program = new Command();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getBackendAndConfig(
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
): { backend: Backend; config: Mem0Config } {
|
||||
const config = loadConfig();
|
||||
|
||||
if (apiKey) config.platform.apiKey = apiKey;
|
||||
if (baseUrl) config.platform.baseUrl = baseUrl;
|
||||
|
||||
if (!config.platform.apiKey) {
|
||||
printError(
|
||||
"No API key configured.",
|
||||
"Run 'mem0 init' or set MEM0_API_KEY environment variable.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { backend: getBackend(config), config };
|
||||
}
|
||||
|
||||
function getBackendOnly(apiKey?: string, baseUrl?: string): Backend {
|
||||
return getBackendAndConfig(apiKey, baseUrl).backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve entity IDs: CLI flag > config default > undefined.
|
||||
*
|
||||
* If any explicit ID is provided, only use explicit IDs (don't mix
|
||||
* in defaults for other entity types which would over-filter).
|
||||
* If no explicit IDs, fall back to all configured defaults.
|
||||
*/
|
||||
function resolveIds(
|
||||
config: Mem0Config,
|
||||
opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
},
|
||||
): { userId?: string; agentId?: string; appId?: string; runId?: string } {
|
||||
const hasExplicit = !!(opts.userId || opts.agentId || opts.appId || opts.runId);
|
||||
if (hasExplicit) {
|
||||
return {
|
||||
userId: opts.userId || undefined,
|
||||
agentId: opts.agentId || undefined,
|
||||
appId: opts.appId || undefined,
|
||||
runId: opts.runId || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
userId: config.defaults.userId || undefined,
|
||||
agentId: config.defaults.agentId || undefined,
|
||||
appId: config.defaults.appId || undefined,
|
||||
runId: config.defaults.runId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve graph tri-state: --no-graph > --graph > config default.
|
||||
*/
|
||||
function resolveGraph(
|
||||
config: Mem0Config,
|
||||
opts: { graph?: boolean; noGraph?: boolean },
|
||||
): boolean {
|
||||
if (opts.noGraph) return false;
|
||||
if (opts.graph) return true;
|
||||
return config.defaults.enableGraph;
|
||||
}
|
||||
|
||||
// ── Main program ──────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.name("mem0")
|
||||
.description(`◆ Mem0 CLI v${VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`)
|
||||
.option("--version", "Show version and exit.")
|
||||
.on("option:version", () => {
|
||||
console.log(` ${colors.brand("◆ Mem0")} CLI v${VERSION}`);
|
||||
process.exit(0);
|
||||
})
|
||||
.usage("<command> [options]")
|
||||
.helpOption("--help", "Show this message and exit.")
|
||||
.addHelpCommand(false)
|
||||
.configureHelp({ formatHelp: richFormatHelp });
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("init")
|
||||
.description("Interactive setup wizard for mem0 CLI.")
|
||||
.option("--api-key <key>", "API key (skip prompt).")
|
||||
.option("-u, --user-id <id>", "Default user ID (skip prompt).")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice")
|
||||
.action(async (opts) => {
|
||||
const { runInit } = await import("./commands/init.js");
|
||||
await runInit({ apiKey: opts.apiKey, userId: opts.userId });
|
||||
});
|
||||
|
||||
// ── Memory: add ───────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("add [text]")
|
||||
.description("Add a memory from text, messages, file, or stdin.")
|
||||
.option("-u, --user-id <id>", "Scope to user.")
|
||||
.option("--agent-id <id>", "Scope to agent.")
|
||||
.option("--app-id <id>", "Scope to app.")
|
||||
.option("--run-id <id>", "Scope to run.")
|
||||
.option("--messages <json>", "Conversation messages as JSON.")
|
||||
.option("-f, --file <path>", "Read messages from JSON file.")
|
||||
.option("-m, --metadata <json>", "Custom metadata as JSON.")
|
||||
.option("--immutable", "Prevent future updates.", false)
|
||||
.option("--no-infer", "Skip inference, store raw.")
|
||||
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
|
||||
.option("--categories <value>", "Categories (JSON array or comma-separated).")
|
||||
.option("--graph", "Enable graph memory extraction.", false)
|
||||
.option("--no-graph", "Disable graph memory extraction.")
|
||||
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", '\nExamples:\n $ mem0 add "I prefer dark mode" --user-id alice\n $ echo "text" | mem0 add -u alice\n $ mem0 add --file msgs.json -u alice -o json')
|
||||
.action(async (text, opts) => {
|
||||
const { cmdAdd } = await import("./commands/memory.js");
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const ids = resolveIds(config, opts);
|
||||
const enableGraph = resolveGraph(config, opts);
|
||||
await cmdAdd(backend, text, { ...ids, ...opts, enableGraph });
|
||||
});
|
||||
|
||||
// ── Memory: search ────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("search [query]")
|
||||
.description("Search memories by semantic query.")
|
||||
.option("-u, --user-id <id>", "Filter by user.")
|
||||
.option("--agent-id <id>", "Filter by agent.")
|
||||
.option("--app-id <id>", "Filter by app.")
|
||||
.option("--run-id <id>", "Filter by run.")
|
||||
.option("-k, --top-k <n>", "Number of results.", (v) => parseInt(v), 10)
|
||||
.option("--threshold <n>", "Minimum similarity score.", (v) => parseFloat(v), 0.3)
|
||||
.option("--rerank", "Enable reranking (Platform only).", false)
|
||||
.option("--keyword", "Use keyword search.", false)
|
||||
.option("--filter <json>", "Advanced filter expression (JSON).")
|
||||
.option("--fields <list>", "Specific fields to return (comma-separated).")
|
||||
.option("--graph", "Enable graph in search.", false)
|
||||
.option("--no-graph", "Disable graph in search.")
|
||||
.option("-o, --output <format>", "Output: text, json, table.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", '\nExamples:\n $ mem0 search "preferences" --user-id alice\n $ mem0 search "tools" -u alice -o json -k 5\n $ echo "preferences" | mem0 search -u alice')
|
||||
.action(async (query, opts) => {
|
||||
let resolvedQuery = query;
|
||||
if (!resolvedQuery && !process.stdin.isTTY) {
|
||||
resolvedQuery = fs.readFileSync(0, "utf-8").trim();
|
||||
}
|
||||
if (!resolvedQuery) {
|
||||
printError("No query provided. Pass a query argument or pipe via stdin.");
|
||||
process.exit(1);
|
||||
}
|
||||
const { cmdSearch } = await import("./commands/memory.js");
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const ids = resolveIds(config, opts);
|
||||
const enableGraph = resolveGraph(config, opts);
|
||||
await cmdSearch(backend, resolvedQuery, {
|
||||
...ids,
|
||||
topK: opts.topK,
|
||||
threshold: opts.threshold,
|
||||
rerank: opts.rerank,
|
||||
keyword: opts.keyword,
|
||||
filterJson: opts.filter,
|
||||
fields: opts.fields,
|
||||
enableGraph,
|
||||
output: opts.output,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memory: get ───────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("get <memoryId>")
|
||||
.description("Get a specific memory by ID.")
|
||||
.option("-o, --output <format>", "Output: text, json.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 get abc-123-def-456\n $ mem0 get abc-123-def-456 -o json")
|
||||
.action(async (memoryId, opts) => {
|
||||
const { cmdGet } = await import("./commands/memory.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdGet(backend, memoryId, { output: opts.output });
|
||||
});
|
||||
|
||||
// ── Memory: list ──────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("list")
|
||||
.description("List memories with optional filters.")
|
||||
.option("-u, --user-id <id>", "Filter by user.")
|
||||
.option("--agent-id <id>", "Filter by agent.")
|
||||
.option("--app-id <id>", "Filter by app.")
|
||||
.option("--run-id <id>", "Filter by run.")
|
||||
.option("--page <n>", "Page number.", (v) => parseInt(v), 1)
|
||||
.option("--page-size <n>", "Results per page.", (v) => parseInt(v), 100)
|
||||
.option("--category <name>", "Filter by category.")
|
||||
.option("--after <date>", "Created after (YYYY-MM-DD).")
|
||||
.option("--before <date>", "Created before (YYYY-MM-DD).")
|
||||
.option("--graph", "Enable graph in listing.", false)
|
||||
.option("--no-graph", "Disable graph in listing.")
|
||||
.option("-o, --output <format>", "Output: text, json, table.", "table")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 list -u alice\n $ mem0 list --category prefs --after 2024-01-01 -o json")
|
||||
.action(async (opts) => {
|
||||
const { cmdList } = await import("./commands/memory.js");
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const ids = resolveIds(config, opts);
|
||||
const enableGraph = resolveGraph(config, opts);
|
||||
await cmdList(backend, {
|
||||
...ids,
|
||||
page: opts.page,
|
||||
pageSize: opts.pageSize,
|
||||
category: opts.category,
|
||||
after: opts.after,
|
||||
before: opts.before,
|
||||
enableGraph,
|
||||
output: opts.output,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memory: update ────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("update <memoryId> [text]")
|
||||
.description("Update a memory's text or metadata.")
|
||||
.option("-m, --metadata <json>", "Update metadata (JSON).")
|
||||
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", `\nExamples:\n $ mem0 update abc-123 "new text"\n $ mem0 update abc-123 --metadata '{"key":"val"}'\n $ echo "new text" | mem0 update abc-123`)
|
||||
.action(async (memoryId, text, opts) => {
|
||||
let resolvedText = text;
|
||||
if (!resolvedText && !opts.metadata && !process.stdin.isTTY) {
|
||||
resolvedText = fs.readFileSync(0, "utf-8").trim();
|
||||
}
|
||||
const { cmdUpdate } = await import("./commands/memory.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdUpdate(backend, memoryId, resolvedText, { metadata: opts.metadata, output: opts.output });
|
||||
});
|
||||
|
||||
// ── Memory: delete (consolidated) ─────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("delete [memoryId]")
|
||||
.description("Delete a memory, all memories matching a scope, or an entity.")
|
||||
.option("--all", "Delete all memories matching scope filters.", false)
|
||||
.option("--entity", "Delete the entity itself and all its memories (cascade).", false)
|
||||
.option("--project", "With --all: delete ALL memories project-wide.", false)
|
||||
.option("--dry-run", "Show what would be deleted without deleting.", false)
|
||||
.option("--force", "Skip confirmation.", false)
|
||||
.option("-u, --user-id <id>", "Scope to user.")
|
||||
.option("--agent-id <id>", "Scope to agent.")
|
||||
.option("--app-id <id>", "Scope to app.")
|
||||
.option("--run-id <id>", "Scope to run.")
|
||||
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", [
|
||||
"\nExamples:",
|
||||
" $ mem0 delete abc-123-def-456 # single memory",
|
||||
" $ mem0 delete --all -u alice --force # all memories for user",
|
||||
" $ mem0 delete --all --project --force # project-wide wipe",
|
||||
" $ mem0 delete --entity -u alice --force # entity + all its memories",
|
||||
].join("\n"))
|
||||
.action(async (memoryId, opts) => {
|
||||
// ── Mutual-exclusion checks ──
|
||||
if (memoryId && opts.all) {
|
||||
printError("Cannot combine <memoryId> with --all. Use one or the other.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (memoryId && opts.entity) {
|
||||
printError("Cannot combine <memoryId> with --entity. Use one or the other.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (opts.all && opts.entity) {
|
||||
printError("Cannot combine --all with --entity. Use one or the other.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!memoryId && !opts.all && !opts.entity) {
|
||||
printError(
|
||||
"Specify a memory ID, --all, or --entity.\n" +
|
||||
" mem0 delete <id> Delete a single memory\n" +
|
||||
" mem0 delete --all [scope] Delete all memories matching scope\n" +
|
||||
" mem0 delete --entity [scope] Delete an entity and all its memories",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Dispatch: single memory ──
|
||||
if (memoryId) {
|
||||
const { cmdDelete } = await import("./commands/memory.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdDelete(backend, memoryId, { output: opts.output, dryRun: opts.dryRun, force: opts.force });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Dispatch: --all ──
|
||||
if (opts.all) {
|
||||
const { cmdDeleteAll } = await import("./commands/memory.js");
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const ids = opts.project
|
||||
? { userId: undefined, agentId: undefined, appId: undefined, runId: undefined }
|
||||
: resolveIds(config, opts);
|
||||
await cmdDeleteAll(backend, { force: opts.force, dryRun: opts.dryRun, all: opts.project, ...ids, output: opts.output });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Dispatch: --entity ──
|
||||
if (opts.entity) {
|
||||
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdEntitiesDelete(backend, opts);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Config subcommands ────────────────────────────────────────────────────
|
||||
|
||||
const configCmd = program
|
||||
.command("config")
|
||||
.description("Manage mem0 configuration.")
|
||||
.addHelpCommand(false);
|
||||
|
||||
configCmd
|
||||
.command("show")
|
||||
.description("Display current configuration (secrets redacted).")
|
||||
.option("-o, --output <format>", "Output: text, json.", "text")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 config show\n $ mem0 config show -o json")
|
||||
.action(async (opts) => {
|
||||
const { cmdConfigShow } = await import("./commands/config.js");
|
||||
cmdConfigShow({ output: opts.output });
|
||||
});
|
||||
|
||||
configCmd
|
||||
.command("get <key>")
|
||||
.description("Get a configuration value.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 config get platform.api_key\n $ mem0 config get defaults.user_id")
|
||||
.action(async (key) => {
|
||||
const { cmdConfigGet } = await import("./commands/config.js");
|
||||
cmdConfigGet(key);
|
||||
});
|
||||
|
||||
configCmd
|
||||
.command("set <key> <value>")
|
||||
.description("Set a configuration value.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 config set defaults.user_id alice\n $ mem0 config set platform.base_url https://api.mem0.ai")
|
||||
.action(async (key, value) => {
|
||||
const { cmdConfigSet } = await import("./commands/config.js");
|
||||
cmdConfigSet(key, value);
|
||||
});
|
||||
|
||||
// ── Entity subcommand group ───────────────────────────────────────────────
|
||||
|
||||
const entityCmd = program
|
||||
.command("entity")
|
||||
.description("Manage entities.")
|
||||
.addHelpCommand(false)
|
||||
.configureHelp({ formatHelp: richFormatHelp });
|
||||
|
||||
entityCmd
|
||||
.command("list <entityType>")
|
||||
.description("List all entities of a given type.")
|
||||
.option("-o, --output <format>", "Output: table, json.", "table")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 entity list users\n $ mem0 entity list agents -o json")
|
||||
.action(async (entityType, opts) => {
|
||||
const { cmdEntitiesList } = await import("./commands/entities.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdEntitiesList(backend, entityType, { output: opts.output });
|
||||
});
|
||||
|
||||
entityCmd
|
||||
.command("delete")
|
||||
.description("Delete an entity and ALL its memories (cascade).")
|
||||
.option("--dry-run", "Show what would be deleted without deleting.", false)
|
||||
.option("-u, --user-id <id>", "Scope to user.")
|
||||
.option("--agent-id <id>", "Scope to agent.")
|
||||
.option("--app-id <id>", "Scope to app.")
|
||||
.option("--run-id <id>", "Scope to run.")
|
||||
.option("--force", "Skip confirmation.", false)
|
||||
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 entity delete --user-id alice --force\n $ mem0 entity delete --user-id alice --dry-run")
|
||||
.action(async (opts) => {
|
||||
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdEntitiesDelete(backend, opts);
|
||||
});
|
||||
|
||||
// ── Utility commands ──────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Check connectivity and authentication.")
|
||||
.option("-o, --output <format>", "Output: text, json.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 status\n $ mem0 status -o json")
|
||||
.action(async (opts) => {
|
||||
const { cmdStatus } = await import("./commands/utils.js");
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
await cmdStatus(backend, {
|
||||
userId: config.defaults.userId || undefined,
|
||||
agentId: config.defaults.agentId || undefined,
|
||||
output: opts.output,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
program
|
||||
.command("import <filePath>")
|
||||
.description("Import memories from a JSON file.")
|
||||
.option("-u, --user-id <id>", "Override user ID.")
|
||||
.option("--agent-id <id>", "Override agent ID.")
|
||||
.option("-o, --output <format>", "Output: text, json.", "text")
|
||||
.option("--api-key <key>", "Override API key.")
|
||||
.option("--base-url <url>", "Override API base URL.")
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 import data.json --user-id alice\n $ mem0 import data.json -u alice -o json")
|
||||
.action(async (filePath, opts) => {
|
||||
const { cmdImport } = await import("./commands/utils.js");
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const ids = resolveIds(config, opts);
|
||||
await cmdImport(backend, filePath, { userId: ids.userId, agentId: ids.agentId, output: opts.output });
|
||||
});
|
||||
|
||||
// ── Help (machine-readable) ──────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("help")
|
||||
.description("Show help. Use --json for machine-readable output (for LLM agents).")
|
||||
.option("--json", "Output machine-readable JSON for LLM agents.", false)
|
||||
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
|
||||
.action((opts) => {
|
||||
if (opts.json) {
|
||||
// Load spec from parent directory
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
|
||||
if (fs.existsSync(specPath)) {
|
||||
const spec = JSON.parse(fs.readFileSync(specPath, "utf-8"));
|
||||
console.log(JSON.stringify(spec, null, 2));
|
||||
} else {
|
||||
console.log(JSON.stringify({ name: "mem0", version: VERSION, description: "The Memory Layer for AI Agents" }, null, 2));
|
||||
}
|
||||
} else {
|
||||
const { brand: b } = colors;
|
||||
console.log(`${b("◆ Mem0 CLI")} v${VERSION} · Node.js SDK\n The Memory Layer for AI Agents\n`);
|
||||
console.log("Usage: mem0 <command> [OPTIONS]\n");
|
||||
console.log("Commands:");
|
||||
console.log(" add Add a memory from text, messages, file, or stdin");
|
||||
console.log(" search Search memories by semantic query");
|
||||
console.log(" get Get a specific memory by ID");
|
||||
console.log(" list List memories with optional filters");
|
||||
console.log(" update Update a memory's text or metadata");
|
||||
console.log(" delete Delete a memory, all memories, or an entity");
|
||||
console.log(" import Import memories from a JSON file");
|
||||
console.log(" config Manage configuration (show, get, set)");
|
||||
console.log(" entity Manage entities (list, delete)");
|
||||
console.log(" init Interactive setup wizard");
|
||||
console.log(" status Check connectivity and authentication");
|
||||
console.log();
|
||||
console.log(" mem0 <command> --help Get help for a command");
|
||||
console.log(" mem0 help --json Machine-readable help (for LLM agents)");
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Entrypoint ────────────────────────────────────────────────────────────
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Output formatting for mem0 CLI — text, JSON, table, quiet modes.
|
||||
*/
|
||||
|
||||
import Table from "cli-table3";
|
||||
import boxen from "boxen";
|
||||
import { colors, sym } from "./branding.js";
|
||||
|
||||
const { brand, accent, success, error: errorColor, dim } = colors;
|
||||
|
||||
function formatDate(dtStr?: string): string | undefined {
|
||||
if (!dtStr) return undefined;
|
||||
try {
|
||||
const dt = new Date(dtStr.replace("Z", "+00:00"));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return dtStr?.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMemoriesText(
|
||||
memories: Record<string, unknown>[],
|
||||
title = "memories",
|
||||
): void {
|
||||
const count = memories.length;
|
||||
console.log(`\n${brand(`Found ${count} ${title}:`)}\n`);
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const mem = memories[i];
|
||||
const memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
const memId = ((mem.id as string) ?? "").slice(0, 8);
|
||||
const score = mem.score as number | undefined;
|
||||
const created = formatDate(mem.created_at as string | undefined);
|
||||
let category: string | undefined;
|
||||
const cats = mem.categories;
|
||||
if (Array.isArray(cats)) {
|
||||
category = cats[0] as string | undefined;
|
||||
}
|
||||
|
||||
console.log(` ${i + 1}. ${memoryText}`);
|
||||
|
||||
const details: string[] = [];
|
||||
if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`);
|
||||
if (memId) details.push(`ID: ${memId}`);
|
||||
if (created) details.push(`Created: ${created}`);
|
||||
if (category) details.push(`Category: ${category}`);
|
||||
|
||||
if (details.length > 0) {
|
||||
console.log(` ${dim(details.join(" · "))}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMemoriesTable(memories: Record<string, unknown>[]): void {
|
||||
const table = new Table({
|
||||
head: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")],
|
||||
colWidths: [12, 52, 16, 14],
|
||||
wordWrap: true,
|
||||
style: { head: [], border: [] },
|
||||
});
|
||||
|
||||
for (const mem of memories) {
|
||||
const memId = ((mem.id as string) ?? "").slice(0, 8);
|
||||
let memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
if (memoryText.length > 60) {
|
||||
memoryText = memoryText.slice(0, 57) + "...";
|
||||
}
|
||||
const categories = mem.categories;
|
||||
const cat =
|
||||
Array.isArray(categories) && categories.length > 0
|
||||
? (categories[0] as string)
|
||||
: "—";
|
||||
const created = formatDate(mem.created_at as string | undefined) ?? "—";
|
||||
table.push([dim(memId), memoryText, cat, created]);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function formatJson(data: unknown): void {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function formatSingleMemory(
|
||||
mem: Record<string, unknown>,
|
||||
output = "text",
|
||||
): void {
|
||||
if (output === "json") {
|
||||
formatJson(mem);
|
||||
return;
|
||||
}
|
||||
|
||||
const memoryText = (mem.memory ?? mem.text ?? "") as string;
|
||||
const memId = (mem.id ?? "") as string;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${memoryText}`);
|
||||
lines.push("");
|
||||
|
||||
if (memId) lines.push(` ${dim("ID:")} ${memId}`);
|
||||
const created = formatDate(mem.created_at as string | undefined);
|
||||
if (created) lines.push(` ${dim("Created:")} ${created}`);
|
||||
const updated = formatDate(mem.updated_at as string | undefined);
|
||||
if (updated) lines.push(` ${dim("Updated:")} ${updated}`);
|
||||
const meta = mem.metadata;
|
||||
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
|
||||
const categories = mem.categories;
|
||||
if (categories) {
|
||||
const catStr = Array.isArray(categories) ? categories.join(", ") : String(categories);
|
||||
lines.push(` ${dim("Categories:")} ${catStr}`);
|
||||
}
|
||||
|
||||
const content = lines.join("\n");
|
||||
console.log();
|
||||
console.log(
|
||||
boxen(content, {
|
||||
title: brand("Memory"),
|
||||
titleAlignment: "left",
|
||||
borderColor: "magenta",
|
||||
padding: 1,
|
||||
}),
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function formatAddResult(
|
||||
result: Record<string, unknown> | Record<string, unknown>[],
|
||||
output = "text",
|
||||
): void {
|
||||
if (output === "json") {
|
||||
formatJson(result);
|
||||
return;
|
||||
}
|
||||
if (output === "quiet") return;
|
||||
|
||||
const results: Record<string, unknown>[] = Array.isArray(result)
|
||||
? result
|
||||
: ((result.results as Record<string, unknown>[]) ?? [result]);
|
||||
|
||||
if (!results.length) {
|
||||
console.log(` ${dim("No memories extracted.")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const r of results) {
|
||||
// Detect async PENDING response
|
||||
if (r.status === "PENDING") {
|
||||
const eventId = ((r.event_id as string) ?? "").slice(0, 8);
|
||||
const icon = accent(sym("⧗", "..."));
|
||||
const parts = [` ${icon} ${dim("Queued".padEnd(10))}`, "Processing in background"];
|
||||
if (eventId) parts.push(dim(`(event ${eventId})`));
|
||||
console.log(parts.join(" "));
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = (r.event ?? "ADD") as string;
|
||||
const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") as string;
|
||||
const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(0, 8);
|
||||
|
||||
let icon: string;
|
||||
let label: string;
|
||||
if (event === "ADD") {
|
||||
icon = success("+");
|
||||
label = "Added";
|
||||
} else if (event === "UPDATE") {
|
||||
icon = accent("~");
|
||||
label = "Updated";
|
||||
} else if (event === "DELETE") {
|
||||
icon = errorColor("-");
|
||||
label = "Deleted";
|
||||
} else if (event === "NOOP") {
|
||||
icon = dim("·");
|
||||
label = "No change";
|
||||
} else {
|
||||
icon = dim("?");
|
||||
label = event;
|
||||
}
|
||||
|
||||
const parts = [` ${icon} ${dim(label.padEnd(10))}`];
|
||||
if (memory) parts.push(memory);
|
||||
if (memId) parts.push(dim(`(${memId})`));
|
||||
console.log(parts.join(" "));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function formatJsonEnvelope(opts: {
|
||||
command: string;
|
||||
data: unknown;
|
||||
durationMs?: number;
|
||||
scope?: Record<string, string | undefined>;
|
||||
count?: number;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}): void {
|
||||
const envelope: Record<string, unknown> = {
|
||||
status: opts.status ?? "success",
|
||||
command: opts.command,
|
||||
};
|
||||
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
|
||||
if (opts.scope !== undefined) envelope.scope = opts.scope;
|
||||
if (opts.count !== undefined) envelope.count = opts.count;
|
||||
if (opts.error) envelope.error = opts.error;
|
||||
envelope.data = opts.data;
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
}
|
||||
|
||||
export function printResultSummary(opts: {
|
||||
count: number;
|
||||
durationSecs?: number;
|
||||
page?: number;
|
||||
scopeIds?: Record<string, string | undefined>;
|
||||
}): void {
|
||||
const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`];
|
||||
if (opts.page !== undefined) parts.push(`page ${opts.page}`);
|
||||
if (opts.scopeIds) {
|
||||
const scopeParts = Object.entries(opts.scopeIds)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`);
|
||||
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
|
||||
}
|
||||
if (opts.durationSecs !== undefined) parts.push(`${opts.durationSecs.toFixed(2)}s`);
|
||||
|
||||
console.log(` ${dim(parts.join(" · "))}`);
|
||||
console.log();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tests for branding utilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
BRAND_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
ERROR_COLOR,
|
||||
TAGLINE,
|
||||
LOGO_MINI,
|
||||
printSuccess,
|
||||
printError,
|
||||
printWarning,
|
||||
printInfo,
|
||||
printScope,
|
||||
} from "../src/branding.js";
|
||||
|
||||
let output: string;
|
||||
let errOutput: string;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errOutput = "";
|
||||
console.log = (...args: unknown[]) => {
|
||||
output += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errOutput += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
describe("branding constants", () => {
|
||||
it("has correct brand color", () => {
|
||||
expect(BRAND_COLOR).toBe("#8b5cf6");
|
||||
});
|
||||
|
||||
it("has correct tagline", () => {
|
||||
expect(TAGLINE).toBe("The Memory Layer for AI Agents");
|
||||
});
|
||||
|
||||
it("has correct logo mini", () => {
|
||||
expect(LOGO_MINI).toBe("◆ mem0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printSuccess", () => {
|
||||
it("prints success message", () => {
|
||||
printSuccess("Operation completed");
|
||||
expect(output).toContain("Operation completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printError", () => {
|
||||
it("prints error message to stderr", () => {
|
||||
printError("Something failed");
|
||||
expect(errOutput).toContain("Something failed");
|
||||
});
|
||||
|
||||
it("prints hint when provided to stderr", () => {
|
||||
printError("Failed", "Try again");
|
||||
expect(errOutput).toContain("Try again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printWarning", () => {
|
||||
it("prints warning message to stderr", () => {
|
||||
printWarning("Be careful");
|
||||
expect(errOutput).toContain("Be careful");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printInfo", () => {
|
||||
it("prints info message", () => {
|
||||
printInfo("Important note");
|
||||
expect(output).toContain("Important note");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printScope", () => {
|
||||
it("prints scope when IDs present", () => {
|
||||
printScope({ user_id: "alice", agent_id: "bot" });
|
||||
expect(output).toContain("alice");
|
||||
expect(output).toContain("bot");
|
||||
});
|
||||
|
||||
it("prints nothing when no IDs", () => {
|
||||
printScope({});
|
||||
expect(output).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Integration tests — invoke CLI as subprocess to test end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
opts: { home?: string; env?: Record<string, string> } = {},
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const env = { ...process.env };
|
||||
// Strip MEM0_ env vars
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("MEM0_")) delete env[key];
|
||||
}
|
||||
if (opts.home) env.HOME = opts.home;
|
||||
if (opts.env) Object.assign(env, opts.env);
|
||||
|
||||
try {
|
||||
const stdout = execSync(
|
||||
`npx tsx src/index.ts ${args.join(" ")}`,
|
||||
{ cwd: path.join(__dirname, ".."), env, encoding: "utf-8", timeout: 15000 },
|
||||
);
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (e: any) {
|
||||
return {
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? "",
|
||||
exitCode: e.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("CLI Integration — help and version", () => {
|
||||
it("shows help with --help", () => {
|
||||
const result = run(["--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("mem0");
|
||||
expect(result.stdout).toContain("add");
|
||||
expect(result.stdout).toContain("search");
|
||||
});
|
||||
|
||||
it("shows version with --version", () => {
|
||||
const result = run(["--version"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("0.1.0");
|
||||
});
|
||||
|
||||
|
||||
it("help --json produces valid JSON", () => {
|
||||
const result = run(["help", "--json"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
// spec may have cli.name or top-level name
|
||||
const name = parsed.name ?? parsed.cli?.name;
|
||||
expect(name).toBe("mem0");
|
||||
});
|
||||
|
||||
it("shows add help", () => {
|
||||
const result = run(["add", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("user-id");
|
||||
expect(result.stdout).toContain("messages");
|
||||
});
|
||||
|
||||
it("shows search help", () => {
|
||||
const result = run(["search", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("top-k");
|
||||
});
|
||||
|
||||
it("shows list help", () => {
|
||||
const result = run(["list", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("page-size");
|
||||
});
|
||||
|
||||
it("shows delete help with --all, --entity, --project", () => {
|
||||
const result = run(["delete", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--all");
|
||||
expect(result.stdout).toContain("--entity");
|
||||
expect(result.stdout).toContain("--project");
|
||||
expect(result.stdout).toContain("--force");
|
||||
expect(result.stdout.toLowerCase()).toContain("memory");
|
||||
});
|
||||
|
||||
it("delete with no args errors", () => {
|
||||
const result = run(["delete"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined).toContain("--all");
|
||||
});
|
||||
|
||||
it("shows entity list help", () => {
|
||||
const result = run(["entity", "list", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.toLowerCase()).toContain("entitytype");
|
||||
});
|
||||
|
||||
it("shows entity delete help", () => {
|
||||
const result = run(["entity", "delete", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--user-id");
|
||||
expect(result.stdout).toContain("--force");
|
||||
});
|
||||
|
||||
it("shows import help", () => {
|
||||
const result = run(["import", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("add help has --graph flag", () => {
|
||||
const result = run(["add", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--graph");
|
||||
});
|
||||
|
||||
it("search help has --graph flag", () => {
|
||||
const result = run(["search", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--graph");
|
||||
});
|
||||
|
||||
it("list help has --graph flag", () => {
|
||||
const result = run(["list", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("--graph");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Integration — isolated (clean home)", () => {
|
||||
function cleanHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
}
|
||||
|
||||
it("add without API key errors", () => {
|
||||
const home = cleanHome();
|
||||
const result = run(["add", "test", "--user-id", "alice"], { home });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined.toLowerCase()).toMatch(/api.key|error/i);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("config show works with clean home", () => {
|
||||
const home = cleanHome();
|
||||
const result = run(["config", "show"], { home });
|
||||
expect(result.exitCode).toBe(0);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Tests for CLI commands using mock backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createMockBackend } from "./setup.js";
|
||||
import type { Backend } from "../src/backend/base.js";
|
||||
|
||||
let mockBackend: Backend;
|
||||
|
||||
// Capture console.log and console.error output
|
||||
let output: string;
|
||||
let errOutput: string;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
mockBackend = createMockBackend();
|
||||
output = "";
|
||||
errOutput = "";
|
||||
console.log = (...args: unknown[]) => {
|
||||
output += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errOutput += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
});
|
||||
|
||||
// Restore after each test
|
||||
import { afterEach } from "vitest";
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
describe("cmdAdd", () => {
|
||||
it("adds text memory", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "I prefer dark mode", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(mockBackend.add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("adds from messages JSON", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, undefined, {
|
||||
userId: "alice",
|
||||
messages: JSON.stringify([{ role: "user", content: "I love Python" }]),
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(mockBackend.add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("outputs json format", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "json",
|
||||
});
|
||||
expect(output).toContain("results");
|
||||
});
|
||||
|
||||
it("quiet mode produces no memory content", async () => {
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
enableGraph: false,
|
||||
output: "quiet",
|
||||
});
|
||||
expect(output).not.toContain("dark mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSearch", () => {
|
||||
it("searches and shows results in text mode", async () => {
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "preferences", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(output).toContain("Found 2");
|
||||
});
|
||||
|
||||
it("outputs json format", async () => {
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "preferences", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
enableGraph: false,
|
||||
output: "json",
|
||||
});
|
||||
expect(output).toContain("memory");
|
||||
});
|
||||
|
||||
it("shows no results message", async () => {
|
||||
(mockBackend.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||
await cmdSearch(mockBackend, "nonexistent", {
|
||||
userId: "alice",
|
||||
topK: 10,
|
||||
threshold: 0.3,
|
||||
rerank: false,
|
||||
keyword: false,
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(output).toContain("No memories found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdGet", () => {
|
||||
it("gets memory in text mode", async () => {
|
||||
const { cmdGet } = await import("../src/commands/memory.js");
|
||||
await cmdGet(mockBackend, "abc-123-def-456", { output: "text" });
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
|
||||
it("gets memory in json mode", async () => {
|
||||
const { cmdGet } = await import("../src/commands/memory.js");
|
||||
await cmdGet(mockBackend, "abc-123-def-456", { output: "json" });
|
||||
expect(output).toContain("memory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdList", () => {
|
||||
it("lists in table mode", async () => {
|
||||
const { cmdList } = await import("../src/commands/memory.js");
|
||||
await cmdList(mockBackend, {
|
||||
userId: "alice",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
enableGraph: false,
|
||||
output: "table",
|
||||
});
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
|
||||
it("shows empty message", async () => {
|
||||
(mockBackend.listMemories as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const { cmdList } = await import("../src/commands/memory.js");
|
||||
await cmdList(mockBackend, {
|
||||
userId: "alice",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
enableGraph: false,
|
||||
output: "text",
|
||||
});
|
||||
expect(output).toContain("No memories found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdUpdate", () => {
|
||||
it("updates memory", async () => {
|
||||
const { cmdUpdate } = await import("../src/commands/memory.js");
|
||||
await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" });
|
||||
expect(output.toLowerCase()).toContain("updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdDelete", () => {
|
||||
it("deletes memory", async () => {
|
||||
const { cmdDelete } = await import("../src/commands/memory.js");
|
||||
await cmdDelete(mockBackend, "abc-123", { output: "text" });
|
||||
expect(output.toLowerCase()).toContain("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdDeleteAll", () => {
|
||||
it("deletes all with force", async () => {
|
||||
const { cmdDeleteAll } = await import("../src/commands/memory.js");
|
||||
await cmdDeleteAll(mockBackend, {
|
||||
force: true,
|
||||
userId: "alice",
|
||||
output: "text",
|
||||
});
|
||||
expect(output.toLowerCase()).toContain("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdVersion", () => {
|
||||
it("shows version", async () => {
|
||||
const { cmdVersion } = await import("../src/commands/utils.js");
|
||||
cmdVersion();
|
||||
expect(output).toContain("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdEntitiesList", () => {
|
||||
it("lists users in table mode", async () => {
|
||||
const { cmdEntitiesList } = await import("../src/commands/entities.js");
|
||||
await cmdEntitiesList(mockBackend, "users", { output: "table" });
|
||||
expect(output).toContain("alice");
|
||||
});
|
||||
|
||||
it("lists in json mode", async () => {
|
||||
const { cmdEntitiesList } = await import("../src/commands/entities.js");
|
||||
await cmdEntitiesList(mockBackend, "users", { output: "json" });
|
||||
expect(output).toContain("alice");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests for configuration management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createDefaultConfig,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
redactKey,
|
||||
getNestedValue,
|
||||
setNestedValue,
|
||||
CONFIG_DIR,
|
||||
CONFIG_FILE,
|
||||
} from "../src/config.js";
|
||||
|
||||
// Use a temp directory for config during tests
|
||||
let origConfigDir: string;
|
||||
let origConfigFile: string;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
// Monkey-patch the module-level constants
|
||||
// We'll use env vars and direct file manipulation instead
|
||||
// Clear MEM0_ env vars
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith("MEM0_")) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("redactKey", () => {
|
||||
it("returns '(not set)' for empty key", () => {
|
||||
expect(redactKey("")).toBe("(not set)");
|
||||
});
|
||||
|
||||
it("redacts short key", () => {
|
||||
expect(redactKey("abc")).toBe("ab***");
|
||||
});
|
||||
|
||||
it("redacts normal key", () => {
|
||||
const result = redactKey("m0-abcdefgh12345678");
|
||||
expect(result).toBe("m0-a...5678");
|
||||
expect(result).not.toContain("abcdefgh");
|
||||
});
|
||||
|
||||
it("redacts exactly 8-char key as short", () => {
|
||||
expect(redactKey("12345678")).toBe("12***");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultConfig", () => {
|
||||
it("has correct defaults", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(config.platform.baseUrl).toBe("https://api.mem0.ai");
|
||||
expect(config.platform.apiKey).toBe("");
|
||||
expect(config.defaults.userId).toBe("");
|
||||
expect(config.defaults.enableGraph).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNestedValue", () => {
|
||||
it("gets platform.api_key", () => {
|
||||
const config = createDefaultConfig();
|
||||
config.platform.apiKey = "test-key";
|
||||
expect(getNestedValue(config, "platform.api_key")).toBe("test-key");
|
||||
});
|
||||
|
||||
it("returns undefined for nonexistent key", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(getNestedValue(config, "nonexistent.key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gets defaults.user_id", () => {
|
||||
const config = createDefaultConfig();
|
||||
config.defaults.userId = "alice";
|
||||
expect(getNestedValue(config, "defaults.user_id")).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNestedValue", () => {
|
||||
it("sets platform.api_key", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "platform.api_key", "new-key")).toBe(true);
|
||||
expect(config.platform.apiKey).toBe("new-key");
|
||||
});
|
||||
|
||||
it("returns false for nonexistent key", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "nonexistent.key", "val")).toBe(false);
|
||||
});
|
||||
|
||||
it("sets defaults.user_id", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "defaults.user_id", "bob")).toBe(true);
|
||||
expect(config.defaults.userId).toBe("bob");
|
||||
});
|
||||
|
||||
it("coerces boolean for enable_graph", () => {
|
||||
const config = createDefaultConfig();
|
||||
expect(setNestedValue(config, "defaults.enable_graph", "true")).toBe(true);
|
||||
expect(config.defaults.enableGraph).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for output formatting.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
formatMemoriesText,
|
||||
formatMemoriesTable,
|
||||
formatJson,
|
||||
formatSingleMemory,
|
||||
formatAddResult,
|
||||
printResultSummary,
|
||||
} from "../src/output.js";
|
||||
|
||||
let output: string;
|
||||
const originalLog = console.log;
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
console.log = (...args: unknown[]) => {
|
||||
output += args.map(String).join(" ") + "\n";
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
});
|
||||
|
||||
const sampleMemories = [
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
score: 0.92,
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
categories: ["preferences"],
|
||||
},
|
||||
{
|
||||
id: "ghi-789-jkl-012",
|
||||
memory: "User uses vim keybindings",
|
||||
score: 0.78,
|
||||
created_at: "2026-03-01T14:00:00Z",
|
||||
categories: ["tools"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("formatMemoriesText", () => {
|
||||
it("shows count and memory content", () => {
|
||||
formatMemoriesText(sampleMemories);
|
||||
expect(output).toContain("Found 2");
|
||||
expect(output).toContain("dark mode");
|
||||
expect(output).toContain("vim keybindings");
|
||||
});
|
||||
|
||||
it("shows scores and IDs", () => {
|
||||
formatMemoriesText(sampleMemories);
|
||||
expect(output).toContain("0.92");
|
||||
expect(output).toContain("abc-123-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoriesTable", () => {
|
||||
it("renders a table with memory content", () => {
|
||||
formatMemoriesTable(sampleMemories);
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJson", () => {
|
||||
it("outputs valid JSON", () => {
|
||||
formatJson({ key: "value" });
|
||||
expect(JSON.parse(output)).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleMemory", () => {
|
||||
it("shows memory text in text mode", () => {
|
||||
formatSingleMemory(sampleMemories[0], "text");
|
||||
expect(output).toContain("dark mode");
|
||||
});
|
||||
|
||||
it("outputs JSON in json mode", () => {
|
||||
formatSingleMemory(sampleMemories[0], "json");
|
||||
expect(output).toContain("memory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAddResult", () => {
|
||||
it("shows ADD event", () => {
|
||||
formatAddResult({
|
||||
results: [{ id: "abc-123", memory: "Test", event: "ADD" }],
|
||||
});
|
||||
expect(output).toContain("Added");
|
||||
});
|
||||
|
||||
it("shows PENDING event", () => {
|
||||
formatAddResult({
|
||||
results: [{ status: "PENDING", event_id: "evt-12345678" }],
|
||||
});
|
||||
expect(output).toContain("Queued");
|
||||
});
|
||||
});
|
||||
|
||||
describe("printResultSummary", () => {
|
||||
it("shows count and duration", () => {
|
||||
printResultSummary({ count: 5, durationSecs: 1.23 });
|
||||
expect(output).toContain("5 results");
|
||||
expect(output).toContain("1.23s");
|
||||
});
|
||||
|
||||
it("handles singular", () => {
|
||||
printResultSummary({ count: 1 });
|
||||
expect(output).toContain("1 result");
|
||||
expect(output).not.toContain("results");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared test helpers and mock factories for mem0 CLI tests.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import type { Backend } from "../src/backend/base.js";
|
||||
|
||||
/** Create a mock backend with all methods stubbed with sensible defaults. */
|
||||
export function createMockBackend(): Backend {
|
||||
return {
|
||||
add: vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
event: "ADD",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
search: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
score: 0.92,
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
categories: ["preferences"],
|
||||
},
|
||||
{
|
||||
id: "ghi-789-jkl-012",
|
||||
memory: "User uses vim keybindings",
|
||||
score: 0.78,
|
||||
created_at: "2026-03-01T14:00:00Z",
|
||||
categories: ["tools"],
|
||||
},
|
||||
]),
|
||||
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
updated_at: "2026-02-20T08:00:00Z",
|
||||
metadata: { source: "onboarding" },
|
||||
categories: ["preferences"],
|
||||
}),
|
||||
|
||||
listMemories: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
created_at: "2026-02-15T10:30:00Z",
|
||||
categories: ["preferences"],
|
||||
},
|
||||
{
|
||||
id: "ghi-789-jkl-012",
|
||||
memory: "User uses vim keybindings",
|
||||
created_at: "2026-03-01T14:00:00Z",
|
||||
categories: ["tools"],
|
||||
},
|
||||
]),
|
||||
|
||||
update: vi.fn().mockResolvedValue({ id: "abc-123-def-456", memory: "Updated memory" }),
|
||||
delete: vi.fn().mockResolvedValue({ status: "deleted" }),
|
||||
status: vi.fn().mockResolvedValue({
|
||||
connected: true,
|
||||
backend: "platform",
|
||||
base_url: "https://api.mem0.ai",
|
||||
}),
|
||||
deleteEntities: vi.fn().mockResolvedValue({ message: "Entity deleted" }),
|
||||
entities: vi.fn().mockResolvedValue([
|
||||
{ name: "alice", count: 5 },
|
||||
{ name: "bob", count: 3 },
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user