diff --git a/cli/node/package.json b/cli/node/package.json index bee02b2f4..4a03bdf96 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -7,7 +7,7 @@ "mem0": "./dist/index.js" }, "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean", + "build": "tsup", "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 419fdf85e..cdef78b35 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -58,15 +58,20 @@ export class PlatformBackend implements Backend { if (resp.status === 400) { let detail: string; try { - const body = await resp.json(); - detail = (body as Record).detail ?? resp.statusText; + const body = await resp.json() as Record; + detail = (body.detail ?? body.message ?? JSON.stringify(body)) as string ?? resp.statusText; } catch { detail = resp.statusText; } throw new APIError(path, detail); } if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + let detail: string = resp.statusText; + try { + const body = await resp.json() as Record; + detail = (body.detail ?? body.message ?? resp.statusText) as string; + } catch { /* ignore */ } + throw new Error(`HTTP ${resp.status}: ${detail}`); } if (resp.status === 204) { return {}; diff --git a/cli/node/src/branding.ts b/cli/node/src/branding.ts index 7eb54fae0..e60c5e3be 100644 --- a/cli/node/src/branding.ts +++ b/cli/node/src/branding.ts @@ -4,10 +4,6 @@ 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 = ` ███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗ @@ -48,7 +44,7 @@ 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 subtitle = `Node.js SDK · v${__CLI_VERSION__}`; const contentLines = ["", ...logoLines, "", tagline, ""]; // Compute inner width from longest content line + padding both sides @@ -90,7 +86,7 @@ export function printWarning(message: string): void { } export function printInfo(message: string): void { - console.log(`${brand(sym("◆", "*"))} ${message}`); + console.error(`${brand(sym("◆", "*"))} ${message}`); } export function printScope(ids: Record): void { @@ -102,7 +98,7 @@ export function printScope(ids: Record): void { } } if (parts.length > 0) { - console.log(` ${dim(`Scope: ${parts.join(", ")}`)}`); + console.error(` ${dim(`Scope: ${parts.join(", ")}`)}`); } } diff --git a/cli/node/src/commands/memory.ts b/cli/node/src/commands/memory.ts index 9c900afc7..95065f98e 100644 --- a/cli/node/src/commands/memory.ts +++ b/cli/node/src/commands/memory.ts @@ -8,6 +8,7 @@ import type { Backend } from "../backend/base.js"; import { formatAddResult, formatJson, + formatJsonEnvelope, formatMemoriesTable, formatMemoriesText, formatSingleMemory, @@ -60,11 +61,27 @@ export async function cmdAdd( content = fs.readFileSync(0, "utf-8").trim(); } + if (content !== undefined && content.trim() === "") { + printError("Content cannot be empty."); + process.exit(1); + } if (!content && !msgs) { printError("No content provided. Pass text, --messages, --file, or pipe via stdin."); process.exit(1); } + // Validate --expires + if (opts.expires) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) { + printError("Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."); + process.exit(1); + } + if (new Date(opts.expires) <= new Date()) { + printError("--expires date must be in the future."); + process.exit(1); + } + } + let meta: Record | undefined; if (opts.metadata) { try { @@ -155,6 +172,15 @@ export async function cmdSearch( const fieldList = opts.fields ? opts.fields.split(",").map((f) => f.trim()) : undefined; + if (opts.topK < 1) { + printError("--top-k must be >= 1."); + process.exit(1); + } + if (opts.threshold < 0 || opts.threshold > 1) { + printError("--threshold must be between 0.0 and 1.0."); + process.exit(1); + } + const start = performance.now(); let results: Record[]; try { @@ -179,11 +205,13 @@ export async function cmdSearch( } const elapsed = (performance.now() - start) / 1000; + if (opts.output === "quiet") return; + if (opts.output === "json") { formatJson(results); } else if (opts.output === "table") { if (results.length > 0) { - formatMemoriesTable(results); + formatMemoriesTable(results, { showScore: true }); printResultSummary({ count: results.length, durationSecs: elapsed, scopeIds: { user_id: opts.userId, agent_id: opts.agentId } }); } else { console.log(); @@ -236,6 +264,15 @@ export async function cmdList( output: string; }, ): Promise { + if (opts.pageSize < 1) { + printError("--page-size must be >= 1."); + process.exit(1); + } + if (opts.page < 1) { + printError("--page must be >= 1."); + process.exit(1); + } + const start = performance.now(); let results: Record[]; try { @@ -259,8 +296,15 @@ export async function cmdList( } const elapsed = (performance.now() - start) / 1000; + if (opts.output === "quiet") return; + if (opts.output === "json") { - formatJson(results); + formatJsonEnvelope({ + command: "list", + data: results, + count: results.length, + scope: { user_id: opts.userId, agent_id: opts.agentId }, + }); } else if (opts.output === "table") { if (results.length > 0) { formatMemoriesTable(results); @@ -372,6 +416,7 @@ export async function cmdDeleteAll( // Project-wide wipe using wildcard entity IDs if (opts.dryRun) { printInfo("Would delete ALL memories project-wide."); + printInfo("Run without --dry-run to see the actual count of deleted memories."); printInfo("No changes made."); return; } diff --git a/cli/node/src/commands/utils.ts b/cli/node/src/commands/utils.ts index b0ad611e7..07297d89e 100644 --- a/cli/node/src/commands/utils.ts +++ b/cli/node/src/commands/utils.ts @@ -3,7 +3,6 @@ */ 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"; @@ -11,9 +10,6 @@ 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 } = {}, @@ -73,7 +69,7 @@ export async function cmdStatus( } export function cmdVersion(): void { - console.log(` ${brand("◆ Mem0")} CLI v${VERSION}`); + console.log(` ${brand("◆ Mem0")} CLI v${__CLI_VERSION__}`); } export async function cmdImport( diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts index 53314f8a9..91ef29353 100644 --- a/cli/node/src/config.ts +++ b/cli/node/src/config.ts @@ -132,6 +132,14 @@ const KEY_MAP: Record = { "defaults.app_id": ["defaults", "appId"], "defaults.run_id": ["defaults", "runId"], "defaults.enable_graph": ["defaults", "enableGraph"], + // Short-form aliases + "api_key": ["platform", "apiKey"], + "base_url": ["platform", "baseUrl"], + "user_id": ["defaults", "userId"], + "agent_id": ["defaults", "agentId"], + "app_id": ["defaults", "appId"], + "run_id": ["defaults", "runId"], + "enable_graph": ["defaults", "enableGraph"], }; export function getNestedValue(config: Mem0Config, dottedKey: string): unknown { diff --git a/cli/node/src/globals.d.ts b/cli/node/src/globals.d.ts new file mode 100644 index 000000000..d51a5c52d --- /dev/null +++ b/cli/node/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Injected by tsup at build time from package.json version field. */ +declare const __CLI_VERSION__: string; diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index d4969f5f6..4c0c7b8e9 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -7,7 +7,6 @@ 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"; @@ -15,9 +14,6 @@ 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 ────────────────────────────────────────────────────────────── @@ -95,10 +91,10 @@ function resolveGraph( program .name("mem0") - .description(`◆ Mem0 CLI v${VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`) + .description(`◆ Mem0 CLI v${__CLI_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}`); + console.log(` ${colors.brand("◆ Mem0")} CLI v${__CLI_VERSION__}`); process.exit(0); }) .usage(" [options]") @@ -476,11 +472,11 @@ program 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)); + console.log(JSON.stringify({ name: "mem0", version: __CLI_VERSION__, description: "The Memory Layer for AI Agents" }, null, 2)); } } else { const { brand: b } = colors; - console.log(`${b("◆ Mem0 CLI")} v${VERSION} · Node.js SDK\n The Memory Layer for AI Agents\n`); + console.log(`${b("◆ Mem0 CLI")} v${__CLI_VERSION__} · Node.js SDK\n The Memory Layer for AI Agents\n`); console.log("Usage: mem0 [OPTIONS]\n"); console.log("Commands:"); console.log(" add Add a memory from text, messages, file, or stdin"); @@ -501,6 +497,16 @@ program } }); +// ── Version ─────────────────────────────────────────────────────────────── + +program + .command("version") + .description("Show version.") + .action(async () => { + const { cmdVersion } = await import("./commands/utils.js"); + cmdVersion(); + }); + // ── Entrypoint ──────────────────────────────────────────────────────────── program.parse(); diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts index f5e5b3403..aa35e16cc 100644 --- a/cli/node/src/output.ts +++ b/cli/node/src/output.ts @@ -52,16 +52,22 @@ export function formatMemoriesText( } } -export function formatMemoriesTable(memories: Record[]): void { +export function formatMemoriesTable(memories: Record[], opts: { showScore?: boolean } = {}): void { + const head = opts.showScore + ? [accent("ID"), accent("Score"), accent("Memory"), accent("Category"), accent("Created")] + : [accent("ID"), accent("Memory"), accent("Category"), accent("Created")]; + const colWidths = opts.showScore + ? [38, 8, 40, 16, 14] + : [38, 40, 16, 14]; const table = new Table({ - head: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")], - colWidths: [12, 52, 16, 14], + head, + colWidths, wordWrap: true, style: { head: [], border: [] }, }); for (const mem of memories) { - const memId = ((mem.id as string) ?? "").slice(0, 8); + const memId = ((mem.id as string) ?? ""); let memoryText = (mem.memory ?? mem.text ?? "") as string; if (memoryText.length > 60) { memoryText = memoryText.slice(0, 57) + "..."; @@ -69,10 +75,18 @@ export function formatMemoriesTable(memories: Record[]): void { const categories = mem.categories; const cat = Array.isArray(categories) && categories.length > 0 - ? (categories[0] as string) + ? categories.length > 1 + ? `${categories[0]} (+${categories.length - 1})` + : (categories[0] as string) : "—"; const created = formatDate(mem.created_at as string | undefined) ?? "—"; - table.push([dim(memId), memoryText, cat, created]); + if (opts.showScore) { + const score = mem.score as number | undefined; + const scoreStr = score !== undefined ? score.toFixed(2) : "—"; + table.push([dim(memId), scoreStr, memoryText, cat, created]); + } else { + table.push([dim(memId), memoryText, cat, created]); + } } console.log(); diff --git a/cli/node/tsup.config.ts b/cli/node/tsup.config.ts new file mode 100644 index 000000000..52a4352d3 --- /dev/null +++ b/cli/node/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); +const pkg = _require('./package.json'); + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + define: { + __CLI_VERSION__: JSON.stringify(pkg.version), + }, +}); diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index c329118a3..5352525b4 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -281,8 +281,8 @@ def search( # STEP 7: stdin fallback for query if query is None: query = _read_stdin() - if query is None: - print_error(err_console, "No query provided. Pass a query argument or pipe via stdin.") + if not query or not query.strip(): + print_error(err_console, "Search query cannot be empty.") raise typer.Exit(1) backend, config = _get_backend_and_config(api_key, base_url) @@ -1014,6 +1014,13 @@ def help( console.print() +@app.command(rich_help_panel="Utility") +def version() -> None: + """Show version and exit.""" + from mem0_cli.commands.utils import cmd_version + cmd_version() + + # Register config subgroup here so it appears after help in Management panel app.add_typer(config_app, name="config", rich_help_panel="Management") diff --git a/cli/python/src/mem0_cli/commands/memory.py b/cli/python/src/mem0_cli/commands/memory.py index 6cc24837a..9d5e201fc 100644 --- a/cli/python/src/mem0_cli/commands/memory.py +++ b/cli/python/src/mem0_cli/commands/memory.py @@ -95,6 +95,17 @@ def cmd_add( except json.JSONDecodeError: cats = [c.strip() for c in categories.split(",")] + # Validate --expires + if expires: + import re + if not re.match(r'^\d{4}-\d{2}-\d{2}$', expires): + print_error(err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).") + raise typer.Exit(1) + from datetime import date + if date.fromisoformat(expires) <= date.today(): + print_error(err_console, "--expires date must be in the future.") + raise typer.Exit(1) + with timed_status(err_console, "Adding memory...") as ts: try: result = backend.add( @@ -113,7 +124,6 @@ def cmd_add( ) except Exception as e: ts.error_msg = str(e) - print_error(err_console, str(e)) raise typer.Exit(1) from None if output == "quiet": @@ -164,6 +174,13 @@ def cmd_search( if fields: field_list = [f.strip() for f in fields.split(",")] + if top_k < 1: + print_error(err_console, "--top-k must be >= 1.") + raise typer.Exit(1) + if not (0.0 <= threshold <= 1.0): + print_error(err_console, "--threshold must be between 0.0 and 1.0.") + raise typer.Exit(1) + _start = _time.perf_counter() with timed_status(err_console, "Searching memories...") as _ts: try: @@ -186,11 +203,14 @@ def cmd_search( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start + if output == "quiet": + return + if output == "json": format_json(console, results) elif output == "table": if results: - format_memories_table(console, results) + format_memories_table(console, results, show_score=True) print_result_summary( console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id ) @@ -238,6 +258,13 @@ def cmd_list( output: str = "table", ) -> None: """List memories.""" + if page_size < 1: + print_error(err_console, "--page-size must be >= 1.") + raise typer.Exit(1) + if page < 1: + print_error(err_console, "--page must be >= 1.") + raise typer.Exit(1) + _start = _time.perf_counter() with timed_status(err_console, "Listing memories...") as _ts: try: @@ -258,8 +285,18 @@ def cmd_list( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start + if output == "quiet": + return + if output == "json": - format_json(console, results) + from mem0_cli.output import format_json_envelope + format_json_envelope( + console, + command="list", + data=results, + count=len(results), + scope={k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v}, + ) elif output == "table": if results: format_memories_table(console, results) @@ -376,6 +413,7 @@ def cmd_delete_all( # Project-wide wipe using wildcard entity IDs if dry_run: print_info(console, "Would delete ALL memories project-wide.") + print_info(console, "Run without --dry-run to see the actual count.") print_info(console, "No changes made (dry run).") return diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py index 780c30ec1..9ffa64161 100644 --- a/cli/python/src/mem0_cli/config.py +++ b/cli/python/src/mem0_cli/config.py @@ -45,6 +45,17 @@ class Mem0Config: platform: PlatformConfig = field(default_factory=PlatformConfig) +SHORT_KEY_ALIASES: dict[str, str] = { + "api_key": "platform.api_key", + "base_url": "platform.base_url", + "user_id": "defaults.user_id", + "agent_id": "defaults.agent_id", + "app_id": "defaults.app_id", + "run_id": "defaults.run_id", + "enable_graph": "defaults.enable_graph", +} + + def ensure_config_dir() -> Path: """Create ~/.mem0 directory with secure permissions if it doesn't exist.""" CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -140,7 +151,8 @@ def redact_key(key: str) -> str: def get_nested_value(config: Mem0Config, dotted_key: str) -> Any: - """Get a config value by dotted path, e.g. 'platform.api_key'.""" + """Get a config value by dotted path, e.g. 'platform.api_key' or short form 'api_key'.""" + dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key) parts = dotted_key.split(".") obj: Any = config for part in parts: @@ -153,6 +165,7 @@ def get_nested_value(config: Mem0Config, dotted_key: str) -> Any: def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool: """Set a config value by dotted path. Returns True on success.""" + dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key) parts = dotted_key.split(".") obj: Any = config for part in parts[:-1]: diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py index 3d33003d9..09156c55a 100644 --- a/cli/python/src/mem0_cli/output.py +++ b/cli/python/src/mem0_cli/output.py @@ -49,7 +49,7 @@ def format_memories_text(console: Console, memories: list[dict], title: str = "m console.print() -def format_memories_table(console: Console, memories: list[dict]) -> None: +def format_memories_table(console: Console, memories: list[dict], *, show_score: bool = False) -> None: """Render memories in a rich table.""" table = Table( border_style=BRAND_COLOR, @@ -57,20 +57,30 @@ def format_memories_table(console: Console, memories: list[dict]) -> None: row_styles=["", "dim"], padding=(0, 1), ) - table.add_column("ID", style="dim", max_width=10) + table.add_column("ID", style="dim", max_width=38, no_wrap=True) + if show_score: + table.add_column("Score", max_width=7, justify="right") table.add_column("Memory", max_width=50, no_wrap=False) table.add_column("Category", max_width=14) table.add_column("Created", max_width=12) for mem in memories: - mem_id = mem.get("id", "")[:8] + mem_id = mem.get("id", "") memory_text = mem.get("memory", mem.get("text", "")) if len(memory_text) > 60: memory_text = memory_text[:57] + "..." categories = mem.get("categories", []) - cat = categories[0] if isinstance(categories, list) and categories else "—" + if isinstance(categories, list) and categories: + cat = categories[0] if len(categories) == 1 else f"{categories[0]} (+{len(categories) - 1})" + else: + cat = "—" created = _format_date(mem.get("created_at")) or "—" - table.add_row(mem_id, memory_text, cat, created) + if show_score: + score = mem.get("score") + score_str = f"{score:.2f}" if score is not None else "—" + table.add_row(mem_id, score_str, memory_text, cat, created) + else: + table.add_row(mem_id, memory_text, cat, created) console.print() console.print(table)