From c90bdbdce078f46d768c44031ce77a1b93dbc3f6 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 3 Aug 2026 17:10:44 +0530 Subject: [PATCH 1/9] feat(cli): Platform option parity across Python and Node CLIs (MEM-5893) (#6696) --- .github/workflows/ci-gate.yml | 14 + .github/workflows/mem0-plugin-checks.yml | 58 ++ AGENTS.md | 1 + cli/cli-spec.json | 14 +- cli/node/src/backend/base.ts | 17 +- cli/node/src/backend/platform.ts | 24 +- cli/node/src/commands/memory.ts | 108 +- cli/node/src/index.ts | 58 +- cli/node/src/output.ts | 20 +- cli/node/src/state.ts | 13 + cli/node/tests/commands.test.ts | 953 +++++++++++------- cli/node/tests/option-parity.test.ts | 133 +++ cli/node/tests/platform-backend.test.ts | 273 +++-- cli/python/src/mem0_cli/app.py | 89 +- cli/python/src/mem0_cli/backend/base.py | 19 +- cli/python/src/mem0_cli/backend/platform.py | 48 +- cli/python/src/mem0_cli/commands/memory.py | 87 +- cli/python/src/mem0_cli/output.py | 24 +- cli/python/tests/test_commands.py | 180 +++- cli/python/tests/test_option_parity.py | 78 ++ .../tests/test_platform_backend_options.py | 109 ++ docs/platform/cli.mdx | 18 +- .../mem0-plugin/scripts/load_settings.py | 37 +- integrations/mem0-plugin/tests/conftest.py | 15 +- .../mem0-plugin/tests/test_load_settings.py | 86 ++ mem0-ts/src/client/mem0.types.ts | 2 + .../client/tests/memoryClient.search.test.ts | 36 + mem0/client/types.py | 6 + tests/test_client.py | 51 + 29 files changed, 2030 insertions(+), 541 deletions(-) create mode 100644 .github/workflows/mem0-plugin-checks.yml create mode 100644 cli/node/tests/option-parity.test.ts create mode 100644 cli/python/tests/test_option_parity.py create mode 100644 cli/python/tests/test_platform_backend_options.py create mode 100644 integrations/mem0-plugin/tests/test_load_settings.py diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index 8c8278aa6..eaf00275c 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -38,6 +38,7 @@ jobs: cli_python: ${{ steps.filter.outputs.cli_python }} cli_node: ${{ steps.filter.outputs.cli_node }} openclaw: ${{ steps.filter.outputs.openclaw }} + mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }} opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }} pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }} @@ -73,6 +74,11 @@ jobs: - 'integrations/openclaw/**' - '.github/workflows/openclaw-checks.yml' - '.github/workflows/ci-gate.yml' + mem0_plugin: + - 'integrations/mem0-plugin/**' + - '!integrations/mem0-plugin/.opencode-plugin/**' + - '.github/workflows/mem0-plugin-checks.yml' + - '.github/workflows/ci-gate.yml' opencode_plugin: - 'integrations/mem0-plugin/.opencode-plugin/**' - '.github/workflows/opencode-plugin-checks.yml' @@ -131,6 +137,13 @@ jobs: uses: ./.github/workflows/openclaw-checks.yml secrets: inherit + mem0-plugin: + name: Mem0 Plugin + needs: changes + if: needs.changes.outputs.mem0_plugin == 'true' + uses: ./.github/workflows/mem0-plugin-checks.yml + secrets: inherit + opencode-plugin: name: OpenCode Plugin needs: changes @@ -173,6 +186,7 @@ jobs: - cli-python - cli-node - openclaw + - mem0-plugin - opencode-plugin - pi-agent-plugin - n8n-nodes-mem0 diff --git a/.github/workflows/mem0-plugin-checks.yml b/.github/workflows/mem0-plugin-checks.yml new file mode 100644 index 000000000..87eda7ada --- /dev/null +++ b/.github/workflows/mem0-plugin-checks.yml @@ -0,0 +1,58 @@ +name: Mem0 Plugin Checks + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +# +# Covers the Python plugin (scripts/ + tests/). The nested .opencode-plugin/ +# is a separate package with its own workflow (opencode-plugin-checks.yml). +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/mem0-plugin/**' + - '!integrations/mem0-plugin/.opencode-plugin/**' + - '.github/workflows/mem0-plugin-checks.yml' + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: integrations/mem0-plugin + run: | + pip install -r requirements.txt + pip install pytest + + - name: Verify hook entry points are executable + working-directory: integrations/mem0-plugin + run: | + missing=$(find scripts -name '*.sh' ! -name '_*' ! -perm -u+x -print) + if [ -n "$missing" ]; then + echo "Hook entry points must be executable:" + echo "$missing" + exit 1 + fi + + - name: Check hook manifests are valid JSON + working-directory: integrations/mem0-plugin + run: | + for f in plugin.json mcp_config.json hooks.json hooks/*.json; do + jq empty "$f" || (echo "Invalid JSON: $f" && exit 1) + done + + - name: Run tests + working-directory: integrations/mem0-plugin + run: pytest -q diff --git a/AGENTS.md b/AGENTS.md index ae9889730..384a92c0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -406,6 +406,7 @@ PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)** | Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 | | Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | | OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | +| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook entry-point exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 | | OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check | | Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 | | n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (on `integrations/n8n-nodes-mem0/`), manual | ESLint (n8n-nodes-base) + tsc build (dist artifact check) on Node 20 | diff --git a/cli/cli-spec.json b/cli/cli-spec.json index 437bb8e46..c8cecad3a 100644 --- a/cli/cli-spec.json +++ b/cli/cli-spec.json @@ -207,7 +207,11 @@ { "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." }, { "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." }, { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, - { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Categories (JSON array or comma-separated)." }, + { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Not supported on add, use --custom-categories instead." }, + { "name": "custom_instructions", "flags": ["--custom-instructions"], "type": "string", "help": "Custom instructions for fact extraction." }, + { "name": "custom_categories", "flags": ["--custom-categories"], "type": "string", "help": "Custom categories as a JSON array of {name: description} objects." }, + { "name": "structured_data_schema", "flags": ["--structured-data-schema"], "type": "string", "help": "Schema for structured data extraction, as JSON." }, + { "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" } @@ -244,6 +248,9 @@ { "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" }, { "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" }, { "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" }, + { "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Search" }, + { "name": "reference_date", "flags": ["--reference-date"], "type": "string", "help": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", "panel": "Search" }, + { "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Search" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" } @@ -296,6 +303,8 @@ { "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" }, { "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" }, { "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" }, + { "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Filters" }, + { "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Filters" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" } @@ -329,6 +338,8 @@ ], "options": [ { "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." }, + { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, + { "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } ], "apiEndpoint": "update" @@ -356,6 +367,7 @@ { "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." }, { "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." }, { "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." }, + { "name": "delete_linked", "flags": ["--delete-linked"], "type": "boolean", "default": false, "help": "Also delete memories linked to this memory." }, { "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." }, { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." }, { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" }, diff --git a/cli/node/src/backend/base.ts b/cli/node/src/backend/base.ts index be1a0ce1f..d66a3519d 100644 --- a/cli/node/src/backend/base.ts +++ b/cli/node/src/backend/base.ts @@ -14,7 +14,10 @@ export interface AddOptions { immutable?: boolean; infer?: boolean; expires?: string; - categories?: string[]; + customInstructions?: string; + customCategories?: Record[]; + structuredDataSchema?: Record; + timestamp?: number; } export interface SearchOptions { @@ -28,6 +31,9 @@ export interface SearchOptions { keyword?: boolean; filters?: Record; fields?: string[]; + showExpired?: boolean; + referenceDate?: string | number; + latestOnly?: boolean; } export interface ListOptions { @@ -40,6 +46,8 @@ export interface ListOptions { category?: string; after?: string; before?: string; + showExpired?: boolean; + latestOnly?: boolean; } export interface DeleteOptions { @@ -48,6 +56,12 @@ export interface DeleteOptions { agentId?: string; appId?: string; runId?: string; + deleteLinked?: boolean; +} + +export interface UpdateOptions { + expirationDate?: string; + timestamp?: number; } export interface EntityIds { @@ -77,6 +91,7 @@ export interface Backend { memoryId: string, content?: string, metadata?: Record, + opts?: UpdateOptions, ): Promise>; delete( diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 0819bf3cb..f53b5dddc 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -15,6 +15,7 @@ import { type ListOptions, NotFoundError, type SearchOptions, + type UpdateOptions, } from "./base.js"; function encodePathSegment(value: unknown): string { @@ -150,7 +151,13 @@ export class PlatformBackend implements Backend { 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.customInstructions) + payload.custom_instructions = opts.customInstructions; + if (opts.customCategories) + payload.custom_categories = opts.customCategories; + if (opts.structuredDataSchema) + payload.structured_data_schema = opts.structuredDataSchema; + if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp; payload.source = "CLI"; return (await this._request("POST", "/v3/memories/add/", { @@ -211,6 +218,10 @@ export class PlatformBackend implements Backend { if (opts.rerank) payload.rerank = true; if (opts.keyword) payload.keyword_search = true; if (opts.fields) payload.fields = opts.fields; + if (opts.showExpired) payload.show_expired = true; + if (opts.referenceDate !== undefined) + payload.reference_date = opts.referenceDate; + if (opts.latestOnly) payload.latest_only = true; payload.source = "CLI"; const result = (await this._request("POST", "/v3/memories/search/", { @@ -265,6 +276,8 @@ export class PlatformBackend implements Backend { extraFilters: Object.keys(extra).length > 0 ? extra : undefined, }); if (apiFilters) payload.filters = apiFilters; + if (opts.showExpired) payload.show_expired = true; + if (opts.latestOnly) payload.latest_only = true; payload.source = "CLI"; const result = (await this._request("POST", "/v3/memories/", { @@ -280,10 +293,13 @@ export class PlatformBackend implements Backend { memoryId: string, content?: string, metadata?: Record, + opts: UpdateOptions = {}, ): Promise> { const payload: Record = {}; if (content) payload.text = content; if (metadata) payload.metadata = metadata; + if (opts.expirationDate) payload.expiration_date = opts.expirationDate; + if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp; payload.source = "CLI"; return (await this._request( "PUT", @@ -309,12 +325,12 @@ export class PlatformBackend implements Backend { })) as Record; } if (memoryId) { + const params: Record = { source: "CLI" }; + if (opts.deleteLinked) params.delete_linked = "true"; return (await this._request( "DELETE", `/v1/memories/${encodePathSegment(memoryId)}/`, - { - params: { source: "CLI" }, - }, + { params }, )) as Record; } throw new Error("Either memoryId or --all is required"); diff --git a/cli/node/src/commands/memory.ts b/cli/node/src/commands/memory.ts index 3b7b67b4f..3f5e5e621 100644 --- a/cli/node/src/commands/memory.ts +++ b/cli/node/src/commands/memory.ts @@ -21,16 +21,19 @@ import { formatSingleMemory, printResultSummary, } from "../output.js"; -import { isAgentMode, setCurrentCommand } from "../state.js"; +import { isAgentMode, setCurrentCommand, stdinIsPiped } from "../state.js"; -/** True only when stdin is an actual pipe or file redirect — never in agent mode. */ -function _stdinIsPiped(): boolean { - if (isAgentMode()) return false; - try { - const stat = fs.fstatSync(0); - return stat.isFIFO() || stat.isFile(); - } catch { - return false; +/** Exit 1 if value is not a future YYYY-MM-DD date. */ +function _validateExpires(value: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + printError( + "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).", + ); + process.exit(1); + } + if (new Date(value) <= new Date()) { + printError("--expires date must be in the future."); + process.exit(1); } } @@ -49,10 +52,22 @@ export async function cmdAdd( infer?: boolean; expires?: string; categories?: string; + customInstructions?: string; + customCategories?: string; + structuredDataSchema?: string; + timestamp?: number; output: string; }, ): Promise { setCurrentCommand("add"); + + if (opts.categories) { + printError( + "--categories is not supported on add. Use --custom-categories instead.", + ); + process.exit(1); + } + let msgs: Record[] | undefined; let content = text; @@ -78,7 +93,7 @@ export async function cmdAdd( } } // Read from stdin only if stdin is an actual pipe or file redirect - else if (!content && _stdinIsPiped()) { + else if (!content && stdinIsPiped()) { content = fs.readFileSync(0, "utf-8").trim(); } @@ -93,20 +108,6 @@ export async function cmdAdd( 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 { @@ -117,15 +118,28 @@ export async function cmdAdd( } } - let cats: string[] | undefined; - if (opts.categories) { + let customCats: Record[] | undefined; + if (opts.customCategories) { try { - cats = JSON.parse(opts.categories); + customCats = JSON.parse(opts.customCategories); } catch { - cats = opts.categories.split(",").map((c) => c.trim()); + printError("Invalid JSON in --custom-categories."); + process.exit(1); } } + let schema: Record | undefined; + if (opts.structuredDataSchema) { + try { + schema = JSON.parse(opts.structuredDataSchema); + } catch { + printError("Invalid JSON in --structured-data-schema."); + process.exit(1); + } + } + + if (opts.expires) _validateExpires(opts.expires); + let result: Record; try { result = await timedStatus("Adding memory...", async () => { @@ -138,7 +152,10 @@ export async function cmdAdd( immutable: opts.immutable, infer: opts.infer !== false, expires: opts.expires, - categories: cats, + customInstructions: opts.customInstructions, + customCategories: customCats, + structuredDataSchema: schema, + timestamp: opts.timestamp, }); }); } catch (e) { @@ -223,6 +240,9 @@ export async function cmdSearch( keyword: boolean; filterJson?: string; fields?: string; + showExpired?: boolean; + referenceDate?: string; + latestOnly?: boolean; output: string; }, ): Promise { @@ -271,6 +291,9 @@ export async function cmdSearch( keyword: opts.keyword, filters, fields: fieldList, + showExpired: opts.showExpired, + referenceDate: opts.referenceDate, + latestOnly: opts.latestOnly, }); }); } catch (e) { @@ -364,6 +387,8 @@ export async function cmdList( category?: string; after?: string; before?: string; + showExpired?: boolean; + latestOnly?: boolean; output: string; }, ): Promise { @@ -391,6 +416,8 @@ export async function cmdList( category: opts.category, after: opts.after, before: opts.before, + showExpired: opts.showExpired, + latestOnly: opts.latestOnly, }); }); } catch (e) { @@ -450,7 +477,12 @@ export async function cmdUpdate( backend: Backend, memoryId: string, text: string | undefined, - opts: { metadata?: string; output: string }, + opts: { + metadata?: string; + expires?: string; + timestamp?: number; + output: string; + }, ): Promise { setCurrentCommand("update"); let meta: Record | undefined; @@ -463,11 +495,16 @@ export async function cmdUpdate( } } + if (opts.expires) _validateExpires(opts.expires); + const start = performance.now(); let result: Record; try { result = await timedStatus("Updating memory...", async () => { - return backend.update(memoryId, text, meta); + return backend.update(memoryId, text, meta, { + expirationDate: opts.expires, + timestamp: opts.timestamp, + }); }); } catch (e) { printError(e instanceof Error ? e.message : String(e)); @@ -493,7 +530,12 @@ export async function cmdUpdate( export async function cmdDelete( backend: Backend, memoryId: string, - opts: { output: string; dryRun?: boolean; force?: boolean }, + opts: { + output: string; + dryRun?: boolean; + force?: boolean; + deleteLinked?: boolean; + }, ): Promise { setCurrentCommand("delete"); if (opts.dryRun) { @@ -514,7 +556,7 @@ export async function cmdDelete( let result: Record; try { result = await timedStatus("Deleting...", async () => { - return backend.delete(memoryId); + return backend.delete(memoryId, { deleteLinked: opts.deleteLinked }); }); } catch (e) { printError(e instanceof Error ? e.message : String(e)); diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index b038fb171..dfe46910f 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -17,6 +17,7 @@ import { isAgentMode, setAgentMode, setCurrentCommand, + stdinIsPiped, takeNotice, } from "./state.js"; import { captureEvent } from "./telemetry.js"; @@ -319,7 +320,25 @@ program .option("--immutable", "Prevent future updates.", false) .option("--no-infer", "Skip inference, store raw.") .option("--expires ", "Expiration date (YYYY-MM-DD).") - .option("--categories ", "Categories (JSON array or comma-separated).") + .option( + "--categories ", + "Not supported on add, use --custom-categories instead.", + ) + .option( + "--custom-instructions ", + "Custom instructions for fact extraction.", + ) + .option( + "--custom-categories ", + "Custom categories as a JSON array of {name: description} objects.", + ) + .option( + "--structured-data-schema ", + "Schema for structured data extraction, as JSON.", + ) + .option("--timestamp ", "Unix timestamp for the memory.", (v) => + Number.parseInt(v), + ) .option("-o, --output ", "Output format: text, json, quiet.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -366,6 +385,16 @@ program .option("--keyword", "Use keyword search.", false) .option("--filter ", "Advanced filter expression (JSON).") .option("--fields ", "Specific fields to return (comma-separated).") + .option("--show-expired", "Include expired memories.", false) + .option( + "--reference-date ", + "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + ) + .option( + "--latest-only", + "Only return the latest version of each memory.", + false, + ) .option("-o, --output ", "Output: text, json, table.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -375,7 +404,7 @@ program ) .action(async (query, opts) => { let resolvedQuery = query; - if (!resolvedQuery && !process.stdin.isTTY) { + if (!resolvedQuery && stdinIsPiped()) { resolvedQuery = fs.readFileSync(0, "utf-8").trim(); } if (!resolvedQuery) { @@ -398,6 +427,9 @@ program keyword: opts.keyword, filterJson: opts.filter, fields: opts.fields, + showExpired: opts.showExpired, + referenceDate: opts.referenceDate, + latestOnly: opts.latestOnly, output, }); }); @@ -441,6 +473,12 @@ program .option("--category ", "Filter by category.") .option("--after ", "Created after (YYYY-MM-DD).") .option("--before ", "Created before (YYYY-MM-DD).") + .option("--show-expired", "Include expired memories.", false) + .option( + "--latest-only", + "Only return the latest version of each memory.", + false, + ) .option("-o, --output ", "Output: text, json, table.", "table") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -464,6 +502,8 @@ program category: opts.category, after: opts.after, before: opts.before, + showExpired: opts.showExpired, + latestOnly: opts.latestOnly, output, }); }); @@ -474,6 +514,10 @@ program .command("update [text]") .description("Update a memory's text or metadata.") .option("-m, --metadata ", "Update metadata (JSON).") + .option("--expires ", "Expiration date (YYYY-MM-DD).") + .option("--timestamp ", "Unix timestamp for the memory.", (v) => + Number.parseInt(v), + ) .option("-o, --output ", "Output: text, json, quiet.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -483,7 +527,7 @@ program ) .action(async (memoryId, text, opts) => { let resolvedText = text; - if (!resolvedText && !opts.metadata && !process.stdin.isTTY) { + if (!resolvedText && stdinIsPiped()) { resolvedText = fs.readFileSync(0, "utf-8").trim(); } const { cmdUpdate } = await import("./commands/memory.js"); @@ -492,6 +536,8 @@ program const output = isAgent ? "agent" : opts.output; await cmdUpdate(backend, memoryId, resolvedText, { metadata: opts.metadata, + expires: opts.expires, + timestamp: opts.timestamp, output, }); }); @@ -510,6 +556,11 @@ program .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( + "--delete-linked", + "Also delete memories linked to this memory.", + false, + ) .option("-u, --user-id ", "Scope to user.") .option("--agent-id ", "Scope to agent.") .option("--app-id ", "Scope to app.") @@ -563,6 +614,7 @@ program output, dryRun: opts.dryRun, force: opts.force, + deleteLinked: opts.deleteLinked, }); return; } diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts index fa14849e7..84dffb1fe 100644 --- a/cli/node/src/output.ts +++ b/cli/node/src/output.ts @@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown { } case "search": return (data as Record[]).map((r) => - pick(r, ["id", "memory", "score", "created_at", "categories"]), + pick(r, [ + "id", + "memory", + "score", + "created_at", + "categories", + "expiration_date", + ]), ); case "list": return (data as Record[]).map((r) => - pick(r, ["id", "memory", "created_at", "categories"]), + pick(r, [ + "id", + "memory", + "created_at", + "categories", + "expiration_date", + ]), ); case "get": { const r = data as Record; @@ -297,11 +310,12 @@ export function sanitizeAgentData(command: string, data: unknown): unknown { "updated_at", "categories", "metadata", + "expiration_date", ]); } case "update": { const r = data as Record; - return pick(r, ["id", "memory"]); + return pick(r, ["id", "memory", "expiration_date"]); } case "delete": case "delete-all": diff --git a/cli/node/src/state.ts b/cli/node/src/state.ts index 724068925..03ace9a4b 100644 --- a/cli/node/src/state.ts +++ b/cli/node/src/state.ts @@ -3,6 +3,8 @@ * read by commands and branding functions. */ +import fs from "node:fs"; + let _agentMode = false; let _currentCommand = ""; let _pendingNotice = ""; @@ -38,3 +40,14 @@ export function takeNotice(): string { _pendingNotice = ""; return msg; } + +/** True only when stdin is an actual pipe or file redirect (never in agent mode). */ +export function stdinIsPiped(): boolean { + if (isAgentMode()) return false; + try { + const stat = fs.fstatSync(0); + return stat.isFIFO() || stat.isFile(); + } catch { + return false; + } +} diff --git a/cli/node/tests/commands.test.ts b/cli/node/tests/commands.test.ts index 4cd43d1be..02789f056 100644 --- a/cli/node/tests/commands.test.ts +++ b/cli/node/tests/commands.test.ts @@ -2,11 +2,11 @@ * Tests for CLI commands using mock backend. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; -import { createMockBackend } from "./setup.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Backend } from "../src/backend/base.js"; import { setAgentMode } from "../src/state.js"; +import { createMockBackend } from "./setup.js"; let mockBackend: Backend; @@ -17,450 +17,657 @@ 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"; - }; + 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; - setAgentMode(false); + console.log = originalLog; + console.error = originalError; + setAgentMode(false); }); 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, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledOnce(); - }); + it("adds text memory", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "I prefer dark mode", { + userId: "alice", + immutable: 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, - 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, + 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, - output: "json", - }); - expect(output).toContain("results"); - }); + it("outputs json format", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: 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, - output: "quiet", - }); - expect(output).not.toContain("dark mode"); - }); + it("quiet mode produces no memory content", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "quiet", + }); + expect(output).not.toContain("dark mode"); + }); }); describe("cmdAdd forwards --no-infer (regression for #5261)", () => { - it("forwards infer: false when --no-infer is set", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - // `infer: false` is the shape Commander produces for `--no-infer`. - await cmdAdd(mockBackend, "store me verbatim", { - userId: "alice", - immutable: false, - infer: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledWith( - "store me verbatim", - undefined, - expect.objectContaining({ infer: false }), - ); - }); + it("forwards infer: false when --no-infer is set", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "store me verbatim", { + userId: "alice", + immutable: false, + infer: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "store me verbatim", + undefined, + expect.objectContaining({ infer: false }), + ); + }); - it("forwards infer: true by default (flag absent)", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "infer me", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledWith( - "infer me", - undefined, - expect.objectContaining({ infer: true }), - ); - }); + it("forwards infer: true by default (flag absent)", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "infer me", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "infer me", + undefined, + expect.objectContaining({ infer: true }), + ); + }); - it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => { - // Pins the assumption the fix relies on: Commander's `--no-X` option - // populates the positive camelCase key (`infer`), never `noInfer`. - const withFlag = new Command(); - withFlag.option("--no-infer", "Skip inference, store raw.").action(() => {}); - withFlag.parse(["--no-infer"], { from: "user" }); - expect(withFlag.opts().infer).toBe(false); - expect(withFlag.opts().noInfer).toBeUndefined(); + it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => { + const withFlag = new Command(); + withFlag + .option("--no-infer", "Skip inference, store raw.") + .action(() => {}); + withFlag.parse(["--no-infer"], { from: "user" }); + expect(withFlag.opts().infer).toBe(false); + expect(withFlag.opts().noInfer).toBeUndefined(); - const withoutFlag = new Command(); - withoutFlag.option("--no-infer", "Skip inference, store raw.").action(() => {}); - withoutFlag.parse([], { from: "user" }); - expect(withoutFlag.opts().infer).toBe(true); - }); + const withoutFlag = new Command(); + withoutFlag + .option("--no-infer", "Skip inference, store raw.") + .action(() => {}); + withoutFlag.parse([], { from: "user" }); + expect(withoutFlag.opts().infer).toBe(true); + }); +}); + +describe("cmdAdd forwards MEM-5893 option-parity flags", () => { + it("forwards customInstructions, customCategories, structuredDataSchema, timestamp", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + customInstructions: "Extract only preferences.", + customCategories: JSON.stringify([{ prefs: "user preferences" }]), + structuredDataSchema: JSON.stringify({ type: "object" }), + timestamp: 1700000000, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "test", + undefined, + expect.objectContaining({ + customInstructions: "Extract only preferences.", + customCategories: [{ prefs: "user preferences" }], + structuredDataSchema: { type: "object" }, + timestamp: 1700000000, + }), + ); + }); + + it("regression: metadata, expires, and customCategories reach the backend together", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + metadata: JSON.stringify({ source: "test" }), + expires: "2099-01-01", + customCategories: JSON.stringify([{ prefs: "user preferences" }]), + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "test", + undefined, + expect.objectContaining({ + metadata: { source: "test" }, + expires: "2099-01-01", + customCategories: [{ prefs: "user preferences" }], + }), + ); + }); +}); + +describe("cmdAdd rejects invalid input before calling the backend (MEM-5893)", () => { + function mockProcessExit() { + return vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + } + + it("rejects --categories with a message pointing to --custom-categories", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + categories: "prefs", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--custom-categories"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("rejects invalid JSON in --custom-categories", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + customCategories: "not-json", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--custom-categories"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("rejects invalid JSON in --structured-data-schema", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + structuredDataSchema: "not-json", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--structured-data-schema"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); }); describe("cmdAdd deduplicates PENDING", () => { - const DUPLICATE_PENDING = { - results: [ - { status: "PENDING", event_id: "evt-dup" }, - { status: "PENDING", event_id: "evt-dup" }, - ], - }; + const DUPLICATE_PENDING = { + results: [ + { status: "PENDING", event_id: "evt-dup" }, + { status: "PENDING", event_id: "evt-dup" }, + ], + }; - it("text shows one pending block", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(output.match(/Queued/g)?.length).toBe(1); - }); + it("text shows one pending block", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(output.match(/Queued/g)?.length).toBe(1); + }); - it("json shows one pending entry", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "json", - }); - const data = JSON.parse(output); - const pending = data.results.filter((r: Record) => r.status === "PENDING"); - expect(pending).toHaveLength(1); - }); + it("json shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "json", + }); + const data = JSON.parse(output); + const pending = data.results.filter( + (r: Record) => r.status === "PENDING", + ); + expect(pending).toHaveLength(1); + }); - it("agent shows one pending entry", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - setAgentMode(true); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "agent", - }); - const data = JSON.parse(output); - expect(data.count).toBe(1); - expect(data.data).toHaveLength(1); - }); + it("agent shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "agent", + }); + const data = JSON.parse(output); + expect(data.count).toBe(1); + expect(data.data).toHaveLength(1); + }); }); describe("cmdSearch", () => { - 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, + 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, - output: "text", - }); - expect(output).toContain("Found 2"); - }); + 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, + 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, - output: "json", - }); - expect(output).toContain("memory"); - }); + output: "json", + }); + expect(output).toContain("memory"); + }); - it("shows no results message", async () => { - (mockBackend.search as ReturnType).mockResolvedValue([]); - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "nonexistent", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("shows no results message", async () => { + (mockBackend.search as ReturnType).mockResolvedValue([]); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "nonexistent", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "text", - }); - expect(errOutput).toContain("No memories found"); - }); + output: "text", + }); + expect(errOutput).toContain("No memories found"); + }); + + it("forwards showExpired, referenceDate, latestOnly (MEM-5893)", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + output: "text", + }); + expect(mockBackend.search).toHaveBeenCalledWith( + "preferences", + expect.objectContaining({ + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + }), + ); + }); }); 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 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"); - }); + 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, + it("lists in table mode", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "table", - }); - expect(output).toContain("dark mode"); - }); + output: "table", + }); + expect(output).toContain("dark mode"); + }); - it("shows empty message", async () => { - (mockBackend.listMemories as ReturnType).mockResolvedValue([]); - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("shows empty message", async () => { + (mockBackend.listMemories as ReturnType).mockResolvedValue( + [], + ); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "text", - }); - expect(errOutput).toContain("No memories found"); - }); + output: "text", + }); + expect(errOutput).toContain("No memories found"); + }); + + it("forwards showExpired and latestOnly (MEM-5893)", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, + showExpired: true, + latestOnly: true, + output: "text", + }); + expect(mockBackend.listMemories).toHaveBeenCalledWith( + expect.objectContaining({ showExpired: true, latestOnly: true }), + ); + }); }); 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"); - }); + 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"); + }); + + it("forwards expires and timestamp (MEM-5893)", async () => { + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "New text", { + expires: "2099-01-01", + timestamp: 1700000000, + output: "text", + }); + expect(mockBackend.update).toHaveBeenCalledWith( + "abc-123", + "New text", + undefined, + expect.objectContaining({ + expirationDate: "2099-01-01", + timestamp: 1700000000, + }), + ); + }); }); 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"); - }); + it("deletes memory", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "text" }); + expect(output.toLowerCase()).toContain("deleted"); + }); + + it("forwards deleteLinked (MEM-5893)", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { + deleteLinked: true, + output: "text", + }); + expect(mockBackend.delete).toHaveBeenCalledWith( + "abc-123", + expect.objectContaining({ deleteLinked: true }), + ); + }); }); 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"); - }); + 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("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 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"); - }); + it("lists in json mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "json" }); + expect(output).toContain("alice"); + }); }); describe("cmdEventList", () => { - it("lists events in table mode", async () => { - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "table" }); - expect(output).toContain("evt-abc-"); - expect(output).toContain("ADD"); - expect(output).toContain("SUCCEEDED"); - }); + it("lists events in table mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect(output).toContain("evt-abc-"); + expect(output).toContain("ADD"); + expect(output).toContain("SUCCEEDED"); + }); - it("lists events in json mode", async () => { - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "json" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("evt-def-456-ghi-789"); - }); + it("lists events in json mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "json" }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("evt-def-456-ghi-789"); + }); - it("shows empty message when no events", async () => { - (mockBackend.listEvents as ReturnType).mockResolvedValueOnce([]); - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "table" }); - expect((output + errOutput).toLowerCase()).toContain("no events"); - }); + it("shows empty message when no events", async () => { + (mockBackend.listEvents as ReturnType).mockResolvedValueOnce( + [], + ); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect((output + errOutput).toLowerCase()).toContain("no events"); + }); }); describe("cmdEventStatus", () => { - it("shows event details in text mode", async () => { - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("SUCCEEDED"); - }); + it("shows event details in text mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "text", + }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("SUCCEEDED"); + }); - it("shows event details in json mode", async () => { - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("ADD"); - }); + it("shows event details in json mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "json", + }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("ADD"); + }); }); describe("agent mode", () => { - it("cmdAdd outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test preference", { - userId: "alice", - immutable: false, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("add"); - expect(parsed.data).toBeDefined(); - expect(parsed.scope).toMatchObject({ user_id: "alice" }); - expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort()); - }); + it("cmdAdd outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test preference", { + userId: "alice", + immutable: false, + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("add"); + expect(parsed.data).toBeDefined(); + expect(parsed.scope).toMatchObject({ user_id: "alice" }); + expect(Object.keys(parsed.data[0]).sort()).toEqual( + ["event", "id", "memory"].sort(), + ); + }); - it("cmdSearch outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("cmdSearch outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("search"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - const keys = Object.keys(parsed.data[0]); - expect(keys).toContain("id"); - expect(keys).toContain("memory"); - expect(keys).toContain("score"); - expect(keys).toContain("created_at"); - expect(keys).toContain("categories"); - expect(keys).not.toContain("user_id"); - expect(keys).not.toContain("agent_id"); - }); + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("search"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + const keys = Object.keys(parsed.data[0]); + expect(keys).toContain("id"); + expect(keys).toContain("memory"); + expect(keys).toContain("score"); + expect(keys).toContain("created_at"); + expect(keys).toContain("categories"); + expect(keys).not.toContain("user_id"); + expect(keys).not.toContain("agent_id"); + }); - it("cmdList outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("cmdList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("list"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]); - }); + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual([ + "categories", + "created_at", + "id", + "memory", + ]); + }); - it("cmdGet outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("get"); - expect(parsed.data).toBeDefined(); - expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); - expect(Object.keys(parsed.data)).not.toContain("user_id"); - }); + it("cmdGet outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("get"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); + expect(Object.keys(parsed.data)).not.toContain("user_id"); + }); - it("cmdUpdate outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdUpdate } = await import("../src/commands/memory.js"); - await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("update"); - expect(parsed.data).toBeDefined(); - }); + it("cmdUpdate outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "Updated text", { + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("update"); + expect(parsed.data).toBeDefined(); + }); - it("cmdDelete outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdDelete } = await import("../src/commands/memory.js"); - await cmdDelete(mockBackend, "abc-123", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("delete"); - expect(parsed.data).toBeDefined(); - }); + it("cmdDelete outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("delete"); + expect(parsed.data).toBeDefined(); + }); - it("cmdEventList outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("event list"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - expect(Object.keys(parsed.data[0]).sort()).toEqual( - ["created_at", "event_type", "id", "latency", "status"], - ); - expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); - }); + it("cmdEventList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual([ + "created_at", + "event_type", + "id", + "latency", + "status", + ]); + expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); + }); - it("cmdEventStatus outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("event status"); - expect(parsed.data).toBeDefined(); - expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); - expect(parsed.data.results[0]).toHaveProperty("memory"); - expect(parsed.data.results[0]).not.toHaveProperty("data"); - }); + it("cmdEventStatus outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event status"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); + expect(parsed.data.results[0]).toHaveProperty("memory"); + expect(parsed.data.results[0]).not.toHaveProperty("data"); + }); }); diff --git a/cli/node/tests/option-parity.test.ts b/cli/node/tests/option-parity.test.ts new file mode 100644 index 000000000..72f46a5f2 --- /dev/null +++ b/cli/node/tests/option-parity.test.ts @@ -0,0 +1,133 @@ +/** + * Drift test: every documented v3 add/search/list param must be reachable from the Node CLI. + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const OPENAPI_PATH = path.join( + __dirname, + "..", + "..", + "..", + "docs", + "openapi.json", +); + +const KNOWN_UNSURFACED: Record> = {}; + +const ADD_MAPPING: Record = { + messages: ["--messages", "--file", "text"], + user_id: ["--user-id"], + agent_id: ["--agent-id"], + run_id: ["--run-id"], + metadata: ["--metadata"], + expiration_date: ["--expires"], + custom_instructions: ["--custom-instructions"], + custom_categories: ["--custom-categories"], + infer: ["--no-infer"], +}; + +const SEARCH_MAPPING: Record = { + query: ["query"], + filters: ["--filter", "--user-id", "--agent-id", "--run-id"], + show_expired: ["--show-expired"], + top_k: ["--top-k"], + threshold: ["--threshold"], + rerank: ["--rerank"], + reference_date: ["--reference-date"], +}; + +const LIST_MAPPING: Record = { + filters: [ + "--user-id", + "--agent-id", + "--run-id", + "--category", + "--after", + "--before", + ], + show_expired: ["--show-expired"], +}; + +function documentedFields(endpoint: string): string[] { + const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const schema = + spec.paths[endpoint].post.requestBody.content["application/json"].schema; + return Object.keys(schema.properties); +} + +function helpText(command: string): string { + return execSync(`npx tsx src/index.ts ${command} --help`, { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + timeout: 15000, + }); +} + +function assertAllReachable( + endpoint: string, + mapping: Record, + command: string, +) { + const documented = documentedFields(endpoint); + const help = helpText(command); + for (const field of documented) { + if (KNOWN_UNSURFACED[endpoint]?.[field]) continue; + const candidates = mapping[field]; + expect( + candidates, + `${endpoint}: documented field "${field}" has no mapping entry for command "${command}"`, + ).toBeDefined(); + const reachable = candidates.some((flag) => + flag.startsWith("--") ? help.includes(flag) : true, + ); + expect( + reachable, + `${endpoint}: documented field "${field}" not reachable via any of ${JSON.stringify(candidates)} on command "${command}"`, + ).toBe(true); + } +} + +describe("Option parity: Node CLI reachability of documented v3 params", () => { + it("add covers documented fields", () => { + assertAllReachable("/v3/memories/add/", ADD_MAPPING, "add"); + }); + + it("search covers documented fields", () => { + assertAllReachable("/v3/memories/search/", SEARCH_MAPPING, "search"); + }); + + it("list covers documented fields", () => { + assertAllReachable("/v3/memories/", LIST_MAPPING, "list"); + }); +}); + +describe("stdin fallback uses the shared piped-stdin guard", () => { + const SOURCES = ["src/index.ts", "src/commands/memory.ts"]; + + for (const rel of SOURCES) { + it(`${rel} never checks process.stdin.isTTY directly`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8"); + expect( + src.includes("process.stdin.isTTY"), + `${rel}: use stdinIsPiped() from state.ts. A bare !isTTY check is also true for /dev/null and sockets, so readFileSync(0) crashes with EAGAIN in scripts, CI, and agent mode.`, + ).toBe(false); + }); + + it(`${rel} guards every readFileSync(0) with stdinIsPiped()`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8"); + const lines = src.split("\n"); + for (const [i, line] of lines.entries()) { + if (!line.includes("readFileSync(0")) continue; + const guard = lines.slice(Math.max(0, i - 3), i).join("\n"); + expect( + guard.includes("stdinIsPiped()"), + `${rel}:${i + 1}: readFileSync(0) must be guarded by stdinIsPiped()`, + ).toBe(true); + } + }); + } +}); diff --git a/cli/node/tests/platform-backend.test.ts b/cli/node/tests/platform-backend.test.ts index cb577a638..62c71305a 100644 --- a/cli/node/tests/platform-backend.test.ts +++ b/cli/node/tests/platform-backend.test.ts @@ -7,94 +7,231 @@ import { PlatformBackend } from "../src/backend/platform.js"; import { createDefaultConfig } from "../src/config.js"; function makeBackend(): PlatformBackend { - // apiKey/baseUrl only build request headers; every test spies on _request, - // so no real network calls are made. - return new PlatformBackend(createDefaultConfig().platform); + return new PlatformBackend(createDefaultConfig().platform); } function mockFetch() { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: vi.fn().mockReturnValue(null) }, - json: vi.fn().mockResolvedValue({ message: "ok" }), - }); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue(null) }, + json: vi.fn().mockResolvedValue({ message: "ok" }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; } beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("deleteEntities", () => { - it("returns all results keyed by entity type for a multi-entity delete", async () => { - const backend = makeBackend(); - const responses: Record = { - "/v2/entities/user/alice/": { message: "user deleted" }, - "/v2/entities/agent/bob/": { message: "agent deleted" }, - }; - const spy = vi - // biome-ignore lint/suspicious/noExplicitAny: spying on a private method - .spyOn(backend as any, "_request") - .mockImplementation(async (_method: string, path: string) => responses[path]); + it("returns all results keyed by entity type for a multi-entity delete", async () => { + const backend = makeBackend(); + const responses: Record = { + "/v2/entities/user/alice/": { message: "user deleted" }, + "/v2/entities/agent/bob/": { message: "agent deleted" }, + }; + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockImplementation( + async (_method: string, path: string) => responses[path], + ); - const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" }); + const result = await backend.deleteEntities({ + userId: "alice", + agentId: "bob", + }); - // Regression: previously only the last entity's response survived. - expect(result).toEqual({ - user: { message: "user deleted" }, - agent: { message: "agent deleted" }, - }); - expect(spy).toHaveBeenCalledTimes(2); - }); + expect(result).toEqual({ + user: { message: "user deleted" }, + agent: { message: "agent deleted" }, + }); + expect(spy).toHaveBeenCalledTimes(2); + }); - it("keys a single-entity delete by its type", async () => { - const backend = makeBackend(); - // biome-ignore lint/suspicious/noExplicitAny: spying on a private method - vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" }); + it("keys a single-entity delete by its type", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + vi.spyOn(backend as any, "_request").mockResolvedValue({ + message: "user deleted", + }); - const result = await backend.deleteEntities({ userId: "alice" }); - expect(result).toEqual({ user: { message: "user deleted" } }); - }); + const result = await backend.deleteEntities({ userId: "alice" }); + expect(result).toEqual({ user: { message: "user deleted" } }); + }); - it("throws when no entity id is provided", async () => { - const backend = makeBackend(); - await expect(backend.deleteEntities({})).rejects.toThrow( - "At least one entity ID is required", - ); - }); + it("throws when no entity id is provided", async () => { + const backend = makeBackend(); + await expect(backend.deleteEntities({})).rejects.toThrow( + "At least one entity ID is required", + ); + }); +}); + +describe("PlatformBackend option-parity payloads (MEM-5893)", () => { + it("add: custom_instructions, custom_categories, structured_data_schema, timestamp reach the payload alongside existing fields", async () => { + const backend = makeBackend(); + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockResolvedValue({ results: [] }); + + await backend.add("hello", undefined, { + userId: "alice", + metadata: { source: "test" }, + expires: "2099-01-01", + customInstructions: "Extract only preferences.", + customCategories: [{ prefs: "user preferences" }], + structuredDataSchema: { type: "object" }, + timestamp: 1700000000, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.custom_instructions).toBe("Extract only preferences."); + expect(payload.custom_categories).toEqual([{ prefs: "user preferences" }]); + expect(payload.structured_data_schema).toEqual({ type: "object" }); + expect(payload.timestamp).toBe(1700000000); + expect(payload.metadata).toEqual({ source: "test" }); + expect(payload.expiration_date).toBe("2099-01-01"); + }); + + it("add: omitted optional fields are absent from the payload", async () => { + const backend = makeBackend(); + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockResolvedValue({ results: [] }); + + await backend.add("hello", undefined, { userId: "alice" }); + + const payload = spy.mock.calls[0][2].json; + expect(payload).not.toHaveProperty("custom_instructions"); + expect(payload).not.toHaveProperty("custom_categories"); + expect(payload).not.toHaveProperty("structured_data_schema"); + expect(payload).not.toHaveProperty("timestamp"); + }); + + it("search: show_expired, reference_date, latest_only reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", { + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.show_expired).toBe(true); + expect(payload.reference_date).toBe("2024-01-01"); + expect(payload.latest_only).toBe(true); + }); + + it("search: keyword_search and fields reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", { + keyword: true, + fields: ["memory", "score"], + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.keyword_search).toBe(true); + expect(payload.fields).toEqual(["memory", "score"]); + }); + + it("search: omitted keyword and fields are absent from the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", {}); + + const payload = spy.mock.calls[0][2].json; + expect(payload).not.toHaveProperty("keyword_search"); + expect(payload).not.toHaveProperty("fields"); + }); + + it("listMemories: show_expired and latest_only are top-level, not nested inside filters", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.listMemories({ + userId: "alice", + showExpired: true, + latestOnly: true, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.show_expired).toBe(true); + expect(payload.latest_only).toBe(true); + expect(payload.filters ?? {}).not.toHaveProperty("show_expired"); + expect(payload.filters ?? {}).not.toHaveProperty("latest_only"); + }); + + it("update: expiration_date and timestamp reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({}); + + await backend.update("mem-123", undefined, undefined, { + expirationDate: "2099-01-01", + timestamp: 1700000000, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.expiration_date).toBe("2099-01-01"); + expect(payload.timestamp).toBe(1700000000); + }); + + it("delete: delete_linked is a query param, not part of the JSON body", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({}); + + await backend.delete("mem-123", { deleteLinked: true }); + + const opts = spy.mock.calls[0][2]; + expect(opts.params.delete_linked).toBe("true"); + expect(opts.json).toBeUndefined(); + }); }); describe("PlatformBackend path encoding", () => { - it("encodes memory IDs before interpolating them into paths", async () => { - const fetchMock = mockFetch(); - const backend = makeBackend(); + it("encodes memory IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); - await backend.get("mem/a?b#c"); - await backend.update("mem/a?b#c", "updated"); - await backend.delete("mem/a?b#c"); + await backend.get("mem/a?b#c"); + await backend.update("mem/a?b#c", "updated"); + await backend.delete("mem/a?b#c"); - const urls = fetchMock.mock.calls.map((call) => call[0]); - expect(urls).toEqual([ - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", - ]); - }); + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + ]); + }); - it("encodes entity and event IDs before interpolating them into paths", async () => { - const fetchMock = mockFetch(); - const backend = makeBackend(); + it("encodes entity and event IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); - await backend.deleteEntities({ userId: "org/team?active#frag" }); - await backend.getEvent("evt/a?b#c"); + await backend.deleteEntities({ userId: "org/team?active#frag" }); + await backend.getEvent("evt/a?b#c"); - const urls = fetchMock.mock.calls.map((call) => call[0]); - expect(urls).toEqual([ - "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", - "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", - ]); - }); + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", + "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", + ]); + }); }); diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 850bfa0cd..9eb4f58ef 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -273,7 +273,21 @@ def add( no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."), expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), categories: str | None = typer.Option( - None, "--categories", help="Categories (JSON array or comma-separated)." + None, "--categories", help="Not supported on add, use --custom-categories instead." + ), + custom_instructions: str | None = typer.Option( + None, "--custom-instructions", help="Custom instructions for fact extraction." + ), + custom_categories: str | None = typer.Option( + None, + "--custom-categories", + help="Custom categories as a JSON array of {name: description} objects.", + ), + structured_data_schema: str | None = typer.Option( + None, "--structured-data-schema", help="Schema for structured data extraction, as JSON." + ), + timestamp: int | None = typer.Option( + None, "--timestamp", help="Unix timestamp for the memory." ), output: str = typer.Option( "text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output" @@ -312,6 +326,10 @@ def add( no_infer=no_infer, expires=expires, categories=categories, + custom_instructions=custom_instructions, + custom_categories=custom_categories, + structured_data_schema=structured_data_schema, + timestamp=timestamp, output=output, ) @@ -355,6 +373,21 @@ def search( help="Specific fields to return (comma-separated).", rich_help_panel="Search", ), + show_expired: bool = typer.Option( + False, "--show-expired", help="Include expired memories.", rich_help_panel="Search" + ), + reference_date: str | None = typer.Option( + None, + "--reference-date", + help="Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + rich_help_panel="Search", + ), + latest_only: bool = typer.Option( + False, + "--latest-only", + help="Only return the latest version of each memory.", + rich_help_panel="Search", + ), output: str = typer.Option( "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" ), @@ -398,6 +431,9 @@ def search( keyword=keyword, filter_json=filter_json, fields=fields, + show_expired=show_expired, + reference_date=reference_date, + latest_only=latest_only, output=output, ) @@ -464,6 +500,15 @@ def list_cmd( before: str | None = typer.Option( None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters" ), + show_expired: bool = typer.Option( + False, "--show-expired", help="Include expired memories.", rich_help_panel="Filters" + ), + latest_only: bool = typer.Option( + False, + "--latest-only", + help="Only return the latest version of each memory.", + rich_help_panel="Filters", + ), output: str = typer.Option( "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" ), @@ -497,6 +542,8 @@ def list_cmd( category=category, after=after, before=before, + show_expired=show_expired, + latest_only=latest_only, output=output, ) @@ -509,6 +556,10 @@ def update( memory_id: str = typer.Argument(..., help="Memory ID to update."), text: str | None = typer.Argument(None, help="New memory text."), metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."), + expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), + timestamp: int | None = typer.Option( + None, "--timestamp", help="Unix timestamp for the memory." + ), output: str = typer.Option( "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" ), @@ -537,7 +588,15 @@ def update( text = _read_stdin() backend = _get_backend(api_key, base_url) - cmd_update(backend, memory_id, text, metadata=metadata, output=output) + cmd_update( + backend, + memory_id, + text, + metadata=metadata, + expires=expires, + timestamp=timestamp, + output=output, + ) # ── Memory: delete ──────────────────────────────────────────────────────── @@ -559,6 +618,9 @@ def delete( False, "--dry-run", help="Show what would be deleted without deleting." ), force: bool = typer.Option(False, "--force", help="Skip confirmation."), + delete_linked: bool = typer.Option( + False, "--delete-linked", help="Also delete memories linked to this memory." + ), user_id: str | None = typer.Option( None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" ), @@ -616,7 +678,14 @@ def delete( from mem0_cli.commands.memory import cmd_delete backend = _get_backend(api_key, base_url) - cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output) + cmd_delete( + backend, + memory_id, + dry_run=dry_run, + force=force, + delete_linked=delete_linked, + output=output, + ) elif all_: _fire_telemetry("delete", {"delete_mode": "all"}) @@ -1068,7 +1137,11 @@ def _build_help_json() -> dict: "--immutable": "Prevent future updates.", "--no-infer": "Skip inference, store raw.", "--expires": "Expiration date (YYYY-MM-DD).", - "--categories": "Categories (JSON array or comma-separated).", + "--categories": "Not supported on add, use --custom-categories instead.", + "--custom-instructions": "Custom instructions for fact extraction.", + "--custom-categories": "Custom categories as a JSON array of {name: description} objects.", + "--structured-data-schema": "Schema for structured data extraction, as JSON.", + "--timestamp": "Unix timestamp for the memory.", "--graph": "Enable graph memory extraction.", "--no-graph": "Disable graph memory extraction.", "--output, -o": "Output format: text, json, quiet.", @@ -1087,6 +1160,9 @@ def _build_help_json() -> dict: "--keyword": "Use keyword search instead of semantic.", "--filter": "Advanced filter expression (JSON).", "--fields": "Specific fields to return (comma-separated).", + "--show-expired": "Include expired memories.", + "--reference-date": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + "--latest-only": "Only return the latest version of each memory.", "--graph": "Enable graph in search.", "--no-graph": "Disable graph in search.", "--output, -o": "Output format: text, json, table.", @@ -1110,6 +1186,8 @@ def _build_help_json() -> dict: "--category": "Filter by category.", "--after": "Created after (YYYY-MM-DD).", "--before": "Created before (YYYY-MM-DD).", + "--show-expired": "Include expired memories.", + "--latest-only": "Only return the latest version of each memory.", "--graph": "Enable graph in listing.", "--no-graph": "Disable graph in listing.", "--output, -o": "Output format: text, json, table.", @@ -1124,6 +1202,8 @@ def _build_help_json() -> dict: }, "options": { "--metadata, -m": "Update metadata (JSON).", + "--expires": "Expiration date (YYYY-MM-DD).", + "--timestamp": "Unix timestamp for the memory.", "--output, -o": "Output format: text, json, quiet.", }, }, @@ -1140,6 +1220,7 @@ def _build_help_json() -> dict: "--all": "Delete all memories matching scope filters.", "--entity": "Delete the entity itself and all its memories (cascade).", "--project": "With --all: delete ALL memories project-wide.", + "--delete-linked": "Also delete memories linked to this memory.", "--dry-run": "Show what would be deleted without deleting.", "--force": "Skip confirmation.", "--user-id, -u": "Scope to user.", diff --git a/cli/python/src/mem0_cli/backend/base.py b/cli/python/src/mem0_cli/backend/base.py index 183e0bfa1..e5788b056 100644 --- a/cli/python/src/mem0_cli/backend/base.py +++ b/cli/python/src/mem0_cli/backend/base.py @@ -25,7 +25,10 @@ class Backend(ABC): immutable: bool = False, infer: bool = True, expires: str | None = None, - categories: list[str] | None = None, + custom_instructions: str | None = None, + custom_categories: list[dict] | None = None, + structured_data_schema: dict | None = None, + timestamp: int | None = None, ) -> dict: ... @abstractmethod @@ -43,6 +46,9 @@ class Backend(ABC): keyword: bool = False, filters: dict | None = None, fields: list[str] | None = None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, ) -> list[dict]: ... @abstractmethod @@ -61,11 +67,19 @@ class Backend(ABC): category: str | None = None, after: str | None = None, before: str | None = None, + show_expired: bool = False, + latest_only: bool = False, ) -> list[dict]: ... @abstractmethod def update( - self, memory_id: str, content: str | None = None, metadata: dict | None = None + self, + memory_id: str, + content: str | None = None, + metadata: dict | None = None, + *, + expiration_date: str | None = None, + timestamp: int | None = None, ) -> dict: ... @abstractmethod @@ -78,6 +92,7 @@ class Backend(ABC): agent_id: str | None = None, app_id: str | None = None, run_id: str | None = None, + delete_linked: bool = False, ) -> dict: ... @abstractmethod diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 1df3deadd..083c804e6 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -87,7 +87,10 @@ class PlatformBackend(Backend): immutable: bool = False, infer: bool = True, expires: str | None = None, - categories: list[str] | None = None, + custom_instructions: str | None = None, + custom_categories: list[dict] | None = None, + structured_data_schema: dict | None = None, + timestamp: int | None = None, ) -> dict: payload: dict[str, Any] = {} @@ -112,8 +115,14 @@ class PlatformBackend(Backend): payload["infer"] = False if expires: payload["expiration_date"] = expires - if categories: - payload["categories"] = categories + if custom_instructions: + payload["custom_instructions"] = custom_instructions + if custom_categories: + payload["custom_categories"] = custom_categories + if structured_data_schema: + payload["structured_data_schema"] = structured_data_schema + if timestamp is not None: + payload["timestamp"] = timestamp payload["source"] = "CLI" return self._request("POST", "/v3/memories/add/", json=payload) @@ -173,6 +182,9 @@ class PlatformBackend(Backend): keyword: bool = False, filters: dict | None = None, fields: list[str] | None = None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, ) -> list[dict]: payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold} @@ -191,6 +203,12 @@ class PlatformBackend(Backend): payload["keyword_search"] = True if fields: payload["fields"] = fields + if show_expired: + payload["show_expired"] = True + if reference_date is not None: + payload["reference_date"] = reference_date + if latest_only: + payload["latest_only"] = True payload["source"] = "CLI" result = self._request("POST", "/v3/memories/search/", json=payload) @@ -219,6 +237,8 @@ class PlatformBackend(Backend): category: str | None = None, after: str | None = None, before: str | None = None, + show_expired: bool = False, + latest_only: bool = False, ) -> list[dict]: payload: dict[str, Any] = {} params = {"page": str(page), "page_size": str(page_size)} @@ -241,6 +261,10 @@ class PlatformBackend(Backend): ) if api_filters: payload["filters"] = api_filters + if show_expired: + payload["show_expired"] = True + if latest_only: + payload["latest_only"] = True payload["source"] = "CLI" result = self._request("POST", "/v3/memories/", json=payload, params=params) @@ -251,13 +275,23 @@ class PlatformBackend(Backend): ) def update( - self, memory_id: str, content: str | None = None, metadata: dict | None = None + self, + memory_id: str, + content: str | None = None, + metadata: dict | None = None, + *, + expiration_date: str | None = None, + timestamp: int | None = None, ) -> dict: payload: dict[str, Any] = {} if content: payload["text"] = content if metadata: payload["metadata"] = metadata + if expiration_date: + payload["expiration_date"] = expiration_date + if timestamp is not None: + payload["timestamp"] = timestamp payload["source"] = "CLI" return self._request( "PUT", @@ -274,6 +308,7 @@ class PlatformBackend(Backend): agent_id: str | None = None, app_id: str | None = None, run_id: str | None = None, + delete_linked: bool = False, ) -> dict: if all: params: dict[str, str] = {"source": "CLI"} @@ -287,10 +322,13 @@ class PlatformBackend(Backend): params["run_id"] = run_id return self._request("DELETE", "/v1/memories/", params=params) elif memory_id: + params = {"source": "CLI"} + if delete_linked: + params["delete_linked"] = "true" return self._request( "DELETE", f"/v1/memories/{_encode_path_segment(memory_id)}/", - params={"source": "CLI"}, + params=params, ) else: raise ValueError("Either memory_id or --all is required") diff --git a/cli/python/src/mem0_cli/commands/memory.py b/cli/python/src/mem0_cli/commands/memory.py index 7e9ace6e4..315cde280 100644 --- a/cli/python/src/mem0_cli/commands/memory.py +++ b/cli/python/src/mem0_cli/commands/memory.py @@ -4,9 +4,11 @@ from __future__ import annotations import json import os +import re import stat as _stat_mod import sys import time as _time +from datetime import date from pathlib import Path import typer @@ -47,6 +49,18 @@ def _stdin_is_piped() -> bool: return False +def _validate_expires(value: str) -> None: + """Exit 1 if value is not a future YYYY-MM-DD date.""" + if not re.match(r"^\d{4}-\d{2}-\d{2}$", value): + print_error( + err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)." + ) + raise typer.Exit(1) + if date.fromisoformat(value) <= date.today(): + print_error(err_console, "--expires date must be in the future.") + raise typer.Exit(1) + + def cmd_add( backend: Backend, text: str | None, @@ -62,6 +76,10 @@ def cmd_add( no_infer: bool, expires: str | None, categories: str | None, + custom_instructions: str | None = None, + custom_categories: str | None = None, + structured_data_schema: str | None = None, + timestamp: int | None = None, output: str = "text", ) -> None: """Add a memory.""" @@ -70,6 +88,13 @@ def cmd_add( set_current_command("add") if is_agent_mode(): output = "agent" + + if categories: + print_error( + err_console, "--categories is not supported on add. Use --custom-categories instead." + ) + raise typer.Exit(1) + msgs = None content = text @@ -108,27 +133,24 @@ def cmd_add( print_error(err_console, "Invalid JSON in --metadata.") raise typer.Exit(1) from None - cats = None - if categories: + custom_cats = None + if custom_categories: try: - cats = json.loads(categories) + custom_cats = json.loads(custom_categories) except json.JSONDecodeError: - cats = [c.strip() for c in categories.split(",")] + print_error(err_console, "Invalid JSON in --custom-categories.") + raise typer.Exit(1) from None + + schema = None + if structured_data_schema: + try: + schema = json.loads(structured_data_schema) + except json.JSONDecodeError: + print_error(err_console, "Invalid JSON in --structured-data-schema.") + raise typer.Exit(1) from None - # 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) + _validate_expires(expires) with timed_status(err_console, "Adding memory...") as ts: try: @@ -143,7 +165,10 @@ def cmd_add( immutable=immutable, infer=not no_infer, expires=expires, - categories=cats, + custom_instructions=custom_instructions, + custom_categories=custom_cats, + structured_data_schema=schema, + timestamp=timestamp, ) except Exception as e: ts.error_msg = str(e) @@ -224,6 +249,9 @@ def cmd_search( keyword: bool, filter_json: str | None, fields: str | None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, output: str = "text", ) -> None: """Search memories.""" @@ -266,6 +294,9 @@ def cmd_search( keyword=keyword, filters=filters, fields=field_list, + show_expired=show_expired, + reference_date=reference_date, + latest_only=latest_only, ) except Exception as e: print_error(err_console, str(e)) @@ -352,6 +383,8 @@ def cmd_list( category: str | None, after: str | None, before: str | None, + show_expired: bool = False, + latest_only: bool = False, output: str = "table", ) -> None: """List memories.""" @@ -380,6 +413,8 @@ def cmd_list( category=category, after=after, before=before, + show_expired=show_expired, + latest_only=latest_only, ) except Exception as e: print_error(err_console, str(e)) @@ -446,6 +481,8 @@ def cmd_update( text: str | None, *, metadata: str | None, + expires: str | None = None, + timestamp: int | None = None, output: str, ) -> None: """Update a memory.""" @@ -462,10 +499,19 @@ def cmd_update( print_error(err_console, "Invalid JSON in --metadata.") raise typer.Exit(1) from None + if expires: + _validate_expires(expires) + _start = _time.perf_counter() with timed_status(err_console, "Updating memory...") as _ts: try: - result = backend.update(memory_id, content=text, metadata=meta) + result = backend.update( + memory_id, + content=text, + metadata=meta, + expiration_date=expires, + timestamp=timestamp, + ) except Exception as e: print_error(err_console, str(e)) raise typer.Exit(1) from None @@ -490,6 +536,7 @@ def cmd_delete( *, dry_run: bool = False, force: bool = False, + delete_linked: bool = False, output: str, ) -> None: """Delete a single memory by ID.""" @@ -512,7 +559,7 @@ def cmd_delete( _start = _time.perf_counter() with timed_status(err_console, "Deleting...") as _ts: try: - result = backend.delete(memory_id=memory_id) + result = backend.delete(memory_id=memory_id, delete_linked=delete_linked) except Exception as e: print_error(err_console, str(e)) raise typer.Exit(1) from None diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py index 8aad44bbb..dc1524d33 100644 --- a/cli/python/src/mem0_cli/output.py +++ b/cli/python/src/mem0_cli/output.py @@ -262,16 +262,32 @@ def sanitize_agent_data(command: str, data: Any) -> Any: return result if command == "search": - return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data] + return [ + pick(r, ["id", "memory", "score", "created_at", "categories", "expiration_date"]) + for r in data + ] if command == "list": - return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data] + return [ + pick(r, ["id", "memory", "created_at", "categories", "expiration_date"]) for r in data + ] if command == "get": - return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"]) + return pick( + data, + [ + "id", + "memory", + "created_at", + "updated_at", + "categories", + "metadata", + "expiration_date", + ], + ) if command == "update": - return pick(data, ["id", "memory"]) + return pick(data, ["id", "memory", "expiration_date"]) if command in ("delete", "delete-all", "entity delete"): return data diff --git a/cli/python/tests/test_commands.py b/cli/python/tests/test_commands.py index 4fd6a0c39..ad900f605 100644 --- a/cli/python/tests/test_commands.py +++ b/cli/python/tests/test_commands.py @@ -252,12 +252,13 @@ class TestAddCommand: ) mock_backend.add.assert_called_once() - def test_add_categories_csv(self, mock_backend): + def test_add_categories_rejected(self, mock_backend): console, _buf = _make_console() - err_console, _err_buf = _make_err_console() + err_console, err_buf = _make_err_console() with ( patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), ): cmd_add( mock_backend, @@ -275,7 +276,93 @@ class TestAddCommand: categories="health,prefs", output="text", ) - mock_backend.add.assert_called_once() + assert "--custom-categories" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_invalid_custom_categories_json(self, mock_backend): + console, _buf = _make_console() + err_console, err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + custom_categories="not-json", + output="text", + ) + assert "--custom-categories" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_invalid_structured_data_schema_json(self, mock_backend): + console, _buf = _make_console() + err_console, err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + structured_data_schema="not-json", + output="text", + ) + assert "--structured-data-schema" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_regression_metadata_expiration_custom_categories_together(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata='{"source": "test"}', + immutable=False, + no_infer=False, + expires="2099-01-01", + categories=None, + custom_categories='[{"prefs": "user preferences"}]', + output="text", + ) + call_kwargs = mock_backend.add.call_args.kwargs + assert call_kwargs["metadata"] == {"source": "test"} + assert call_kwargs["expires"] == "2099-01-01" + assert call_kwargs["custom_categories"] == [{"prefs": "user preferences"}] class TestAddDeduplicatesPending: @@ -464,6 +551,36 @@ class TestSearchCommand: ) mock_backend.search.assert_called_once() + def test_search_new_flags_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "preferences", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + show_expired=True, + reference_date="2024-01-01", + latest_only=True, + output="text", + ) + call_kwargs = mock_backend.search.call_args.kwargs + assert call_kwargs["show_expired"] is True + assert call_kwargs["reference_date"] == "2024-01-01" + assert call_kwargs["latest_only"] is True + class TestGetCommand: def test_get_text(self, mock_backend): @@ -561,6 +678,32 @@ class TestListCommand: output = buf.getvalue() assert "No memories found" in output + def test_list_new_flags_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + show_expired=True, + latest_only=True, + output="table", + ) + call_kwargs = mock_backend.list_memories.call_args.kwargs + assert call_kwargs["show_expired"] is True + assert call_kwargs["latest_only"] is True + class TestUpdateCommand: def test_update(self, mock_backend): @@ -585,6 +728,26 @@ class TestUpdateCommand: output = buf.getvalue() assert '"memory"' in output + def test_update_new_fields_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_update( + mock_backend, + "abc-123", + "New text", + metadata=None, + expires="2099-01-01", + timestamp=1700000000, + output="text", + ) + call_kwargs = mock_backend.update.call_args.kwargs + assert call_kwargs["expiration_date"] == "2099-01-01" + assert call_kwargs["timestamp"] == 1700000000 + class TestDeleteCommand: def test_delete_single(self, mock_backend): @@ -610,6 +773,17 @@ class TestDeleteCommand: assert "dry run" in output.lower() mock_backend.delete.assert_not_called() + def test_delete_linked_reaches_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete(mock_backend, "abc-123", delete_linked=True, output="text") + call_kwargs = mock_backend.delete.call_args.kwargs + assert call_kwargs["delete_linked"] is True + class TestDeleteAllCommand: def test_delete_all_force(self, mock_backend): diff --git a/cli/python/tests/test_option_parity.py b/cli/python/tests/test_option_parity.py new file mode 100644 index 000000000..191cb32f4 --- /dev/null +++ b/cli/python/tests/test_option_parity.py @@ -0,0 +1,78 @@ +"""Drift test: every documented v3 add/search/list param must be reachable from the Python CLI.""" + +import json +from pathlib import Path + +import typer.main + +from mem0_cli.app import app + +REPO_ROOT = Path(__file__).resolve().parents[3] +OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json" + +KNOWN_UNSURFACED: dict[tuple[str, str], str] = {} + +ADD_MAPPING: dict[str, list[str]] = { + "messages": ["messages", "file", "text"], + "user_id": ["user_id"], + "agent_id": ["agent_id"], + "run_id": ["run_id"], + "metadata": ["metadata"], + "expiration_date": ["expires"], + "custom_instructions": ["custom_instructions"], + "custom_categories": ["custom_categories"], + "infer": ["no_infer"], +} + +SEARCH_MAPPING: dict[str, list[str]] = { + "query": ["query"], + "filters": ["filter_json", "user_id", "agent_id", "run_id"], + "show_expired": ["show_expired"], + "top_k": ["top_k"], + "threshold": ["threshold"], + "rerank": ["rerank"], + "reference_date": ["reference_date"], +} + +LIST_MAPPING: dict[str, list[str]] = { + "filters": ["user_id", "agent_id", "run_id", "category", "after", "before"], + "show_expired": ["show_expired"], +} + + +def _documented_fields(endpoint: str) -> set[str]: + spec = json.loads(OPENAPI_PATH.read_text()) + schema = spec["paths"][endpoint]["post"]["requestBody"]["content"]["application/json"]["schema"] + return set(schema["properties"]) + + +def _cli_param_names(command_name: str) -> set[str]: + click_app = typer.main.get_command(app) + command = click_app.commands[command_name] + return {param.name for param in command.params} + + +def _assert_all_reachable(endpoint: str, mapping: dict[str, list[str]], command_name: str) -> None: + documented = _documented_fields(endpoint) + reachable = _cli_param_names(command_name) + for field in documented: + if (endpoint, field) in KNOWN_UNSURFACED: + continue + candidates = mapping.get(field) + assert candidates, ( + f"{endpoint}: documented field {field!r} has no mapping entry for command {command_name!r}" + ) + assert any(candidate in reachable for candidate in candidates), ( + f"{endpoint}: documented field {field!r} not reachable via any of {candidates} on command {command_name!r}" + ) + + +class TestOptionParity: + def test_add_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/add/", ADD_MAPPING, "add") + + def test_search_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/search/", SEARCH_MAPPING, "search") + + def test_list_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/", LIST_MAPPING, "list") diff --git a/cli/python/tests/test_platform_backend_options.py b/cli/python/tests/test_platform_backend_options.py new file mode 100644 index 000000000..9559864d2 --- /dev/null +++ b/cli/python/tests/test_platform_backend_options.py @@ -0,0 +1,109 @@ +"""Tests that the MEM-5893 option-parity flags reach the correct request payload/params.""" + +from __future__ import annotations + +from unittest.mock import patch + +from mem0_cli.backend.platform import PlatformBackend +from mem0_cli.config import PlatformConfig + + +def _make_backend() -> PlatformBackend: + return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai")) + + +class TestAddOptions: + def test_new_fields_and_existing_fields_land_in_payload_together(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={"results": []}) as mock_request: + backend.add( + content="hello", + user_id="alice", + metadata={"source": "test"}, + expires="2099-01-01", + custom_instructions="Extract only preferences.", + custom_categories=[{"prefs": "user preferences"}], + structured_data_schema={"type": "object"}, + timestamp=1700000000, + ) + payload = mock_request.call_args.kwargs["json"] + assert payload["custom_instructions"] == "Extract only preferences." + assert payload["custom_categories"] == [{"prefs": "user preferences"}] + assert payload["structured_data_schema"] == {"type": "object"} + assert payload["timestamp"] == 1700000000 + assert payload["metadata"] == {"source": "test"} + assert payload["expiration_date"] == "2099-01-01" + + def test_omitted_fields_are_absent_from_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={"results": []}) as mock_request: + backend.add(content="hello", user_id="alice") + payload = mock_request.call_args.kwargs["json"] + assert "custom_instructions" not in payload + assert "custom_categories" not in payload + assert "structured_data_schema" not in payload + assert "timestamp" not in payload + + +class TestSearchOptions: + def test_show_expired_reference_date_latest_only_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search( + "query", + show_expired=True, + reference_date="2024-01-01", + latest_only=True, + ) + payload = mock_request.call_args.kwargs["json"] + assert payload["show_expired"] is True + assert payload["reference_date"] == "2024-01-01" + assert payload["latest_only"] is True + + def test_keyword_and_fields_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search("query", keyword=True, fields=["memory", "score"]) + payload = mock_request.call_args.kwargs["json"] + assert payload["keyword_search"] is True + assert payload["fields"] == ["memory", "score"] + + def test_keyword_and_fields_omitted_are_absent_from_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search("query") + payload = mock_request.call_args.kwargs["json"] + assert "keyword_search" not in payload + assert "fields" not in payload + + +class TestListOptions: + def test_show_expired_and_latest_only_are_top_level_not_in_filters(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.list_memories(user_id="alice", show_expired=True, latest_only=True) + payload = mock_request.call_args.kwargs["json"] + assert payload["show_expired"] is True + assert payload["latest_only"] is True + assert "show_expired" not in payload.get("filters", {}) + assert "latest_only" not in payload.get("filters", {}) + + +class TestUpdateOptions: + def test_expires_and_timestamp_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={}) as mock_request: + backend.update("mem-123", expiration_date="2099-01-01", timestamp=1700000000) + payload = mock_request.call_args.kwargs["json"] + assert payload["expiration_date"] == "2099-01-01" + assert payload["timestamp"] == 1700000000 + + +class TestDeleteOptions: + def test_delete_linked_is_a_query_param_not_json_body(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={}) as mock_request: + backend.delete(memory_id="mem-123", delete_linked=True) + call = mock_request.call_args + assert call.kwargs["params"]["delete_linked"] == "true" + assert "json" not in call.kwargs diff --git a/docs/platform/cli.mdx b/docs/platform/cli.mdx index 399216632..f2547997d 100644 --- a/docs/platform/cli.mdx +++ b/docs/platform/cli.mdx @@ -122,12 +122,20 @@ echo "Loves hiking on weekends" | mem0 add --user-id alice | `--messages` | Conversation messages as JSON | | `-f, --file` | Read messages from a JSON file | | `-m, --metadata` | Custom metadata as JSON | -| `--categories` | Categories (JSON array or comma-separated) | +| `--categories` | Rejected on `add`. Use `--custom-categories` instead | +| `--custom-categories` | Custom categories as a JSON array of `{name: description}` objects | +| `--custom-instructions` | Custom instructions for fact extraction | +| `--structured-data-schema` | Schema for structured data extraction, as JSON | +| `--timestamp` | Unix timestamp for the memory | | `--expires` | Expiration date, after which the memory stops being returned | | `--immutable` | Store the memory so it can never be updated or overwritten | | `--no-infer` | Store the text exactly as given, skipping fact extraction | | `-o, --output` | Output format: `text`, `json`, `quiet` | + +`--categories` is not supported on `add` and exits with an error pointing at `--custom-categories`. + + ### `mem0 search` Search memories using natural language. @@ -149,6 +157,9 @@ mem0 search "preferred tools" --user-id alice --output json --top-k 5 | `--keyword` | Use keyword search instead of semantic | | `--filter` | Advanced filter expression (JSON) | | `--fields` | Return only the named fields | +| `--show-expired` | Include expired memories | +| `--reference-date` | Reference date for relative queries (`YYYY-MM-DD` or Unix timestamp) | +| `--latest-only` | Only return the latest version of each memory | | `-o, --output` | Output format: `text`, `json`, `table` | ### `mem0 list` @@ -172,6 +183,8 @@ mem0 list --user-id alice --after 2024-01-01 --page-size 50 | `--category` | Filter by category | | `--after` | Created after date (YYYY-MM-DD) | | `--before` | Created before date (YYYY-MM-DD) | +| `--show-expired` | Include expired memories | +| `--latest-only` | Only return the latest version of each memory | | `-o, --output` | Output format: `text`, `json`, `table` | ### `mem0 get` @@ -200,6 +213,8 @@ echo "new text" | mem0 update | Flag | Description | |------|-------------| | `-m, --metadata` | Replace the memory's metadata with this JSON | +| `--expires` | Expiration date (YYYY-MM-DD) | +| `--timestamp` | Unix timestamp for the memory | | `-o, --output` | Output format: `text`, `json`, `quiet` | ### `mem0 delete` @@ -229,6 +244,7 @@ mem0 delete --all --user-id alice --dry-run | `--all` | Delete all memories matching scope filters | | `--entity` | Delete the entity and all its memories | | `--project` | With `--all`: delete all memories project-wide | +| `--delete-linked` | Also delete memories linked to this memory | | `--dry-run` | Preview without deleting | | `--force` | Skip confirmation prompt | diff --git a/integrations/mem0-plugin/scripts/load_settings.py b/integrations/mem0-plugin/scripts/load_settings.py index 58a53b569..139d180c6 100644 --- a/integrations/mem0-plugin/scripts/load_settings.py +++ b/integrations/mem0-plugin/scripts/load_settings.py @@ -27,24 +27,45 @@ def load_settings() -> dict: try: with open(SETTINGS_PATH) as f: user = json.load(f) - settings.update({k: v for k, v in user.items() if k in DEFAULTS}) except (json.JSONDecodeError, OSError): - pass + return settings + if isinstance(user, dict): + settings.update({k: v for k, v in user.items() if k in DEFAULTS}) return settings -def create_default_settings() -> None: +def create_default_settings() -> bool: + """Write the default settings file if absent. Returns True if it was created.""" SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + if SETTINGS_PATH.exists(): + return False + with open(SETTINGS_PATH, "w") as f: + json.dump(DEFAULTS, f, indent=2) + f.write("\n") + return True + + +def unknown_keys() -> list[str]: + """Keys present in the user's settings file that no code reads.""" if not SETTINGS_PATH.exists(): - with open(SETTINGS_PATH, "w") as f: - json.dump(DEFAULTS, f, indent=2) - f.write("\n") + return [] + try: + with open(SETTINGS_PATH) as f: + user = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + if not isinstance(user, dict): + return [] + return sorted(k for k in user if k not in DEFAULTS) if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "init": - create_default_settings() - print(f"Created {SETTINGS_PATH}") + if create_default_settings(): + print(f"Created {SETTINGS_PATH}") + ignored = unknown_keys() + if ignored: + print(f"Ignoring unrecognized settings in {SETTINGS_PATH}: {', '.join(ignored)}") else: print(json.dumps(load_settings())) diff --git a/integrations/mem0-plugin/tests/conftest.py b/integrations/mem0-plugin/tests/conftest.py index ab3eb809c..a170017f5 100644 --- a/integrations/mem0-plugin/tests/conftest.py +++ b/integrations/mem0-plugin/tests/conftest.py @@ -23,15 +23,14 @@ def _scripts_on_path(): @pytest.fixture(autouse=True) -def _clean_project_map(monkeypatch): - """Remove project_map.json and clear MEM0_PROJECT_ID before each test.""" +def _isolated_home(tmp_path, monkeypatch): + """Point HOME at a tmp dir so ~/.mem0 writes never touch the real home.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) - map_path = os.path.expanduser("~/.mem0/project_map.json") - if os.path.isfile(map_path): - os.remove(map_path) - yield - if os.path.isfile(map_path): - os.remove(map_path) + yield home @pytest.fixture() diff --git a/integrations/mem0-plugin/tests/test_load_settings.py b/integrations/mem0-plugin/tests/test_load_settings.py new file mode 100644 index 000000000..0f3216256 --- /dev/null +++ b/integrations/mem0-plugin/tests/test_load_settings.py @@ -0,0 +1,86 @@ +"""Tests for scripts/load_settings.py.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + + +@pytest.fixture() +def settings(tmp_path, monkeypatch): + import load_settings + + path = tmp_path / ".mem0" / "settings.json" + monkeypatch.setattr(load_settings, "SETTINGS_PATH", path) + return load_settings + + +def test_creates_file_when_absent(settings): + assert settings.create_default_settings() is True + assert settings.SETTINGS_PATH.exists() + assert json.loads(settings.SETTINGS_PATH.read_text()) == settings.DEFAULTS + + +def test_does_not_recreate_or_overwrite_existing(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"search_limit": 42}') + + assert settings.create_default_settings() is False + assert json.loads(settings.SETTINGS_PATH.read_text()) == {"search_limit": 42} + + +def test_user_values_override_defaults(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"search_limit": 3, "auto_save": false}') + + loaded = settings.load_settings() + assert loaded["search_limit"] == 3 + assert loaded["auto_save"] is False + assert loaded["global_search"] == settings.DEFAULTS["global_search"] + + +def test_unknown_keys_are_dropped_but_reported(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"skip_tools": ["Read"], "output_style": "compact"}') + + assert "skip_tools" not in settings.load_settings() + assert settings.unknown_keys() == ["output_style", "skip_tools"] + + +def test_unknown_keys_empty_for_clean_file(settings): + settings.create_default_settings() + assert settings.unknown_keys() == [] + + +@pytest.mark.parametrize("body", ["{not json", '["a", "list"]']) +def test_malformed_file_falls_back_to_defaults(settings, body): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text(body) + + assert settings.load_settings() == settings.DEFAULTS + assert settings.unknown_keys() == [] + + +def test_init_announces_creation_only_once(_isolated_home): + import load_settings + + home = _isolated_home + + def run_init(): + return subprocess.run( + [sys.executable, load_settings.__file__, "init"], + capture_output=True, + text=True, + check=True, + env={**os.environ, "HOME": str(home)}, + ).stdout + + first = run_init() + second = run_init() + + assert "Created" in first + assert "Created" not in second diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index d4f54b53f..b66d4a444 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -27,6 +27,8 @@ export interface SearchMemoryOptions { fields?: string[]; categories?: string[]; showExpired?: boolean; + referenceDate?: string | number; + keywordSearch?: boolean; } export interface GetAllMemoryOptions { diff --git a/mem0-ts/src/client/tests/memoryClient.search.test.ts b/mem0-ts/src/client/tests/memoryClient.search.test.ts index 9b36babe3..021187867 100644 --- a/mem0-ts/src/client/tests/memoryClient.search.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.search.test.ts @@ -99,6 +99,42 @@ describe("MemoryClient - search()", () => { expect(getFetchBody(call!).show_expired).toBe(true); }); + test("serializes referenceDate as reference_date (MEM-5893)", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { + filters: { user_id: "u1" }, + referenceDate: "2024-01-01", + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).reference_date).toBe("2024-01-01"); + }); + + test("serializes keywordSearch as keyword_search (MEM-5893)", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { + filters: { user_id: "u1" }, + keywordSearch: true, + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).keyword_search).toBe(true); + }); + test("passes complex OR filters through to the API body", async () => { const extra = new Map(); extra.set("/v3/memories/search/", { diff --git a/mem0/client/types.py b/mem0/client/types.py index 62df27629..527bc2fc6 100644 --- a/mem0/client/types.py +++ b/mem0/client/types.py @@ -52,6 +52,11 @@ class SearchMemoryOptions(BaseModel): fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response") categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") + reference_date: Optional[Union[str, int]] = Field( + default=None, description="Reference date for relative time queries (YYYY-MM-DD or Unix timestamp)" + ) + latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version") + keyword_search: Optional[bool] = Field(default=None, description="Whether to use keyword search") class GetAllMemoryOptions(BaseModel): @@ -74,6 +79,7 @@ class GetAllMemoryOptions(BaseModel): ) categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") + latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version") class DeleteAllMemoryOptions(BaseModel): diff --git a/tests/test_client.py b/tests/test_client.py index 8aa554763..aa8c16ce5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,8 @@ import httpx import pytest import requests +from mem0.client.types import GetAllMemoryOptions, SearchMemoryOptions + @pytest.fixture def mock_memory_client(): @@ -130,6 +132,55 @@ class TestGetAllEntityParamRejection: ) +class TestSearchTypedOptionsParity: + """MEM-5893: SearchMemoryOptions' new typed fields serialize to their v3 snake_case keys.""" + + def test_search_options_pass_reference_date_latest_only_keyword_search(self, mock_memory_client): + """search(options=SearchMemoryOptions(...)) should forward the new fields verbatim.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + options = SearchMemoryOptions( + filters={"user_id": "u1"}, + reference_date="2024-01-01", + latest_only=True, + keyword_search=True, + ) + mock_memory_client.search("test query", options=options) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/search/", + json={ + "query": "test query", + "filters": {"user_id": "u1"}, + "reference_date": "2024-01-01", + "latest_only": True, + "keyword_search": True, + }, + ) + + +class TestGetAllTypedOptionsParity: + """MEM-5893: GetAllMemoryOptions.latest_only serializes to its v3 snake_case key.""" + + def test_get_all_options_pass_latest_only(self, mock_memory_client): + """get_all(options=GetAllMemoryOptions(...)) should forward latest_only verbatim.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + options = GetAllMemoryOptions(filters={"user_id": "u1"}, latest_only=True) + mock_memory_client.get_all(options=options) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/", + json={"filters": {"user_id": "u1"}, "latest_only": True}, + ) + + class TestUpdateExpirationDate: """Tests for update expiration_date payload handling.""" From 9ef409222f1d128552a3e9bad6137fb72387d89c Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 3 Aug 2026 20:02:45 +0530 Subject: [PATCH 2/9] fix(ts-oss/redis): fall back to "*" for empty or all-null filters (#6014) --- mem0-ts/src/oss/src/vector_stores/redis.ts | 28 +++++++-------- .../src/oss/tests/redis.filter-expr.test.ts | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 mem0-ts/src/oss/tests/redis.filter-expr.test.ts diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index c5d8394f7..8c274f7d5 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -133,6 +133,18 @@ function toSnakeCase(obj: Record): Record { ); } +// Build a RediSearch pre-filter expression from filters. Returns "*" (match +// all) when there are no usable conditions — including an empty filters object +// or one whose values are all null/undefined — since an empty expression is an +// invalid RediSearch query (e.g. ` =>[KNN ...]`). +export function buildRedisFilterExpr(filters?: SearchFilters): string { + if (!filters) return "*"; + const conditions = Object.entries(toSnakeCase(filters)) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`); + return conditions.length > 0 ? conditions.join(" ") : "*"; +} + // Utility function to convert object keys to camelCase function toCamelCase(obj: Record): Record { if (typeof obj !== "object" || obj === null) return obj; @@ -406,13 +418,7 @@ export class RedisDB implements VectorStore { filters?: SearchFilters, ): Promise { await this.initialize(); - const snakeFilters = filters ? toSnakeCase(filters) : undefined; - const filterExpr = snakeFilters - ? Object.entries(snakeFilters) - .filter(([_, value]) => value !== null && value !== undefined) - .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`) - .join(" ") - : "*"; + const filterExpr = buildRedisFilterExpr(filters); const queryVector = new Float32Array(query).buffer; @@ -655,13 +661,7 @@ export class RedisDB implements VectorStore { topK: number = 100, ): Promise<[VectorStoreResult[], number]> { await this.initialize(); - const snakeFilters = filters ? toSnakeCase(filters) : undefined; - const filterExpr = snakeFilters - ? Object.entries(snakeFilters) - .filter(([_, value]) => value !== null && value !== undefined) - .map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`) - .join(" ") - : "*"; + const filterExpr = buildRedisFilterExpr(filters); const searchOptions = { SORTBY: "created_at", diff --git a/mem0-ts/src/oss/tests/redis.filter-expr.test.ts b/mem0-ts/src/oss/tests/redis.filter-expr.test.ts new file mode 100644 index 000000000..43ed6c63b --- /dev/null +++ b/mem0-ts/src/oss/tests/redis.filter-expr.test.ts @@ -0,0 +1,35 @@ +import { buildRedisFilterExpr } from "../src/vector_stores/redis"; + +describe("buildRedisFilterExpr", () => { + it("returns '*' when filters is undefined", () => { + expect(buildRedisFilterExpr(undefined)).toBe("*"); + }); + + it("returns '*' for an empty filters object", () => { + // Regression: previously produced "" -> invalid RediSearch query + // (` =>[KNN ...]`). + expect(buildRedisFilterExpr({})).toBe("*"); + }); + + it("returns '*' when every filter value is null/undefined", () => { + expect( + buildRedisFilterExpr({ user_id: null as any, agent_id: undefined }), + ).toBe("*"); + }); + + it("builds a tag clause for a single filter", () => { + expect(buildRedisFilterExpr({ user_id: "alice" })).toBe("@user_id:{alice}"); + }); + + it("snake-cases keys and joins multiple clauses", () => { + expect(buildRedisFilterExpr({ userId: "alice", runId: "r1" })).toBe( + "@user_id:{alice} @run_id:{r1}", + ); + }); + + it("drops null/undefined values but keeps the rest", () => { + expect( + buildRedisFilterExpr({ user_id: "alice", agent_id: null as any }), + ).toBe("@user_id:{alice}"); + }); +}); From 5f77d86caff94e74def4bd5c9c4076b46a2291c9 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 3 Aug 2026 20:36:14 +0530 Subject: [PATCH 3/9] docs: correct openapi.json against live API behavior (#6771) --- docs/openapi.json | 2445 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 1999 insertions(+), 446 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index a53c0c7c7..468ed2993 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -45,7 +45,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateAgent" + "$ref": "#/components/schemas/AgentResponse" } } } @@ -77,7 +77,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApp" + "$ref": "#/components/schemas/AppResponse" } } } @@ -116,65 +116,97 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the entity." - }, - "name": { - "type": "string", - "description": "Name of the entity." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of when the entity was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of when the entity was last updated." - }, - "total_memories": { - "type": "integer", - "description": "Total number of memories associated with the entity." - }, - "owner": { - "type": "string", - "description": "Owner of the entity." - }, - "organization": { - "type": "string", - "description": "Organization the entity belongs to." - }, - "metadata": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Total number of entities matching the filters." + }, + "next": { + "type": "string", + "nullable": true, + "description": "URL of the next page of results, or null if this is the last page." + }, + "previous": { + "type": "string", + "nullable": true, + "description": "URL of the previous page of results, or null if this is the first page." + }, + "results": { + "type": "array", + "items": { "type": "object", - "description": "Additional metadata associated with the entity" - }, - "type": { - "type": "string", - "enum": [ - "user", - "agent", - "app", - "run" + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entity." + }, + "name": { + "type": "string", + "description": "Name of the entity." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the entity was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the entity was last updated." + }, + "owner": { + "type": "string", + "description": "Owner of the entity." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the entity." + }, + "type": { + "type": "string", + "enum": [ + "user", + "agent", + "app", + "run" + ] + } + }, + "required": [ + "id", + "name", + "created_at", + "updated_at", + "owner", + "type" ] } }, - "required": [ - "id", - "name", - "created_at", - "updated_at", - "total_memories", - "owner", - "organization", - "type" - ] - } + "total_users": { + "type": "integer", + "description": "Total number of user entities in the project." + }, + "total_agents": { + "type": "integer", + "description": "Total number of agent entities in the project." + }, + "total_apps": { + "type": "integer", + "description": "Total number of app entities in the project." + }, + "total_runs": { + "type": "integer", + "description": "Total number of run entities in the project." + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] } } } @@ -217,11 +249,163 @@ "responses": { "200": { "description": "Successfully retrieved entity filters.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event_type": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Event types that can be filtered on.", + "example": [ + "ADD", + "UPDATE", + "DELETE", + "SEARCH", + "GET_ALL", + "GET", + "DELETE_ALL", + "DELETE_USER" + ] + }, + "categories": { + "type": "array", + "items": {}, + "description": "Memory categories that can be filtered on." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User IDs that can be filtered on." + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "description": "Filter UI field definitions used by the Mem0 dashboard." + } + } + } + } + } } } } }, + "/v1/entities/{entity_type}/{entity_id}/": { + "delete": { + "tags": [ + "entities" + ], + "deprecated": true, + "description": "Delete an entity by its numeric ID. Deprecated in favor of `DELETE /v2/entities/{entity_type}/{entity_id}/`, which takes the entity's string name instead of this numeric ID.", + "operationId": "entities_delete_v1", + "parameters": [ + { + "name": "entity_type", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "user", + "agent", + "app", + "run" + ] + }, + "description": "The type of the entity (user, agent, app, or run)." + }, + { + "name": "entity_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The numeric identifier of the entity, as returned in the `id` field by `GET /v1/entities/`." + } + ], + "responses": { + "200": { + "description": "Entity deletion has been queued for background (async) execution.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Delete in progress. This may take some time." + }, + "event_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "400": { + "description": "Invalid entity ID. The path parameter must be an integer.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid entity ID. Must be an integer." + } + } + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "cURL", + "source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/entities/user/12345/ \\\n --header 'Authorization: Token '" + } + ] + } + }, "/v2/entities/{entity_type}/{entity_id}/": { "get": { "tags": [ @@ -257,7 +441,42 @@ "responses": { "200": { "description": "Successfully retrieved entity details.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + }, + "type": { + "type": "string" + } + } + } + } + } + } } } }, @@ -293,8 +512,8 @@ } ], "responses": { - "204": { - "description": "Entity deleted successfully!", + "200": { + "description": "Entity deletion has been queued for background (async) execution.", "content": { "application/json": { "schema": { @@ -302,7 +521,11 @@ "properties": { "message": { "type": "string", - "example": "Entity deleted successfully!" + "example": "Delete in progress. This may take some time." + }, + "event_id": { + "type": "string", + "format": "uuid" } } } @@ -568,6 +791,16 @@ "latency": { "type": "number", "description": "Processing time in milliseconds." + }, + "graph_status": { + "type": "string", + "nullable": true, + "description": "Status of graph memory processing for this event, if graph memory is enabled." + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the event failed to process." } } } @@ -581,8 +814,9 @@ "schema": { "type": "object", "properties": { - "detail": { - "type": "string" + "error": { + "type": "string", + "example": "Event not found or you don't have permission to access it." } } } @@ -641,21 +875,7 @@ }, "filters": { "type": "object", - "properties": { - "user_id": { - "type": "string" - }, - "agent_id": { - "type": "string" - }, - "app_id": { - "type": "string" - }, - "run_id": { - "type": "string" - } - }, - "description": "Filters to apply while exporting memories. Available fields are: user_id, agent_id, app_id, run_id." + "description": "Filters to apply while exporting memories, using the structured AND/OR filter format (see `/v2/memories/search/`), e.g. `{\"AND\": [{\"user_id\": \"\"}]}`. Available fields are: user_id, agent_id, app_id, run_id. Flat filter objects (e.g. `{\"user_id\": \"\"}`) are rejected with a 400 error." }, "org_id": { "type": "string", @@ -702,13 +922,28 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "Schema is required and must be a valid object" + "oneOf": [ + { + "type": "object", + "description": "Returned when the schema is missing or invalid.", + "properties": { + "message": { + "type": "string", + "example": "Schema is required and must be a valid object" + } + } + }, + { + "type": "object", + "description": "Returned when filters are missing or not in the required structured AND/OR format.", + "properties": { + "error": { + "type": "string", + "example": "One of the filters: app_id, user_id, agent_id, run_id, memory_export_id is required!" + } + } } - } + ] } } } @@ -742,7 +977,7 @@ ] } }, - "/v1/exports/get": { + "/v1/exports/get/": { "post": { "tags": [ "exports" @@ -877,6 +1112,7 @@ ], "description": "Get all memories.", "operationId": "memories_list", + "deprecated": true, "parameters": [ { "name": "user_id", @@ -1005,73 +1241,172 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "memory": { - "type": "string" - }, - "input": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string" - }, - "content": { + "oneOf": [ + { + "type": "array", + "description": "Returned when the `page` and `page_size` query parameters are not both supplied.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "ID of the user this memory belongs to, if scoped to a user." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "User-supplied metadata attached to the memory." + }, + "categories": { + "type": "array", + "nullable": true, + "items": { "type": "string" } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "nullable": true, + "default": null + }, + "structured_attributes": { + "type": "object", + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." + } + }, + "required": [ + "id", + "memory", + "created_at", + "updated_at" + ] + } + }, + { + "type": "object", + "description": "Returned when both the `page` and `page_size` query parameters are supplied.", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "ID of the user this memory belongs to, if scoped to a user." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "User-supplied metadata attached to the memory." + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "nullable": true, + "default": null + }, + "structured_attributes": { + "type": "object", + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." + } + }, + "required": [ + "id", + "memory", + "created_at", + "updated_at" + ] } } }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "owner": { - "type": "string" - }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false - }, - "expiration_date": { - "type": "string", - "format": "date", - "description": "The date when the memory will expire. Format: YYYY-MM-DD.", - "title": "Expiration date", - "nullable": true, - "default": null - }, - "organization": { - "type": "string" - }, - "metadata": { - "type": "object" - } - }, - "required": [ - "id", - "memory", - "created_at", - "updated_at", - "total_memories", - "owner", - "organization", - "type" - ] - } + "required": [ + "count", + "next", + "previous", + "results" + ] + } + ] } } } @@ -1126,6 +1461,7 @@ ], "description": "Add memories.", "operationId": "memories_create", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1138,43 +1474,77 @@ }, "responses": { "200": { - "description": "Successful memory creation.", + "description": "Memory processing has been queued for background (async) execution. The response reports queueing status, not the final memory content; poll `GET /v1/event/{event_id}/` with the returned `event_id` to see the processed result. By default (or with `version=v1` / `output_format=v1.0`) the response is a bare array. When both `output_format=v1.1` and `version=v2` are requested, the response is an object wrapping the same array under `results`.", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "data": { + "oneOf": [ + { + "type": "array", + "description": "Default response shape.", + "items": { "type": "object", "properties": { - "memory": { - "type": "string" + "message": { + "type": "string", + "example": "Memory processing has been queued for background execution" + }, + "status": { + "type": "string", + "example": "PENDING" + }, + "event_id": { + "type": "string", + "format": "uuid" } }, "required": [ - "memory" - ] - }, - "event": { - "type": "string", - "enum": [ - "ADD", - "UPDATE", - "DELETE" + "message", + "status", + "event_id" ] } }, - "required": [ - "id", - "data", - "event" - ] - } + { + "type": "object", + "description": "Response shape when both `output_format=v1.1` and `version=v2` are requested.", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Memory processing has been queued for background execution" + }, + "status": { + "type": "string", + "example": "PENDING" + }, + "event_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "message", + "status", + "event_id" + ] + } + }, + "relations": { + "type": "array", + "items": {} + } + }, + "required": [ + "results", + "relations" + ] + } + ] } } } @@ -1295,8 +1665,8 @@ } ], "responses": { - "204": { - "description": "Successful deletion of memories.", + "200": { + "description": "Deletion of memories has been queued for background (async) execution.", "content": { "application/json": { "schema": { @@ -1304,7 +1674,11 @@ "properties": { "message": { "type": "string", - "example": "Memories deleted successfully!" + "example": "Delete in progress. This may take some time." + }, + "event_id": { + "type": "string", + "format": "uuid" } } } @@ -1348,6 +1722,7 @@ ], "description": "Get all memories.", "operationId": "memories_list_v2", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1369,52 +1744,82 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid", + "description": "Unique memory identifier." }, "memory": { - "type": "string" + "type": "string", + "description": "The extracted memory fact." + }, + "user_id": { + "type": "string", + "nullable": true, + "description": "User the memory is scoped to." + }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "Agent the memory is scoped to." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "App the memory is scoped to." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `session_id`, matching `POST /v3/memories/`." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Additional metadata associated with the memory" + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } }, "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Timestamp of when the memory was created." }, "updated_at": { "type": "string", - "format": "date-time" - }, - "owner": { - "type": "string" - }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false + "format": "date-time", + "description": "Timestamp of when the memory was last updated." }, "expiration_date": { "type": "string", "format": "date", - "description": "The date when the memory will expire. Format: YYYY-MM-DD.", - "title": "Expiration date", "nullable": true, - "default": null + "description": "The date when the memory will expire. Format: YYYY-MM-DD." }, - "organization": { - "type": "string" + "structured_attributes": { + "type": "object", + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, - "metadata": { - "type": "object" + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." } }, "required": [ "id", "memory", "created_at", - "updated_at", - "total_memories", - "owner", - "organization", - "type" + "updated_at" ] } } @@ -1471,11 +1876,134 @@ "tags": [ "memories" ], + "description": "Despite the endpoint name, this returns memory history entries (the same shape as `GET /v1/memories/{memory_id}/history/`), not event/ingestion-job records. For event/ingestion-job status, use `GET /v1/event/{event_id}/`.", "operationId": "memories_events_list", "responses": { "200": { - "description": "Successfully retrieved memory events.", - "content": {} + "description": "Successfully retrieved memory history entries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the history entry." + }, + "memory_id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier of the associated memory." + }, + "input": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "description": "The conversation input that led to this memory change" + }, + "old_memory": { + "type": "string", + "nullable": true, + "description": "The previous state of the memory, if applicable" + }, + "new_memory": { + "type": "string", + "description": "The new or updated state of the memory" + }, + "user_id": { + "type": "string", + "description": "The identifier of the user associated with this memory" + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Categories associated with the memory at this point in its history" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when this history entry was created." + }, + "event": { + "type": "string", + "enum": [ + "ADD", + "UPDATE", + "DELETE" + ], + "description": "The type of event that occurred" + }, + "previous_embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Previous internal embedding representation (Qwen model), if applicable" + }, + "embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Internal embedding representation (Qwen model), if available" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the memory change" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when this history entry was last updated." + } + }, + "required": [ + "id", + "memory_id", + "new_memory", + "user_id", + "event", + "created_at", + "updated_at" + ] + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + } + } + } } } } @@ -1487,6 +2015,7 @@ ], "description": "Perform a semantic search on memories.", "operationId": "memories_search_create", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1503,68 +2032,147 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Unique identifier for the memory." - }, - "memory": { - "type": "string", - "description": "The content of the memory" - }, - "user_id": { - "type": "string", - "description": "The identifier of the user associated with this memory" - }, - "metadata": { + "oneOf": [ + { + "type": "array", + "description": "Default response shape. Returned when `output_format` is omitted or set to `v1.0`.", + "items": { "type": "object", - "nullable": true, - "description": "Additional metadata associated with the memory" - }, - "categories": { - "type": "array", - "items": { - "type": "string" + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the memory." + }, + "memory": { + "type": "string", + "description": "The content of the memory" + }, + "user_id": { + "type": "string", + "description": "The identifier of the user associated with this memory" + }, + "score": { + "type": "number", + "description": "Relevance score of the memory for the search query." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the memory" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categories associated with the memory" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "title": "Expiration date", + "nullable": true, + "default": null + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was last updated." + } }, - "description": "Categories associated with the memory" - }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false - }, - "expiration_date": { - "type": "string", - "format": "date", - "description": "The date when the memory will expire. Format: YYYY-MM-DD.", - "title": "Expiration date", - "nullable": true, - "default": null - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "The timestamp when the memory was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "The timestamp when the memory was last updated." + "required": [ + "id", + "memory", + "user_id", + "created_at", + "updated_at" + ] } }, - "required": [ - "id", - "memory", - "user_id", - "created_at", - "updated_at" - ] - } + { + "type": "object", + "description": "Returned when `output_format=v1.1` is requested.", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the memory." + }, + "memory": { + "type": "string", + "description": "The content of the memory" + }, + "user_id": { + "type": "string", + "description": "The identifier of the user associated with this memory" + }, + "score": { + "type": "number", + "description": "Relevance score of the memory for the search query." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the memory" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categories associated with the memory" + }, + "expiration_date": { + "type": "string", + "format": "date", + "description": "The date when the memory will expire. Format: YYYY-MM-DD.", + "title": "Expiration date", + "nullable": true, + "default": null + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the memory was last updated." + } + }, + "required": [ + "id", + "memory", + "user_id", + "created_at", + "updated_at" + ] + } + }, + "relations": { + "type": "array", + "items": {} + } + }, + "required": [ + "results", + "relations" + ] + } + ] } } } @@ -1622,6 +2230,7 @@ ], "description": "Search memories based on a query and filters.", "operationId": "memories_search_v2", + "deprecated": true, "requestBody": { "content": { "application/json": { @@ -1667,12 +2276,6 @@ }, "description": "Categories associated with the memory" }, - "immutable": { - "type": "boolean", - "description": "Whether the memory is immutable.", - "title": "Immutable", - "default": false - }, "expiration_date": { "type": "string", "format": "date", @@ -1690,6 +2293,26 @@ "type": "string", "format": "date-time", "description": "The timestamp when the memory was last updated." + }, + "score": { + "type": "number", + "format": "float", + "minimum": 0, + "maximum": 1, + "description": "Combined multi-signal relevance score in [0, 1] (search responses only)." + }, + "agent_id": { + "type": "string", + "nullable": true + }, + "app_id": { + "type": "string", + "nullable": true + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `session_id`, matching `POST /v3/memories/`." } }, "required": [ @@ -1784,6 +2407,42 @@ "type": "boolean", "default": false, "description": "When true, include memories whose `expiration_date` has passed. Expired memories are hidden by default." + }, + "start_date": { + "type": "string", + "format": "date", + "description": "Only include memories created on or after this date (YYYY-MM-DD)." + }, + "end_date": { + "type": "string", + "format": "date", + "description": "Only include memories created on or before this date (YYYY-MM-DD)." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict results to memories tagged with any of these categories." + }, + "fields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict the fields returned per memory, e.g. [\"id\", \"memory\"]." + }, + "keywords": { + "type": "string", + "description": "Free-text keyword filter applied on top of filters." + }, + "page": { + "type": "integer", + "description": "1-indexed page number. Also accepted here in the request body; the query parameter of the same name takes precedence." + }, + "page_size": { + "type": "integer", + "description": "Results per page. Also accepted here in the request body; the query parameter of the same name takes precedence." } } }, @@ -1833,12 +2492,25 @@ "type": "string", "description": "The extracted memory fact." }, - "score": { - "type": "number", - "format": "float", - "minimum": 0, - "maximum": 1, - "description": "Combined multi-signal relevance score in [0, 1] (search responses only)." + "user_id": { + "type": "string", + "nullable": true, + "description": "User the memory is scoped to." + }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "Agent the memory is scoped to." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "App the memory is scoped to." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `session_id`, while `POST /v3/memories/search/` returns the same value as `run_id`." }, "metadata": { "type": "object", @@ -1847,10 +2519,31 @@ }, "categories": { "type": "array", + "nullable": true, "items": { "type": "string" } }, + "structured_attributes": { + "type": "object", + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Date after which the memory is hidden from search and get-all unless `show_expired` is true." + }, "created_at": { "type": "string", "format": "date-time" @@ -2007,6 +2700,65 @@ "type": "boolean", "default": true, "description": "When `false`, stores each message verbatim without running the extraction LLM." + }, + "app_id": { + "type": "string", + "description": "Scope memories to this app." + }, + "immutable": { + "type": "boolean", + "default": false, + "description": "Mark stored memories as immutable, excluding them from future update/consolidation." + }, + "includes": { + "type": "string", + "description": "Free-text hint of what to include during extraction, e.g. \"vehicles\"." + }, + "excludes": { + "type": "string", + "description": "Free-text hint of what to exclude during extraction, e.g. \"politics\"." + }, + "enable_graph": { + "type": "boolean", + "description": "Enable graph memory extraction for this call." + }, + "structured_data_schema": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "Optional schema constraining structured extraction. Exact shape not fully characterized; observed only as null in captured traffic." + }, + "output_format": { + "type": "string", + "nullable": true, + "description": "Response envelope version, e.g. \"v1.1\". Full set of accepted values not confirmed." + }, + "prompt_profile_id": { + "type": "string", + "nullable": true, + "description": "ID of a saved prompt profile to use for extraction." + }, + "temporal_reasoning": { + "type": "boolean", + "description": "Enable temporal reasoning during extraction." + }, + "timezone": { + "type": "string", + "description": "IANA timezone used to interpret observation_datetime and observation_date, e.g. \"UTC\"." + }, + "observation_datetime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime the conversation was observed." + }, + "observation_date": { + "type": "string", + "format": "date", + "description": "Date the conversation was observed (YYYY-MM-DD)." + }, + "timestamp": { + "type": "integer", + "description": "Unix epoch seconds used to backdate created_at on the stored memories. Not echoed back in the event payload but confirmed applied." } } }, @@ -2049,6 +2801,31 @@ "event_id": { "type": "string", "format": "uuid" + }, + "results": { + "type": "array", + "description": "Only present when `infer` is `false`, where processing is synchronous and memories are stored verbatim without extraction.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "data": { + "type": "object", + "properties": { + "memory": { + "type": "string" + } + } + }, + "event": { + "type": "string", + "example": "ADD" + } + } + } } } }, @@ -2154,6 +2931,25 @@ ], "nullable": true, "description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime." + }, + "fields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restrict the fields returned per memory." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter results to memories tagged with any of these categories." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Filter results to memories matching this metadata." } } }, @@ -2189,6 +2985,11 @@ "type": "string", "description": "The extracted memory fact." }, + "user_id": { + "type": "string", + "nullable": true, + "description": "User the memory is scoped to." + }, "score": { "type": "number", "format": "float", @@ -2196,6 +2997,40 @@ "maximum": 1, "description": "Combined multi-signal relevance score in [0, 1] (search responses only)." }, + "score_breakdown": { + "type": "object", + "description": "Per-signal breakdown of the relevance score.", + "properties": { + "semantic": { + "type": "number" + }, + "bm25": { + "type": "number" + }, + "entity": { + "type": "number" + } + } + }, + "agent_id": { + "type": "string", + "nullable": true + }, + "app_id": { + "type": "string", + "nullable": true + }, + "run_id": { + "type": "string", + "nullable": true, + "description": "Run/session the memory is scoped to. Note: this endpoint returns this field as `run_id`, while `POST /v3/memories/` returns the same value as `session_id`." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Date after which the memory is hidden from search unless `show_expired` is true." + }, "metadata": { "type": "object", "additionalProperties": true, @@ -2283,7 +3118,106 @@ "responses": { "200": { "description": "Successfully retrieved memories.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "input": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "description": "The conversation input that produced this memory." + }, + "is_updated": { + "type": "boolean" + } + }, + "required": [ + "id", + "memory", + "created_at", + "updated_at" + ] + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + } + } + } + }, + "400": { + "description": "Invalid entity type.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid entity type" + } + } + } + } + } } } }, @@ -2293,8 +3227,15 @@ "in": "path", "required": true, "schema": { - "type": "string" - } + "type": "string", + "enum": [ + "user", + "agent", + "run", + "app" + ] + }, + "description": "The type of entity to retrieve memories for." }, { "name": "entity_id", @@ -2344,31 +3285,21 @@ }, "user_id": { "type": "string", + "nullable": true, "description": "Identifier of the user associated with this memory" }, - "agent_id": { - "type": "string", - "nullable": true, - "description": "The agent ID associated with the memory, if any" - }, - "app_id": { - "type": "string", - "nullable": true, - "description": "The app ID associated with the memory, if any" - }, - "run_id": { - "type": "string", - "nullable": true, - "description": "The run ID associated with the memory, if any" - }, - "hash": { - "type": "string", - "description": "Hash of the memory content" - }, "metadata": { "type": "object", + "additionalProperties": true, "description": "Additional metadata associated with the memory" }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string", "format": "date-time", @@ -2378,6 +3309,30 @@ "type": "string", "format": "date-time", "description": "Timestamp of when the memory was last updated." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date when the memory will expire. Format: YYYY-MM-DD." + }, + "structured_attributes": { + "type": "object", + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." } } } @@ -2484,38 +3439,27 @@ "format": "uuid", "description": "The unique identifier of the updated memory." }, - "text": { + "memory": { "type": "string", - "description": "The updated text content of the memory" + "description": "The updated content of the memory." }, "user_id": { "type": "string", "nullable": true, - "description": "The user ID associated with the memory, if any" - }, - "agent_id": { - "type": "string", - "nullable": true, - "description": "The agent ID associated with the memory, if any" - }, - "app_id": { - "type": "string", - "nullable": true, - "description": "The app ID associated with the memory, if any" - }, - "run_id": { - "type": "string", - "nullable": true, - "description": "The run ID associated with the memory, if any" - }, - "hash": { - "type": "string", - "description": "Hash of the memory content" + "description": "Identifier of the user associated with this memory" }, "metadata": { "type": "object", + "additionalProperties": true, "description": "Additional metadata associated with the memory" }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string", "format": "date-time", @@ -2525,6 +3469,30 @@ "type": "string", "format": "date-time", "description": "Timestamp of when the memory was last updated." + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date when the memory will expire. Format: YYYY-MM-DD." + }, + "structured_attributes": { + "type": "object", + "additionalProperties": true, + "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." + }, + "replaced_by": { + "type": "string", + "nullable": true, + "description": "ID of the memory that superseded this one, if any." + }, + "synthesized": { + "type": "boolean", + "description": "Whether this memory was synthesized rather than directly extracted." + }, + "lifecycle_state": { + "type": "string", + "description": "Lifecycle state of the memory." } } } @@ -2576,10 +3544,19 @@ "format": "uuid" }, "description": "The unique identifier of the memory to retrieve." + }, + { + "name": "delete_linked", + "in": "query", + "schema": { + "type": "boolean", + "default": false + }, + "description": "If true, also delete memories linked to this one. The response then includes a `cascade_count` field with the number of linked memories deleted." } ], "responses": { - "204": { + "200": { "description": "Successful deletion of memory.", "content": { "application/json": { @@ -2589,6 +3566,10 @@ "message": { "type": "string", "example": "Memory deleted successfully!" + }, + "cascade_count": { + "type": "integer", + "description": "Number of linked memories also deleted. Only present when `delete_linked=true` was passed." } } } @@ -2701,6 +3682,14 @@ "type": "string", "description": "The identifier of the user associated with this memory" }, + "categories": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Categories associated with the memory at this point in its history" + }, "event": { "type": "string", "enum": [ @@ -2710,6 +3699,16 @@ ], "description": "The type of event that occurred" }, + "previous_embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Previous internal embedding representation (Qwen model), if applicable" + }, + "embedding_qwen": { + "type": "string", + "nullable": true, + "description": "Internal embedding representation (Qwen model), if available" + }, "metadata": { "type": "object", "nullable": true, @@ -2793,7 +3792,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateRun" + "$ref": "#/components/schemas/RunResponse" } } } @@ -2813,11 +3812,164 @@ "responses": { "200": { "description": "Successfully retrieved statistics.", - "content": {} + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total_memories": { + "type": "integer" + }, + "total_retrieval_events": { + "type": "integer" + }, + "total_add_events": { + "type": "integer" + }, + "total_api_retrieval_events": { + "type": "integer" + }, + "total_retrieval_events_limit": { + "type": "integer" + }, + "total_users_with_memories": { + "type": "integer" + }, + "total_agents_with_memories": { + "type": "integer" + }, + "total_runs_with_memories": { + "type": "integer" + }, + "memories_per_user": { + "type": "integer" + }, + "memories_per_agent": { + "type": "integer" + }, + "memories_per_run": { + "type": "integer" + } + } + } + } + } } } } }, + "/v1/ping/": { + "get": { + "tags": [ + "stats" + ], + "summary": "Check API key validity and resolve organization/project context.", + "description": "Both the Python and TypeScript SDKs call this endpoint on client construction to validate the API key and resolve the default `org_id` and `project_id`.", + "operationId": "ping_read", + "responses": { + "200": { + "description": "The API key is valid.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "org_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "user_email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "401": { + "description": "Invalid API key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "Invalid API key. You can find your API key on https://app.mem0.ai/dashboard/api-keys." + } + } + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "cURL", + "source": "curl --request GET \\\n --url https://api.mem0.ai/v1/ping/ \\\n --header 'Authorization: Token '" + } + ] + } + }, + "/v1/summary/": { + "post": { + "tags": [ + "memories" + ], + "summary": "Get a summary of memories matching the given filters.", + "description": "Backs the Python SDK's `client.get_summary()`. Not currently exposed by the TypeScript SDK.", + "operationId": "summary_create", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "description": "Either flat field:value pairs (e.g. `{\"user_id\": \"\"}`) or the structured AND/OR filter format used by `/v2/memories/search/`." + } + }, + "required": [ + "filters" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successfully retrieved the summary.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "Python", + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\nsummary = client.get_summary(filters={\"user_id\": \"\"})\nprint(summary)" + }, + { + "lang": "cURL", + "source": "curl --request POST \\\n --url https://api.mem0.ai/v1/summary/ \\\n --header 'Authorization: Token ' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"filters\": {\"user_id\": \"\"}\n}'" + } + ] + } + }, "/v1/users/": { "post": { "tags": [ @@ -2841,7 +3993,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUser" + "$ref": "#/components/schemas/UserResponse" } } } @@ -2963,10 +4115,6 @@ "items": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the organization." - }, "org_id": { "type": "string", "description": "Organization's unique string identifier." @@ -2975,30 +4123,6 @@ "type": "string", "description": "Name of the organization." }, - "description": { - "type": "string", - "description": "Brief description of the organization" - }, - "address": { - "type": "string", - "description": "Physical address of the organization" - }, - "contact_email": { - "type": "string", - "description": "Primary contact email for the organization" - }, - "phone_number": { - "type": "string", - "description": "Contact phone number for the organization" - }, - "website": { - "type": "string", - "description": "Official website URL of the organization" - }, - "on_paid_plan": { - "type": "boolean", - "description": "Indicates whether the organization is on a paid plan" - }, "created_at": { "type": "string", "format": "date-time", @@ -3009,16 +4133,48 @@ "format": "date-time", "description": "Timestamp of when the organization was last updated." }, - "owner": { - "type": "integer", - "description": "Identifier of the organization's owner" - }, "members": { "type": "array", "items": { - "type": "integer" + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string" + } + } }, - "description": "List of member identifiers belonging to the organization." + "description": "Members belonging to the organization." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the caller's default organization." + }, + "byok_enabled": { + "type": "boolean", + "description": "Whether bring-your-own-key is enabled for the organization." + }, + "invitations": { + "type": "array", + "items": {}, + "description": "Pending invitations for the organization." + }, + "owner_pricing_plan": { + "type": "string", + "description": "Pricing plan of the organization owner." + }, + "is_owner": { + "type": "boolean", + "description": "Whether the caller owns the organization." + }, + "user_role": { + "type": "string", + "description": "The caller's role within the organization." } } } @@ -3179,44 +4335,14 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the organization." - }, "org_id": { "type": "string", - "description": "Unique organization ID" + "description": "Organization's unique string identifier." }, "name": { "type": "string", "description": "Name of the organization." }, - "description": { - "type": "string", - "description": "Description of the organization" - }, - "address": { - "type": "string", - "description": "Address of the organization" - }, - "contact_email": { - "type": "string", - "format": "email", - "description": "Contact email for the organization" - }, - "phone_number": { - "type": "string", - "description": "Phone number of the organization" - }, - "website": { - "type": "string", - "format": "uri", - "description": "Website of the organization" - }, - "on_paid_plan": { - "type": "boolean", - "description": "Indicates if the organization is on a paid plan" - }, "created_at": { "type": "string", "format": "date-time", @@ -3227,16 +4353,48 @@ "format": "date-time", "description": "Timestamp of when the organization was last updated." }, - "owner": { - "type": "integer", - "description": "Identifier of the organization's owner" - }, "members": { "type": "array", "items": { - "type": "integer" + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string" + } + } }, - "description": "List of member identifiers belonging to the organization." + "description": "Members belonging to the organization." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the caller's default organization." + }, + "byok_enabled": { + "type": "boolean", + "description": "Whether bring-your-own-key is enabled for the organization." + }, + "invitations": { + "type": "array", + "items": {}, + "description": "Pending invitations for the organization." + }, + "owner_pricing_plan": { + "type": "string", + "description": "Pricing plan of the organization owner." + }, + "is_owner": { + "type": "boolean", + "description": "Whether the caller owns the organization." + }, + "user_role": { + "type": "string", + "description": "The caller's role within the organization." } } } @@ -3853,17 +5011,13 @@ "items": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique numeric identifier of the project" - }, "project_id": { "type": "string", "description": "Unique string identifier of the project" }, "name": { "type": "string", - "description": "Name of the project." + "description": "Name of the project" }, "description": { "type": "string", @@ -3891,10 +5045,97 @@ "role": { "type": "string", "description": "Role of the member in the project." + }, + "email": { + "type": "string", + "description": "Email address of the project member." } } }, - "description": "List of members belonging to the project." + "description": "List of members belonging to the project" + }, + "usecase": { + "type": "string", + "nullable": true, + "description": "Configured use case for the project." + }, + "memory_depth": { + "type": "string", + "description": "Memory extraction depth setting for this project." + }, + "inclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be included when extracting memories." + }, + "exclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be excluded when extracting memories." + }, + "custom_categories": { + "type": "array", + "items": { + "type": "object" + }, + "description": "List of custom categories configured for memory categorization in this project." + }, + "custom_instructions": { + "type": "string", + "description": "Custom instructions for memory processing in this project." + }, + "update_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when updating existing memories." + }, + "summary_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when generating summaries." + }, + "summary_batch_size": { + "type": "integer", + "description": "Number of items processed per batch when generating summaries." + }, + "summary_max_tokens": { + "type": "integer", + "nullable": true, + "description": "Maximum tokens allowed in a generated summary." + }, + "expiration_date": { + "type": "string", + "nullable": true, + "description": "Default expiration date applied to memories created in this project." + }, + "enable_graph": { + "type": "boolean", + "description": "Whether graph memory is enabled for this project." + }, + "multilingual": { + "type": "boolean", + "description": "Whether to use the input language for memory storage and retrieval." + }, + "version": { + "type": "string", + "description": "API version used by this project." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the organization's default project." + }, + "active_prompt_profile": { + "type": "string", + "nullable": true, + "description": "Identifier of the active prompt profile for this project." + }, + "decay": { + "type": "boolean", + "description": "Whether memory decay is enabled for this project." + }, + "user_role": { + "type": "string", + "description": "Role of the requesting user within this project." } } } @@ -4086,10 +5327,6 @@ "schema": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Unique numeric identifier of the project" - }, "project_id": { "type": "string", "description": "Unique string identifier of the project" @@ -4124,10 +5361,97 @@ "role": { "type": "string", "description": "Role of the member in the project." + }, + "email": { + "type": "string", + "description": "Email address of the project member." } } }, "description": "List of members belonging to the project" + }, + "usecase": { + "type": "string", + "nullable": true, + "description": "Configured use case for the project." + }, + "memory_depth": { + "type": "string", + "description": "Memory extraction depth setting for this project." + }, + "inclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be included when extracting memories." + }, + "exclusion_prompt": { + "type": "string", + "nullable": true, + "description": "Custom prompt describing what should be excluded when extracting memories." + }, + "custom_categories": { + "type": "array", + "items": { + "type": "object" + }, + "description": "List of custom categories configured for memory categorization in this project." + }, + "custom_instructions": { + "type": "string", + "description": "Custom instructions for memory processing in this project." + }, + "update_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when updating existing memories." + }, + "summary_custom_instructions": { + "type": "string", + "nullable": true, + "description": "Custom instructions applied when generating summaries." + }, + "summary_batch_size": { + "type": "integer", + "description": "Number of items processed per batch when generating summaries." + }, + "summary_max_tokens": { + "type": "integer", + "nullable": true, + "description": "Maximum tokens allowed in a generated summary." + }, + "expiration_date": { + "type": "string", + "nullable": true, + "description": "Default expiration date applied to memories created in this project." + }, + "enable_graph": { + "type": "boolean", + "description": "Whether graph memory is enabled for this project." + }, + "multilingual": { + "type": "boolean", + "description": "Whether to use the input language for memory storage and retrieval." + }, + "version": { + "type": "string", + "description": "API version used by this project." + }, + "is_default": { + "type": "boolean", + "description": "Whether this is the organization's default project." + }, + "active_prompt_profile": { + "type": "string", + "nullable": true, + "description": "Identifier of the active prompt profile for this project." + }, + "decay": { + "type": "boolean", + "description": "Whether memory decay is enabled for this project." + }, + "user_role": { + "type": "string", + "description": "Role of the requesting user within this project." } } } @@ -4236,6 +5560,22 @@ "multilingual": { "type": "boolean", "description": "Whether to use the input language for memory storage and retrieval." + }, + "memory_depth": { + "type": "string", + "description": "Memory extraction depth setting for this project." + }, + "decay": { + "type": "boolean", + "description": "Whether memory decay is enabled for this project." + }, + "enable_graph": { + "type": "boolean", + "description": "Whether graph memory is enabled for this project." + }, + "version": { + "type": "string", + "description": "API version used by this project." } } } @@ -5110,6 +6450,10 @@ }, "description": "List of event types the webhook subscribes to." }, + "owner": { + "type": "string", + "description": "Username of the webhook's owner." + }, "is_active": { "type": "boolean", "description": "Whether the webhook is active" @@ -5154,27 +6498,27 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Get all webhooks\nwebhooks = client.get_webhooks(project_id=\"your_project_id\")\nprint(webhooks)\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)" + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Get all webhooks\nwebhooks = client.get_webhooks(project_id=\"your_project_id\")\nprint(webhooks)\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory_add\", \"memory_categorize\"]\n)\nprint(webhook)" }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Get all webhooks\nclient.getWebhooks('your_project_id')\n .then(webhooks => console.log(webhooks))\n .catch(err => console.error(err));\n\n// Create a webhook\nclient.createWebhook({\n url: 'https://your-webhook-url.com',\n name: 'My Webhook',\n project_id: 'your_project_id',\n event_types: ['memory:add']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Get all webhooks\nclient.getWebhooks('your_project_id')\n .then(webhooks => console.log(webhooks))\n .catch(err => console.error(err));\n\n// Create a webhook\nclient.createWebhook({\n url: 'https://your-webhook-url.com',\n name: 'My Webhook',\n project_id: 'your_project_id',\n event_types: ['memory_add']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));" }, { "lang": "cURL", - "source": "# Get all webhooks\ncurl --request GET \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key'\n\n# Create a webhook\ncurl --request POST \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory:add\"]\n }'" + "source": "# Get all webhooks\ncurl --request GET \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key'\n\n# Create a webhook\ncurl --request POST \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory_add\"]\n }'" }, { "lang": "PHP", - "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\n\n// Create a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory:add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" + "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\n\n// Create a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory_add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" }, { "lang": "Go", - "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Get all webhooks\n\treq, _ := http.NewRequest(\"GET\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Create a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"url\": \"https://your-webhook-url.com\",\n\t\t\"name\": \"My Webhook\",\n\t\t\"event_types\": [\"memory:add\"]\n\t}`)\n\n\treq, _ = http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" + "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Get all webhooks\n\treq, _ := http.NewRequest(\"GET\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Create a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"url\": \"https://your-webhook-url.com\",\n\t\t\"name\": \"My Webhook\",\n\t\t\"event_types\": [\"memory_add\"]\n\t}`)\n\n\treq, _ = http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" }, { "lang": "Java", - "source": "// Get all webhooks\nHttpResponse response = Unirest.get(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory:add\\\"]\n }\")\n .asString();" + "source": "// Get all webhooks\nHttpResponse response = Unirest.get(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory_add\\\"]\n }\")\n .asString();" } ] }, @@ -5203,7 +6547,9 @@ "schema": { "type": "object", "required": [ - "url" + "url", + "name", + "event_types" ], "properties": { "name": { @@ -5219,13 +6565,17 @@ "items": { "type": "string", "enum": [ - "memory:add", - "memory:update", - "memory:delete", - "memory:categorize" + "memory_add", + "memory_update", + "memory_delete", + "memory_categorize", + "ingest_job_completed", + "ingest_job_partially_completed", + "ingest_job_failed", + "ingest_job_cancelled" ] }, - "description": "List of event types to subscribe to." + "description": "List of event types to subscribe to. Required: omitting this field does not fall back to a usable default." }, "is_active": { "type": "boolean", @@ -5263,6 +6613,10 @@ "type": "string" } }, + "owner": { + "type": "string", + "description": "Username of the webhook's owner." + }, "is_active": { "type": "boolean" }, @@ -5288,10 +6642,12 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } + "description": "Field-keyed validation errors. Each key is a request field name; the value is an array of message strings or a nested object of messages.", + "additionalProperties": true, + "example": { + "name": [ + "This field is required." + ] } } } @@ -5317,27 +6673,27 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)" + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory_add\", \"memory_categorize\"]\n)\nprint(webhook)" }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Create a webhook\nclient.createWebhook({\n url: \"https://your-webhook-url.com\",\n name: \"My Webhook\",\n project_id: \"your_project_id\",\n event_types: [\"memory:add\"]\n})\n .then(response => console.log('Create webhook response:', response))\n .catch(error => console.error(error));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Create a webhook\nclient.createWebhook({\n url: \"https://your-webhook-url.com\",\n name: \"My Webhook\",\n project_id: \"your_project_id\",\n event_types: [\"memory_add\"]\n})\n .then(response => console.log('Create webhook response:', response))\n .catch(error => console.error(error));" }, { "lang": "cURL", - "source": "curl -X POST \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\" \\\n -H \"Authorization: Token your-api-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory:add\"]\n }'" + "source": "curl -X POST \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\" \\\n -H \"Authorization: Token your-api-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory_add\"]\n }'" }, { "lang": "PHP", - "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory:add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);\n\necho $response;" + "source": " \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode([\n \"url\" => \"https://your-webhook-url.com\",\n \"name\" => \"My Webhook\",\n \"event_types\" => [\"memory_add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);\n\necho $response;" }, { "lang": "Go", - "source": "package main\n\nimport (\n \"fmt\"\n \"strings\"\n \"net/http\"\n \"io/ioutil\"\n)\n\nfunc main() {\n payload := strings.NewReader(`{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory:add\"]\n }`)\n\n req, _ := http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n req.Header.Add(\"Authorization\", \"Token your-api-key\")\n req.Header.Add(\"Content-Type\", \"application/json\")\n\n res, _ := http.DefaultClient.Do(req)\n defer res.Body.Close()\n body, _ := ioutil.ReadAll(res.Body)\n\n fmt.Println(string(body))\n}" + "source": "package main\n\nimport (\n \"fmt\"\n \"strings\"\n \"net/http\"\n \"io/ioutil\"\n)\n\nfunc main() {\n payload := strings.NewReader(`{\n \"url\": \"https://your-webhook-url.com\",\n \"name\": \"My Webhook\",\n \"event_types\": [\"memory_add\"]\n }`)\n\n req, _ := http.NewRequest(\"POST\", \"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\", payload)\n req.Header.Add(\"Authorization\", \"Token your-api-key\")\n req.Header.Add(\"Content-Type\", \"application/json\")\n\n res, _ := http.DefaultClient.Do(req)\n defer res.Body.Close()\n body, _ := ioutil.ReadAll(res.Body)\n\n fmt.Println(string(body))\n}" }, { "lang": "Java", - "source": "import com.konghq.unirest.http.HttpResponse;\nimport com.konghq.unirest.http.Unirest;\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory:add\\\"]\n }\")\n .asString();\n\nSystem.out.println(response.getBody());" + "source": "import com.konghq.unirest.http.HttpResponse;\nimport com.konghq.unirest.http.Unirest;\n\n// Create a webhook\nHttpResponse response = Unirest.post(\"https://api.mem0.ai/api/v1/webhooks/your_project_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"url\\\": \\\"https://your-webhook-url.com\\\",\n \\\"name\\\": \\\"My Webhook\\\",\n \\\"event_types\\\": [\\\"memory_add\\\"]\n }\")\n .asString();\n\nSystem.out.println(response.getBody());" } ] } @@ -5381,10 +6737,14 @@ "items": { "type": "string", "enum": [ - "memory:add", - "memory:update", - "memory:delete", - "memory:categorize" + "memory_add", + "memory_update", + "memory_delete", + "memory_categorize", + "ingest_job_completed", + "ingest_job_partially_completed", + "ingest_job_failed", + "ingest_job_cancelled" ] }, "description": "New list of event types to subscribe to" @@ -5417,10 +6777,12 @@ "application/json": { "schema": { "type": "object", - "properties": { - "error": { - "type": "string" - } + "description": "Field-keyed validation errors. Each key is a request field name; the value is an array of message strings or a nested object of messages.", + "additionalProperties": true, + "example": { + "name": [ + "This field is required." + ] } } } @@ -5462,27 +6824,27 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Update a webhook\nwebhook = client.update_webhook(\n webhook_id=\"your_webhook_id\",\n name=\"Updated Webhook\",\n url=\"https://new-webhook-url.com\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)\n\n# Delete a webhook\nresponse = client.delete_webhook(webhook_id=\"your_webhook_id\")\nprint(response)" + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Update a webhook\nwebhook = client.update_webhook(\n webhook_id=\"your_webhook_id\",\n name=\"Updated Webhook\",\n url=\"https://new-webhook-url.com\",\n event_types=[\"memory_add\", \"memory_categorize\"]\n)\nprint(webhook)\n\n# Delete a webhook\nresponse = client.delete_webhook(webhook_id=\"your_webhook_id\")\nprint(response)" }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Update a webhook\nclient.updateWebhook('your_webhook_id', {\n name: 'Updated Webhook',\n url: 'https://new-webhook-url.com',\n event_types: ['memory:add', 'memory:categorize']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));\n\n// Delete a webhook\nclient.deleteWebhook('your_webhook_id')\n .then(response => console.log(response))\n .catch(err => console.error(err));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Update a webhook\nclient.updateWebhook('your_webhook_id', {\n name: 'Updated Webhook',\n url: 'https://new-webhook-url.com',\n event_types: ['memory_add', 'memory_categorize']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));\n\n// Delete a webhook\nclient.deleteWebhook('your_webhook_id')\n .then(response => console.log(response))\n .catch(err => console.error(err));" }, { "lang": "cURL", - "source": "# Update a webhook\ncurl --request PUT \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"name\": \"Updated Webhook\",\n \"url\": \"https://new-webhook-url.com\",\n \"event_types\": [\"memory:add\"]\n }'\n\n# Delete a webhook\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key'" + "source": "# Update a webhook\ncurl --request PUT \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key' \\\n --header 'Content-Type: application/json' \\\n --data '{\n \"name\": \"Updated Webhook\",\n \"url\": \"https://new-webhook-url.com\",\n \"event_types\": [\"memory_add\"]\n }'\n\n# Delete a webhook\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/' \\\n --header 'Authorization: Token your-api-key'" }, { "lang": "PHP", - "source": " \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"PUT\",\n CURLOPT_POSTFIELDS => json_encode([\n \"name\" => \"Updated Webhook\",\n \"url\" => \"https://new-webhook-url.com\",\n \"event_types\" => [\"memory:add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\n\n// Delete a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"DELETE\",\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" + "source": " \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"PUT\",\n CURLOPT_POSTFIELDS => json_encode([\n \"name\" => \"Updated Webhook\",\n \"url\" => \"https://new-webhook-url.com\",\n \"event_types\" => [\"memory_add\"]\n ]),\n CURLOPT_HTTPHEADER => [\n \"Authorization: Token your-api-key\",\n \"Content-Type: application/json\"\n ],\n]);\n\n$response = curl_exec($curl);\n\n// Delete a webhook\ncurl_setopt_array($curl, [\n CURLOPT_URL => \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\",\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CUSTOMREQUEST => \"DELETE\",\n CURLOPT_HTTPHEADER => [\"Authorization: Token your-api-key\"],\n]);\n\n$response = curl_exec($curl);\ncurl_close($curl);" }, { "lang": "Go", - "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Update a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"name\": \"Updated Webhook\",\n\t\t\"url\": \"https://new-webhook-url.com\",\n\t\t\"event_types\": [\"memory:add\"]\n\t}`)\n\n\treq, _ := http.NewRequest(\"PUT\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Delete a webhook\n\treq, _ = http.NewRequest(\"DELETE\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" + "source": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"net/http\"\n\t\"io/ioutil\"\n)\n\nfunc main() {\n\t// Update a webhook\n\tpayload := strings.NewReader(`{\n\t\t\"name\": \"Updated Webhook\",\n\t\t\"url\": \"https://new-webhook-url.com\",\n\t\t\"event_types\": [\"memory_add\"]\n\t}`)\n\n\treq, _ := http.NewRequest(\"PUT\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", payload)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\tres, _ := http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ := ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n\n\t// Delete a webhook\n\treq, _ = http.NewRequest(\"DELETE\", \"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\", nil)\n\treq.Header.Add(\"Authorization\", \"Token your-api-key\")\n\n\tres, _ = http.DefaultClient.Do(req)\n\tdefer res.Body.Close()\n\tbody, _ = ioutil.ReadAll(res.Body)\n\tfmt.Println(string(body))\n}" }, { "lang": "Java", - "source": "// Update a webhook\nHttpResponse response = Unirest.put(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"name\\\": \\\"Updated Webhook\\\",\n \\\"url\\\": \\\"https://new-webhook-url.com\\\",\n \\\"event_types\\\": [\\\"memory:add\\\"]\n }\")\n .asString();\n\n// Delete a webhook\nHttpResponse response = Unirest.delete(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();" + "source": "// Update a webhook\nHttpResponse response = Unirest.put(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .header(\"Content-Type\", \"application/json\")\n .body(\"{\n \\\"name\\\": \\\"Updated Webhook\\\",\n \\\"url\\\": \\\"https://new-webhook-url.com\\\",\n \\\"event_types\\\": [\\\"memory_add\\\"]\n }\")\n .asString();\n\n// Delete a webhook\nHttpResponse response = Unirest.delete(\"https://api.mem0.ai/api/v1/webhooks/your_webhook_id/webhook/\")\n .header(\"Authorization\", \"Token your-api-key\")\n .asString();" } ] }, @@ -5587,7 +6949,8 @@ "schemas": { "CreateAgent": { "required": [ - "agent_id" + "agent_id", + "name" ], "type": "object", "properties": { @@ -5608,9 +6971,58 @@ } } }, + "AgentResponse": { + "type": "object", + "description": "Response returned after creating an Agent.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "agent_id": { + "title": "Agent id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "CreateApp": { "required": [ - "app_id" + "app_id", + "name" ], "type": "object", "properties": { @@ -5631,6 +7043,54 @@ } } }, + "AppResponse": { + "type": "object", + "description": "Response returned after creating an App.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "app_id": { + "title": "App id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "MemoryInput": { "type": "object", "properties": { @@ -5697,11 +7157,11 @@ "default": true }, "output_format": { - "description": "Controls the response format structure. `v1.0` (deprecated) returns a direct array of memory objects: `[{...}, {...}]`. `v1.1` (recommended) returns an object with a 'results' key containing the array: `{\"results\": [...]}`. The `v1.0` format will be removed in future versions.", + "description": "Controls the response format structure. `v1.0` (default) returns a direct array of memory objects: `[{...}, {...}]`. `v1.1` returns an object with a 'results' key containing the array: `{\"results\": [...]}`.", "title": "Output format", "type": "string", "nullable": true, - "default": "v1.1" + "default": "v1.0" }, "custom_categories": { "description": "A list of categories with category name and its description.", @@ -5834,7 +7294,7 @@ "title": "Output format", "type": "string", "nullable": true, - "default": "v1.1", + "default": "v1.0", "description": "The search method supports two output formats: `v1.0` (default) and `v1.1`. We recommend using `v1.1` as `v1.0` will be deprecated soon." }, "org_id": { @@ -6145,7 +7605,8 @@ }, "CreateRun": { "required": [ - "run_id" + "run_id", + "name" ], "type": "object", "properties": { @@ -6166,6 +7627,54 @@ } } }, + "RunResponse": { + "type": "object", + "description": "Response returned after creating a Run.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "run_id": { + "title": "Run id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "CreateUser": { "required": [ "user_id" @@ -6184,6 +7693,50 @@ } } }, + "UserResponse": { + "type": "object", + "description": "Response returned after creating a User.", + "properties": { + "id": { + "title": "Id", + "type": "integer", + "description": "Internal numeric identifier." + }, + "user_id": { + "title": "User id", + "type": "string" + }, + "metadata": { + "title": "Metadata", + "type": "object", + "properties": {} + }, + "created_at": { + "title": "Created at", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "title": "Updated at", + "type": "string", + "format": "date-time" + }, + "graph_entities": { + "title": "Graph entities", + "nullable": true, + "description": "Graph entities linked to this record, if any." + }, + "is_deleted": { + "title": "Is deleted", + "type": "boolean" + }, + "project": { + "title": "Project", + "type": "integer", + "description": "Internal numeric identifier of the project this record belongs to." + } + } + }, "DeleteMemoriesInput": { "type": "object", "description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.", @@ -6302,4 +7855,4 @@ } }, "x-original-swagger-version": "2.0" -} +} \ No newline at end of file From 8ca9a0f2c006706f6f0b683e1b8d6c715d6eda86 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 3 Aug 2026 20:36:28 +0530 Subject: [PATCH 4/9] fix: OSS Python SDK hygiene batch, 9 small bug fixes (#6770) --- mem0/embeddings/fastembed.py | 6 +- mem0/embeddings/huggingface.py | 2 +- mem0/embeddings/ollama.py | 14 +--- mem0/memory/utils.py | 8 +- mem0/reranker/cohere_reranker.py | 9 ++- mem0/reranker/huggingface_reranker.py | 14 ++-- .../reranker/sentence_transformer_reranker.py | 16 ++-- mem0/reranker/zero_entropy_reranker.py | 9 ++- mem0/utils/factory.py | 6 +- mem0/vector_stores/mongodb.py | 1 - mem0/vector_stores/vertex_ai_vector_search.py | 1 - .../embeddings/test_huggingface_embeddings.py | 14 +++- tests/memory/test_memory_utils.py | 26 ++++++- tests/rerankers/conftest.py | 32 ++++++++ .../test_reranker_fallback_no_mutation.py | 75 +++++++++++++++++++ .../rerankers/test_reranker_fallback_topk.py | 45 +---------- tests/utils/test_factory.py | 9 +++ 17 files changed, 201 insertions(+), 86 deletions(-) create mode 100644 tests/rerankers/test_reranker_fallback_no_mutation.py diff --git a/mem0/embeddings/fastembed.py b/mem0/embeddings/fastembed.py index fadaedb8c..9434e5089 100644 --- a/mem0/embeddings/fastembed.py +++ b/mem0/embeddings/fastembed.py @@ -1,7 +1,7 @@ -from typing import Optional, Literal +from typing import Literal, Optional -from mem0.embeddings.base import EmbeddingBase from mem0.configs.embeddings.base import BaseEmbedderConfig +from mem0.embeddings.base import EmbeddingBase try: from fastembed import TextEmbedding @@ -29,4 +29,4 @@ class FastEmbedEmbedding(EmbeddingBase): """ text = text.replace("\n", " ") embeddings = list(self.dense_model.embed(text)) - return embeddings[0] + return embeddings[0].tolist() diff --git a/mem0/embeddings/huggingface.py b/mem0/embeddings/huggingface.py index 230fb7e20..7e175f36e 100644 --- a/mem0/embeddings/huggingface.py +++ b/mem0/embeddings/huggingface.py @@ -17,7 +17,7 @@ class HuggingFaceEmbedding(EmbeddingBase): super().__init__(config) if self.config.huggingface_base_url: - self.client = OpenAI(base_url=self.config.huggingface_base_url) + self.client = OpenAI(base_url=self.config.huggingface_base_url, api_key=self.config.api_key) self.config.model = self.config.model or "tei" else: self.config.model = self.config.model or "multi-qa-MiniLM-L6-cos-v1" diff --git a/mem0/embeddings/ollama.py b/mem0/embeddings/ollama.py index c5a74e36e..55c164f2c 100644 --- a/mem0/embeddings/ollama.py +++ b/mem0/embeddings/ollama.py @@ -1,5 +1,3 @@ -import subprocess -import sys from typing import Literal, Optional from mem0.configs.embeddings.base import BaseEmbedderConfig @@ -8,17 +6,7 @@ from mem0.embeddings.base import EmbeddingBase try: from ollama import Client except ImportError: - user_input = input("The 'ollama' library is required. Install it now? [y/N]: ") - if user_input.lower() == "y": - try: - subprocess.check_call([sys.executable, "-m", "pip", "install", "ollama"]) - from ollama import Client - except subprocess.CalledProcessError: - print("Failed to install 'ollama'. Please install it manually using 'pip install ollama'.") - sys.exit(1) - else: - print("The required 'ollama' library is not installed.") - sys.exit(1) + raise ImportError("The 'ollama' library is required. Please install it using 'pip install ollama'.") class OllamaEmbedding(EmbeddingBase): diff --git a/mem0/memory/utils.py b/mem0/memory/utils.py index dd7b1e4cc..b22d60a27 100644 --- a/mem0/memory/utils.py +++ b/mem0/memory/utils.py @@ -121,6 +121,8 @@ def remove_code_blocks(content: str) -> str: - If a code block is detected, it returns only the inner content, stripping out the markers. - If no code block markers are found, the original content is returned as-is. """ + if content is None: + return "" pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$" match = re.match(pattern, content.strip()) match_res=match.group(1).strip() if match else content.strip() @@ -213,8 +215,8 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"): try: description = get_image_description(image_url, llm, vision_details) returned_messages.append({"role": role, "content": description}) - except Exception: - raise Exception(f"Error while downloading {image_url}.") + except Exception as e: + raise Exception(f"Error while downloading {image_url}.") from e else: # Regular text content returned_messages.append(msg) @@ -227,7 +229,7 @@ def process_telemetry_filters(filters): Process the telemetry filters """ if filters is None: - return {} + return [], {} encoded_ids = {} if "user_id" in filters: diff --git a/mem0/reranker/cohere_reranker.py b/mem0/reranker/cohere_reranker.py index 281fabcc6..6063ad0a7 100644 --- a/mem0/reranker/cohere_reranker.py +++ b/mem0/reranker/cohere_reranker.py @@ -1,6 +1,6 @@ import logging import os -from typing import List, Dict, Any +from typing import Any, Dict, List from mem0.reranker.base import BaseReranker @@ -84,7 +84,10 @@ class CohereReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("Cohere reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/reranker/huggingface_reranker.py b/mem0/reranker/huggingface_reranker.py index be87d3dba..6af1bee2e 100644 --- a/mem0/reranker/huggingface_reranker.py +++ b/mem0/reranker/huggingface_reranker.py @@ -1,14 +1,15 @@ import logging -from typing import List, Dict, Any, Union +from typing import Any, Dict, List, Union + import numpy as np -from mem0.reranker.base import BaseReranker from mem0.configs.rerankers.base import BaseRerankerConfig from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig +from mem0.reranker.base import BaseReranker try: - from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer TRANSFORMERS_AVAILABLE = True except ImportError: TRANSFORMERS_AVAILABLE = False @@ -163,7 +164,10 @@ class HuggingFaceReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("HuggingFace reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/reranker/sentence_transformer_reranker.py b/mem0/reranker/sentence_transformer_reranker.py index d891294f1..2b92f3e80 100644 --- a/mem0/reranker/sentence_transformer_reranker.py +++ b/mem0/reranker/sentence_transformer_reranker.py @@ -1,10 +1,13 @@ import logging -from typing import List, Dict, Any, Union +from typing import Any, Dict, List, Union + import numpy as np -from mem0.reranker.base import BaseReranker from mem0.configs.rerankers.base import BaseRerankerConfig -from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig +from mem0.configs.rerankers.sentence_transformer import ( + SentenceTransformerRerankerConfig, +) +from mem0.reranker.base import BaseReranker try: from sentence_transformers import CrossEncoder @@ -108,7 +111,10 @@ class SentenceTransformerReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/reranker/zero_entropy_reranker.py b/mem0/reranker/zero_entropy_reranker.py index dcf71bfaf..68228c85d 100644 --- a/mem0/reranker/zero_entropy_reranker.py +++ b/mem0/reranker/zero_entropy_reranker.py @@ -1,6 +1,6 @@ import logging import os -from typing import List, Dict, Any +from typing import Any, Dict, List from mem0.reranker.base import BaseReranker @@ -95,7 +95,10 @@ class ZeroEntropyReranker(BaseReranker): except Exception as e: # Fallback to original order if reranking fails logger.warning("Zero Entropy reranking failed, falling back to original order: %s", e) + fallback_docs = [] for doc in documents: - doc['rerank_score'] = 0.0 + fallback_doc = doc.copy() + fallback_doc['rerank_score'] = 0.0 + fallback_docs.append(fallback_doc) final_top_k = top_k or self.config.top_k - return documents[:final_top_k] if final_top_k else documents \ No newline at end of file + return fallback_docs[:final_top_k] if final_top_k else fallback_docs \ No newline at end of file diff --git a/mem0/utils/factory.py b/mem0/utils/factory.py index 866a4da73..30a1079bb 100644 --- a/mem0/utils/factory.py +++ b/mem0/utils/factory.py @@ -19,7 +19,9 @@ from mem0.configs.rerankers.base import BaseRerankerConfig from mem0.configs.rerankers.cohere import CohereRerankerConfig from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig from mem0.configs.rerankers.llm import LLMRerankerConfig -from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig +from mem0.configs.rerankers.sentence_transformer import ( + SentenceTransformerRerankerConfig, +) from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig from mem0.embeddings.mock import MockEmbeddings @@ -86,7 +88,7 @@ class LlmFactory: config = config_class(**kwargs) elif isinstance(config, dict): # Merge dict config with kwargs - config.update(kwargs) + config = {**config, **kwargs} config = config_class(**config) elif isinstance(config, BaseLlmConfig): # Convert base config to provider-specific config if needed diff --git a/mem0/vector_stores/mongodb.py b/mem0/vector_stores/mongodb.py index a7738c6f2..53c0b7de6 100644 --- a/mem0/vector_stores/mongodb.py +++ b/mem0/vector_stores/mongodb.py @@ -15,7 +15,6 @@ except ImportError: from mem0.vector_stores.base import VectorStoreBase logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) _DRIVER_METADATA = DriverInfo(name="Mem0", version=version("mem0ai")) diff --git a/mem0/vector_stores/vertex_ai_vector_search.py b/mem0/vector_stores/vertex_ai_vector_search.py index 8ba1ceea7..0954bf90c 100644 --- a/mem0/vector_stores/vertex_ai_vector_search.py +++ b/mem0/vector_stores/vertex_ai_vector_search.py @@ -22,7 +22,6 @@ from mem0.configs.vector_stores.vertex_ai_vector_search import ( from mem0.vector_stores.base import VectorStoreBase # Configure logging -logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) diff --git a/tests/embeddings/test_huggingface_embeddings.py b/tests/embeddings/test_huggingface_embeddings.py index 91f3ffe40..ed2ee61e8 100644 --- a/tests/embeddings/test_huggingface_embeddings.py +++ b/tests/embeddings/test_huggingface_embeddings.py @@ -94,7 +94,7 @@ def test_embed_with_huggingface_base_url(): embedder = HuggingFaceEmbedding(config) result = embedder.embed("Hello from custom endpoint") - mock_openai.assert_called_once_with(base_url="http://localhost:8080") + mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key=None) mock_client.embeddings.create.assert_called_once_with( input="Hello from custom endpoint", model="my-custom-model", @@ -103,6 +103,18 @@ def test_embed_with_huggingface_base_url(): assert result == [0.1, 0.2, 0.3] +def test_embed_with_huggingface_base_url_forwards_api_key(): + config = BaseEmbedderConfig( + huggingface_base_url="http://localhost:8080", + model="my-custom-model", + api_key="tei-token", + ) + with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai: + HuggingFaceEmbedding(config) + + mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="tei-token") + + def test_embed_batch_sentence_transformer(mock_sentence_transformer): config = BaseEmbedderConfig() embedder = HuggingFaceEmbedding(config) diff --git a/tests/memory/test_memory_utils.py b/tests/memory/test_memory_utils.py index 49c55c5cb..3bab79429 100644 --- a/tests/memory/test_memory_utils.py +++ b/tests/memory/test_memory_utils.py @@ -1,9 +1,12 @@ -import pytest from unittest.mock import Mock +import pytest + from mem0.memory.utils import ( parse_messages, parse_vision_messages, + process_telemetry_filters, + remove_code_blocks, remove_spaces_from_entities, sanitize_relationship_for_cypher, ) @@ -114,6 +117,17 @@ class TestParseVisionMessages: parse_vision_messages(messages, llm=mock_llm) mock_llm.generate_response.assert_not_called() + def test_download_failure_preserves_original_exception(self): + mock_llm = Mock() + mock_llm.generate_response.side_effect = ValueError("network down") + messages = [ + {"role": "user", "content": {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}} + ] + with pytest.raises(Exception) as exc_info: + parse_vision_messages(messages, llm=mock_llm) + assert isinstance(exc_info.value.__cause__, ValueError) + assert "network down" in str(exc_info.value.__cause__) + class TestRemoveSpacesFromEntities: """ @@ -168,3 +182,13 @@ class TestRemoveSpacesFromEntities: f = remove_spaces_from_entities([dict(base)], sanitize_relationship=False)[0]["relationship"] assert t == sanitize_relationship_for_cypher("a/b") assert f == "a/b" + + +class TestProcessTelemetryFilters: + def test_none_filters_returns_empty_list_and_dict(self): + assert process_telemetry_filters(None) == ([], {}) + + +class TestRemoveCodeBlocks: + def test_none_content_returns_empty_string(self): + assert remove_code_blocks(None) == "" diff --git a/tests/rerankers/conftest.py b/tests/rerankers/conftest.py index 258d959cb..edae4f581 100644 --- a/tests/rerankers/conftest.py +++ b/tests/rerankers/conftest.py @@ -1,3 +1,5 @@ +import sys +from types import ModuleType from unittest.mock import MagicMock, patch import pytest @@ -9,3 +11,33 @@ def mock_llm(): mock_llm_instance = MagicMock() mock_factory.create.return_value = mock_llm_instance yield mock_factory, mock_llm_instance + + +@pytest.fixture +def mock_cohere(monkeypatch): + """Provide a fake ``cohere`` module so CohereReranker imports/constructs.""" + fake_cohere = ModuleType("cohere") + fake_client = MagicMock() + fake_cohere.Client = MagicMock(return_value=fake_client) + monkeypatch.setitem(sys.modules, "cohere", fake_cohere) + + import mem0.reranker.cohere_reranker as cohere_reranker + + monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False) + monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False) + return cohere_reranker, fake_client + + +@pytest.fixture +def mock_zero_entropy(monkeypatch): + """Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports.""" + fake_module = ModuleType("zeroentropy") + fake_client = MagicMock() + fake_module.ZeroEntropy = MagicMock(return_value=fake_client) + monkeypatch.setitem(sys.modules, "zeroentropy", fake_module) + + import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker + + monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False) + monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False) + return zero_entropy_reranker, fake_client diff --git a/tests/rerankers/test_reranker_fallback_no_mutation.py b/tests/rerankers/test_reranker_fallback_no_mutation.py new file mode 100644 index 000000000..3a33ded60 --- /dev/null +++ b/tests/rerankers/test_reranker_fallback_no_mutation.py @@ -0,0 +1,75 @@ +"""Regression tests pinning that the reranker fallback path never mutates caller-owned documents.""" + +from unittest.mock import MagicMock, patch + +from mem0.configs.rerankers.cohere import CohereRerankerConfig +from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig +from mem0.configs.rerankers.sentence_transformer import ( + SentenceTransformerRerankerConfig, +) +from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig +from mem0.reranker.huggingface_reranker import HuggingFaceReranker +from mem0.reranker.sentence_transformer_reranker import SentenceTransformerReranker + + +def _docs(n): + return [{"memory": f"doc{i}"} for i in range(n)] + + +class TestCohereFallbackNoMutation: + def test_fallback_does_not_mutate_original_documents(self, mock_cohere): + module, fake_client = mock_cohere + fake_client.rerank.side_effect = RuntimeError("API error") + + reranker = module.CohereReranker(CohereRerankerConfig(api_key="test-key")) + documents = _docs(3) + result = reranker.rerank("query", documents) + + assert all("rerank_score" not in doc for doc in documents) + assert all(doc["rerank_score"] == 0.0 for doc in result) + + +class TestZeroEntropyFallbackNoMutation: + def test_fallback_does_not_mutate_original_documents(self, mock_zero_entropy): + module, fake_client = mock_zero_entropy + fake_client.models.rerank.side_effect = RuntimeError("API error") + + reranker = module.ZeroEntropyReranker(ZeroEntropyRerankerConfig(api_key="test-key")) + documents = _docs(3) + result = reranker.rerank("query", documents) + + assert all("rerank_score" not in doc for doc in documents) + assert all(doc["rerank_score"] == 0.0 for doc in result) + + +class TestHuggingFaceFallbackNoMutation: + def test_fallback_does_not_mutate_original_documents(self): + with ( + patch("mem0.reranker.huggingface_reranker.AutoTokenizer") as mock_tokenizer_cls, + patch("mem0.reranker.huggingface_reranker.AutoModelForSequenceClassification") as mock_model_cls, + ): + mock_tokenizer = MagicMock(side_effect=RuntimeError("tokenizer error")) + mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer + mock_model_cls.from_pretrained.return_value = MagicMock() + + reranker = HuggingFaceReranker(HuggingFaceRerankerConfig()) + documents = _docs(3) + result = reranker.rerank("query", documents) + + assert all("rerank_score" not in doc for doc in documents) + assert all(doc["rerank_score"] == 0.0 for doc in result) + + +class TestSentenceTransformerFallbackNoMutation: + def test_fallback_does_not_mutate_original_documents(self): + with patch("mem0.reranker.sentence_transformer_reranker.CrossEncoder") as mock_cross_encoder_cls: + mock_model = MagicMock() + mock_model.predict.side_effect = RuntimeError("predict error") + mock_cross_encoder_cls.return_value = mock_model + + reranker = SentenceTransformerReranker(SentenceTransformerRerankerConfig()) + documents = _docs(3) + result = reranker.rerank("query", documents) + + assert all("rerank_score" not in doc for doc in documents) + assert all(doc["rerank_score"] == 0.0 for doc in result) diff --git a/tests/rerankers/test_reranker_fallback_topk.py b/tests/rerankers/test_reranker_fallback_topk.py index a2767e8ae..aa6410a32 100644 --- a/tests/rerankers/test_reranker_fallback_topk.py +++ b/tests/rerankers/test_reranker_fallback_topk.py @@ -1,52 +1,9 @@ -"""Regression tests for the reranker fallback path honoring ``config.top_k``. - -When the underlying rerank call fails, the reranker falls back to returning the -documents in their original order. That fallback must still respect the -configured ``top_k`` limit, exactly like the success path does. The HuggingFace -and SentenceTransformer rerankers already behave this way; these tests pin the -same contract for the Cohere and ZeroEntropy rerankers. -""" - -import sys -from types import ModuleType -from unittest.mock import MagicMock - -import pytest +"""Regression tests pinning that the reranker fallback path still honors ``config.top_k``.""" from mem0.configs.rerankers.cohere import CohereRerankerConfig from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig -@pytest.fixture -def mock_cohere(monkeypatch): - """Provide a fake ``cohere`` module so CohereReranker imports/constructs.""" - fake_cohere = ModuleType("cohere") - fake_client = MagicMock() - fake_cohere.Client = MagicMock(return_value=fake_client) - monkeypatch.setitem(sys.modules, "cohere", fake_cohere) - - import mem0.reranker.cohere_reranker as cohere_reranker - - monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False) - monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False) - return cohere_reranker, fake_client - - -@pytest.fixture -def mock_zero_entropy(monkeypatch): - """Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports.""" - fake_module = ModuleType("zeroentropy") - fake_client = MagicMock() - fake_module.ZeroEntropy = MagicMock(return_value=fake_client) - monkeypatch.setitem(sys.modules, "zeroentropy", fake_module) - - import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker - - monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False) - monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False) - return zero_entropy_reranker, fake_client - - def _docs(n): return [{"memory": f"doc{i}"} for i in range(n)] diff --git a/tests/utils/test_factory.py b/tests/utils/test_factory.py index 0f07bd2d5..cbb63890b 100644 --- a/tests/utils/test_factory.py +++ b/tests/utils/test_factory.py @@ -51,3 +51,12 @@ def test_base_to_provider_without_reasoning_fields_still_builds(): assert isinstance(built, AnthropicConfig) assert built.model == "claude-3-5-sonnet-20240620" + + +def test_dict_config_not_mutated_by_kwargs(): + config = {"model": "gpt-4o-mini"} + + with patch("mem0.utils.factory.load_class", return_value=Mock(return_value=Mock())): + LlmFactory.create("openai", config, api_key="secret") + + assert config == {"model": "gpt-4o-mini"} From ea6fd3b457e06d0f27e9b3b54b81b1e69f6a4e23 Mon Sep 17 00:00:00 2001 From: Harsh Vardhan Gupta Date: Mon, 3 Aug 2026 21:05:50 +0530 Subject: [PATCH 5/9] docs: correct add-memories endpoint to /v3/memories/add/ in entity-scoped memory guide (#6774) --- docs/platform/features/entity-scoped-memory.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/platform/features/entity-scoped-memory.mdx b/docs/platform/features/entity-scoped-memory.mdx index 305ad6c09..b42aeee3b 100644 --- a/docs/platform/features/entity-scoped-memory.mdx +++ b/docs/platform/features/entity-scoped-memory.mdx @@ -83,7 +83,7 @@ Passing both `user_id` and `agent_id` to `client.add` does **not** produce recor As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting. -The HTTP equivalent uses `POST /v1/memories/` with the same identifiers in the JSON body. See the Add Memories API reference for REST details. +The HTTP equivalent uses `POST /v3/memories/add/` with the same identifiers in the JSON body. See the [Add Memories](/api-reference/memory/add-memories) API reference for REST details. ## See it in action From 21aae599beb6a9743b357709dd654fa5ceaee77e Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 3 Aug 2026 21:29:47 +0530 Subject: [PATCH 6/9] feat(cli): remove mem0 version subcommand from md files and fix help --json in the Python CLI (#6773) --- cli/README.md | 3 +-- cli/node/README.md | 10 ++------ cli/node/development.md | 2 +- cli/node/src/commands/utils.ts | 7 +----- cli/node/src/index.ts | 4 +-- cli/node/tests/cli-integration.test.ts | 24 ++++++++++++------ cli/node/tests/option-parity.test.ts | 32 +++++++++++++++++++++++- cli/python/README.md | 10 ++------ cli/python/development.md | 2 +- cli/python/src/mem0_cli/app.py | 6 +++-- cli/python/tests/test_cli_integration.py | 26 +++++++++++++++++++ cli/python/tests/test_option_parity.py | 26 ++++++++++++++++++- 12 files changed, 113 insertions(+), 39 deletions(-) diff --git a/cli/README.md b/cli/README.md index d411daa65..2bce6aad6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,9 +60,8 @@ mem0 delete | `mem0 entity` | List or delete entities (users, agents, apps, runs) | | `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) | | `mem0 status` | Verify API connection and display current project | -| `mem0 version` | Print the CLI version | -Run `mem0 --help` for detailed usage on any command. +Run `mem0 --help` for detailed usage on any command, or `mem0 --version` to print the CLI version. ## Agent mode diff --git a/cli/node/README.md b/cli/node/README.md index 6110d1149..dd96b6515 100644 --- a/cli/node/README.md +++ b/cli/node/README.md @@ -232,14 +232,6 @@ Verify your API connection and display the current project. mem0 status ``` -### `mem0 version` - -Print the CLI version. - -```bash -mem0 version -``` - ## Agent mode Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: @@ -298,6 +290,8 @@ These flags are available on all commands: | `--base-url` | Override the configured API base URL for this request | | `-o, --output` | Set the output format | +`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one. + ## Environment variables | Variable | Description | diff --git a/cli/node/development.md b/cli/node/development.md index 5b5d0b060..38317253f 100644 --- a/cli/node/development.md +++ b/cli/node/development.md @@ -51,7 +51,7 @@ pnpm link --global # Now use it like a normal CLI mem0 --help -mem0 version +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`. diff --git a/cli/node/src/commands/utils.ts b/cli/node/src/commands/utils.ts index 1e12d231a..354200d71 100644 --- a/cli/node/src/commands/utils.ts +++ b/cli/node/src/commands/utils.ts @@ -1,5 +1,5 @@ /** - * Utility commands: status, version, import. + * Utility commands: status, import. */ import fs from "node:fs"; @@ -8,7 +8,6 @@ import type { Backend } from "../backend/base.js"; import { colors, printError, printSuccess, timedStatus } from "../branding.js"; import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js"; import { setCurrentCommand } from "../state.js"; -import { CLI_VERSION } from "../version.js"; const { brand, dim, success, error: errorColor } = colors; @@ -82,10 +81,6 @@ export async function cmdStatus( console.log(); } -export function cmdVersion(): void { - console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`); -} - export async function cmdImport( backend: Backend, filePath: string, diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index dfe46910f..d102b5a33 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -858,8 +858,8 @@ program .addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json") .action((opts) => { // opts.json is set when `mem0 help --json` is used (subcommand flag). - // program.opts().json is set when the root --json global flag was used first. - if (opts.json || program.opts().json) { + // program.opts().json/.agent is set when a root global flag was used first. + if (opts.json || program.opts().json || program.opts().agent) { // Load spec from parent directory const __dirname = path.dirname(fileURLToPath(import.meta.url)); const specPath = path.join(__dirname, "..", "..", "cli-spec.json"); diff --git a/cli/node/tests/cli-integration.test.ts b/cli/node/tests/cli-integration.test.ts index 630cfec4b..285a8abc8 100644 --- a/cli/node/tests/cli-integration.test.ts +++ b/cli/node/tests/cli-integration.test.ts @@ -44,15 +44,25 @@ describe("CLI Integration — help and version", () => { expect(result.stdout).toContain("search"); }); - 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("prints the version with --version, and has no version subcommand", () => { + const flag = run(["--version"]); + expect(flag.exitCode).toBe(0); + expect(flag.stdout).toContain("Mem0"); + expect(run(["version"]).exitCode).not.toBe(0); }); + it.each([["help", "--json"], ["--json", "help"], ["--agent", "help"]])( + "%s %s produces valid JSON", + (...args) => { + const result = run(args); + 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); diff --git a/cli/node/tests/option-parity.test.ts b/cli/node/tests/option-parity.test.ts index 72f46a5f2..4a0ff9b90 100644 --- a/cli/node/tests/option-parity.test.ts +++ b/cli/node/tests/option-parity.test.ts @@ -16,18 +16,45 @@ const OPENAPI_PATH = path.join( "openapi.json", ); -const KNOWN_UNSURFACED: Record> = {}; +const KNOWN_UNSURFACED: Record> = { + "/v3/memories/add/": { + includes: "extraction hint, no CLI flag yet", + excludes: "extraction hint, no CLI flag yet", + enable_graph: "graph memory toggle, no CLI flag yet", + output_format: "response envelope is pinned by the CLI", + prompt_profile_id: "no CLI flag yet", + temporal_reasoning: "no CLI flag yet", + timezone: "no CLI flag yet", + observation_datetime: "no CLI flag yet, --timestamp backdates instead", + observation_date: "no CLI flag yet, --timestamp backdates instead", + }, + "/v3/memories/search/": { + categories: "expressible through --filter", + metadata: "expressible through --filter", + }, + "/v3/memories/": { + start_date: "covered by --after via filters.created_at.gte", + end_date: "covered by --before via filters.created_at.lte", + categories: "covered by --category via filters.categories", + fields: "no CLI flag yet", + keywords: "no CLI flag yet", + }, +}; const ADD_MAPPING: Record = { messages: ["--messages", "--file", "text"], user_id: ["--user-id"], agent_id: ["--agent-id"], + app_id: ["--app-id"], run_id: ["--run-id"], metadata: ["--metadata"], expiration_date: ["--expires"], custom_instructions: ["--custom-instructions"], custom_categories: ["--custom-categories"], infer: ["--no-infer"], + immutable: ["--immutable"], + structured_data_schema: ["--structured-data-schema"], + timestamp: ["--timestamp"], }; const SEARCH_MAPPING: Record = { @@ -38,6 +65,7 @@ const SEARCH_MAPPING: Record = { threshold: ["--threshold"], rerank: ["--rerank"], reference_date: ["--reference-date"], + fields: ["--fields"], }; const LIST_MAPPING: Record = { @@ -50,6 +78,8 @@ const LIST_MAPPING: Record = { "--before", ], show_expired: ["--show-expired"], + page: ["--page"], + page_size: ["--page-size"], }; function documentedFields(endpoint: string): string[] { diff --git a/cli/python/README.md b/cli/python/README.md index a6ac0beb1..8cb0aa29d 100644 --- a/cli/python/README.md +++ b/cli/python/README.md @@ -241,14 +241,6 @@ Verify your API connection and display the current project. mem0 status ``` -### `mem0 version` - -Print the CLI version. - -```bash -mem0 version -``` - ## Agent mode Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: @@ -307,6 +299,8 @@ These flags are available on all commands: | `--base-url` | Override the configured API base URL for this request | | `-o, --output` | Set the output format | +`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one. + ## Environment variables | Variable | Description | diff --git a/cli/python/development.md b/cli/python/development.md index e05a2b062..bf77594a5 100644 --- a/cli/python/development.md +++ b/cli/python/development.md @@ -80,7 +80,7 @@ mem0 --help # Using Python directly (with venv activated) source .venv/bin/activate mem0 --help -mem0 version +mem0 --version # Or run without activating .venv/bin/mem0 --help diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 9eb4f58ef..d3258bf6a 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -1350,8 +1350,10 @@ def help( mem0 help mem0 help --json """ - if json: - console.print(_json.dumps(_build_help_json(), indent=2)) + from mem0_cli.state import is_agent_mode + + if json or is_agent_mode(): + console.print_json(_json.dumps(_build_help_json())) else: console.print( f"[{BRAND_COLOR}]◆ mem0 CLI[/] v{__version__} — The Memory Layer for AI Agents\n" diff --git a/cli/python/tests/test_cli_integration.py b/cli/python/tests/test_cli_integration.py index 8abd145d0..fb2b9a029 100644 --- a/cli/python/tests/test_cli_integration.py +++ b/cli/python/tests/test_cli_integration.py @@ -7,6 +7,7 @@ boundaries). from __future__ import annotations +import json import os import re import subprocess @@ -84,6 +85,31 @@ class TestCLIIntegration: assert "add" in result.stdout assert "search" in result.stdout + def test_version_flag_only(self): + from mem0_cli import __version__ + + flag = _run(["--version"]) + assert flag.returncode == 0 + assert __version__ in flag.stdout + assert _run(["version"]).returncode != 0 + + @pytest.mark.parametrize( + "args", + [["help", "--json"], ["--json", "help"], ["help", "--agent"], ["--agent", "help"]], + ) + def test_help_json_produces_valid_json(self, args): + result = _run(args) + assert result.returncode == 0 + spec = json.loads(result.stdout) + assert spec["name"] == "mem0" + assert "add" in spec["commands"] + + def test_help_without_json_is_text(self): + result = _run(["help"]) + assert result.returncode == 0 + with pytest.raises(json.JSONDecodeError): + json.loads(result.stdout) + def test_add_help(self): result = _run(["add", "--help"]) assert result.returncode == 0 diff --git a/cli/python/tests/test_option_parity.py b/cli/python/tests/test_option_parity.py index 191cb32f4..e7bc575f3 100644 --- a/cli/python/tests/test_option_parity.py +++ b/cli/python/tests/test_option_parity.py @@ -10,18 +10,39 @@ from mem0_cli.app import app REPO_ROOT = Path(__file__).resolve().parents[3] OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json" -KNOWN_UNSURFACED: dict[tuple[str, str], str] = {} +KNOWN_UNSURFACED: dict[tuple[str, str], str] = { + ("/v3/memories/add/", "includes"): "extraction hint, no CLI flag yet", + ("/v3/memories/add/", "excludes"): "extraction hint, no CLI flag yet", + ("/v3/memories/add/", "enable_graph"): "graph memory toggle, no CLI flag yet", + ("/v3/memories/add/", "output_format"): "response envelope is pinned by the CLI", + ("/v3/memories/add/", "prompt_profile_id"): "no CLI flag yet", + ("/v3/memories/add/", "temporal_reasoning"): "no CLI flag yet", + ("/v3/memories/add/", "timezone"): "no CLI flag yet", + ("/v3/memories/add/", "observation_datetime"): "no CLI flag yet, --timestamp backdates instead", + ("/v3/memories/add/", "observation_date"): "no CLI flag yet, --timestamp backdates instead", + ("/v3/memories/search/", "categories"): "expressible through --filter", + ("/v3/memories/search/", "metadata"): "expressible through --filter", + ("/v3/memories/", "start_date"): "covered by --after via filters.created_at.gte", + ("/v3/memories/", "end_date"): "covered by --before via filters.created_at.lte", + ("/v3/memories/", "categories"): "covered by --category via filters.categories", + ("/v3/memories/", "fields"): "no CLI flag yet", + ("/v3/memories/", "keywords"): "no CLI flag yet", +} ADD_MAPPING: dict[str, list[str]] = { "messages": ["messages", "file", "text"], "user_id": ["user_id"], "agent_id": ["agent_id"], + "app_id": ["app_id"], "run_id": ["run_id"], "metadata": ["metadata"], "expiration_date": ["expires"], "custom_instructions": ["custom_instructions"], "custom_categories": ["custom_categories"], "infer": ["no_infer"], + "immutable": ["immutable"], + "structured_data_schema": ["structured_data_schema"], + "timestamp": ["timestamp"], } SEARCH_MAPPING: dict[str, list[str]] = { @@ -32,11 +53,14 @@ SEARCH_MAPPING: dict[str, list[str]] = { "threshold": ["threshold"], "rerank": ["rerank"], "reference_date": ["reference_date"], + "fields": ["fields"], } LIST_MAPPING: dict[str, list[str]] = { "filters": ["user_id", "agent_id", "run_id", "category", "after", "before"], "show_expired": ["show_expired"], + "page": ["page"], + "page_size": ["page_size"], } From fd32b980b4110973f3f5a27edf8fdd7f485c8dd8 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 3 Aug 2026 23:41:24 +0530 Subject: [PATCH 7/9] docs(openapi): complete parameter and description coverage for current v1 memory routes (#6775) --- docs/openapi.json | 258 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 229 insertions(+), 29 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 468ed2993..0bc42eb72 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1878,6 +1878,24 @@ ], "description": "Despite the endpoint name, this returns memory history entries (the same shape as `GET /v1/memories/{memory_id}/history/`), not event/ingestion-job records. For event/ingestion-job status, use `GET /v1/event/{event_id}/`.", "operationId": "memories_events_list", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Page number for pagination. Default: 1. Default page size is 100." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Number of items per page. Default: 100." + } + ], "responses": { "200": { "description": "Successfully retrieved memory history entries.", @@ -1940,6 +1958,21 @@ "type": "string", "description": "The identifier of the user associated with this memory" }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the agent associated with this memory, if any." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the app associated with this memory, if any." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "The run identifier associated with this memory, returned as `session_id`." + }, "categories": { "type": "array", "nullable": true, @@ -2004,6 +2037,22 @@ } } } + }, + "404": { + "description": "Invalid page.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "Invalid page." + } + } + } + } + } } } } @@ -3114,7 +3163,42 @@ "tags": [ "memories" ], + "description": "Returns a paginated list of memories belonging to the given entity.", "operationId": "memories_entity_read", + "parameters": [ + { + "name": "entity_type", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "user", + "agent", + "run", + "app" + ] + }, + "description": "The type of entity to retrieve memories for. Any other value returns `400 {\"error\": \"Invalid entity type\"}`." + }, + { + "name": "entity_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The identifier of the entity whose memories to retrieve." + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Page number for pagination. Default: 1. Page size is fixed at 100 and is not client-configurable." + } + ], "responses": { "200": { "description": "Successfully retrieved memories.", @@ -3218,34 +3302,25 @@ } } } - } - } - }, - "parameters": [ - { - "name": "entity_type", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "user", - "agent", - "run", - "app" - ] }, - "description": "The type of entity to retrieve memories for." - }, - { - "name": "entity_id", - "in": "path", - "required": true, - "schema": { - "type": "string" + "404": { + "description": "Invalid page.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "Invalid page." + } + } + } + } + } } } - ] + } }, "/v1/memories/{memory_id}/": { "get": { @@ -3288,6 +3363,21 @@ "nullable": true, "description": "Identifier of the user associated with this memory" }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the agent associated with this memory, if any." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the app associated with this memory, if any." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "The run identifier associated with this memory, returned as `session_id`." + }, "metadata": { "type": "object", "additionalProperties": true, @@ -3339,6 +3429,22 @@ } } }, + "400": { + "description": "Invalid memory ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "memory_id should be a valid UUID" + } + } + } + } + } + }, "404": { "description": "Memory not found.", "content": { @@ -3387,7 +3493,7 @@ "tags": [ "memories" ], - "description": "Get or Update or delete a memory.", + "description": "Update a memory's text, metadata, or expiration date.", "operationId": "memories_update", "parameters": [ { @@ -3398,7 +3504,7 @@ "type": "string", "format": "uuid" }, - "description": "The unique identifier of the memory to retrieve." + "description": "The unique identifier of the memory to update." } ], "requestBody": { @@ -3448,6 +3554,21 @@ "nullable": true, "description": "Identifier of the user associated with this memory" }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the agent associated with this memory, if any." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the app associated with this memory, if any." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "The run identifier associated with this memory, returned as `session_id`." + }, "metadata": { "type": "object", "additionalProperties": true, @@ -3498,6 +3619,38 @@ } } } + }, + "400": { + "description": "Invalid memory ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "memory_id should be a valid UUID" + } + } + } + } + } + }, + "404": { + "description": "Memory not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Memory not found!" + } + } + } + } + } } }, "x-code-samples": [ @@ -3532,7 +3685,7 @@ "tags": [ "memories" ], - "description": "Get or Update or delete a memory.", + "description": "Delete a memory.", "operationId": "memories_delete", "parameters": [ { @@ -3543,7 +3696,7 @@ "type": "string", "format": "uuid" }, - "description": "The unique identifier of the memory to retrieve." + "description": "The unique identifier of the memory to delete." }, { "name": "delete_linked", @@ -3575,6 +3728,38 @@ } } } + }, + "400": { + "description": "Invalid memory ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "memory_id should be a valid UUID" + } + } + } + } + } + }, + "404": { + "description": "Memory not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Memory not found!" + } + } + } + } + } } }, "x-code-samples": [ @@ -3682,6 +3867,21 @@ "type": "string", "description": "The identifier of the user associated with this memory" }, + "agent_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the agent associated with this memory, if any." + }, + "app_id": { + "type": "string", + "nullable": true, + "description": "The identifier of the app associated with this memory, if any." + }, + "session_id": { + "type": "string", + "nullable": true, + "description": "The run identifier associated with this memory, returned as `session_id`." + }, "categories": { "type": "array", "nullable": true, From dd54e387de195bffe61deb85c4251af2ec0ad116 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 3 Aug 2026 23:56:12 +0530 Subject: [PATCH 8/9] docs(openapi): document limit param and error responses on GET /v1/memories/ (#6779) --- docs/openapi.json | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 0bc42eb72..9770b590a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1218,6 +1218,15 @@ }, "description": "Number of items per page. Default: 100." }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "maximum": 200 + }, + "description": "Maximum number of memories to return. Maximum accepted value: 200. Ignored when both `page` and `page_size` are supplied." + }, { "name": "start_date", "in": "query", @@ -1261,6 +1270,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "User-supplied metadata attached to the memory." }, @@ -1288,6 +1298,7 @@ }, "structured_attributes": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, @@ -1346,6 +1357,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "User-supplied metadata attached to the memory." }, @@ -1373,6 +1385,7 @@ }, "structured_attributes": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, @@ -1413,14 +1426,47 @@ }, "400": { "description": "Bad Request.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "array", + "description": "Returned when none of the required filters (app_id, user_id, agent_id, run_id) is supplied.", + "items": { + "type": "string" + }, + "example": [ + "One of the filters: app_id, user_id, agent_id, run_id is required!" + ] + }, + { + "type": "object", + "description": "Returned when the `limit` query parameter is invalid.", + "properties": { + "error": { + "type": "string" + } + }, + "example": { + "error": "limit must be <= 200" + } + } + ] + } + } + } + }, + "404": { + "description": "Not Found.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "message": { + "detail": { "type": "string", - "example": "One of the filters: app_id, user_id, agent_id, run_id is required!" + "example": "Invalid page." } } } @@ -1774,6 +1820,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "Additional metadata associated with the memory" }, @@ -1802,6 +1849,7 @@ }, "structured_attributes": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, @@ -2563,6 +2611,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "User-supplied metadata attached to the memory." }, @@ -2575,6 +2624,7 @@ }, "structured_attributes": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, @@ -3082,6 +3132,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "User-supplied metadata attached to the memory." }, @@ -3380,6 +3431,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "Additional metadata associated with the memory" }, @@ -3408,6 +3460,7 @@ }, "structured_attributes": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, @@ -3571,6 +3624,7 @@ }, "metadata": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "Additional metadata associated with the memory" }, @@ -3599,6 +3653,7 @@ }, "structured_attributes": { "type": "object", + "nullable": true, "additionalProperties": true, "description": "System-derived temporal breakdown of the memory's creation time (e.g. year, month, day_of_week)." }, From 6e6f5b8d59e9d59c2223ce7d0f0d28caa8218b31 Mon Sep 17 00:00:00 2001 From: soumil-rathi Date: Mon, 3 Aug 2026 11:42:43 -0700 Subject: [PATCH 9/9] docs(temporal): clarify feature behavior and usage (#6780) Co-authored-by: Soumil Rathi --- docs/openapi.json | 2 +- docs/platform/features/temporal-reasoning.mdx | 149 ++++++------------ 2 files changed, 46 insertions(+), 105 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 9770b590a..d9130ce54 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3029,7 +3029,7 @@ } ], "nullable": true, - "description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime." + "description": "Date and time to simulate the search from. Accepts a Unix epoch, YYYY-MM-DD, or ISO datetime." }, "fields": { "type": "array", diff --git a/docs/platform/features/temporal-reasoning.mdx b/docs/platform/features/temporal-reasoning.mdx index 0a2fdd510..64882e898 100644 --- a/docs/platform/features/temporal-reasoning.mdx +++ b/docs/platform/features/temporal-reasoning.mdx @@ -1,139 +1,80 @@ --- title: Temporal Reasoning -description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories." +description: "Boost memories whose dates match the time in a search." badge: "v3" --- -Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results. +Temporal Reasoning gives a ranking boost to memories whose event dates match the time in a search. Event dates are when something described in a memory happened or will happen. It runs automatically on Mem0 Platform v3. - - **Use Temporal Reasoning when…** - - Users ask questions like "what happened last week?" or "what do I have coming up?" - - Your app stores both past events and future plans for the same person - - You want time-aware retrieval without building your own date-parsing layer - +It is not available in the OSS SDK. - - Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints. - +Dates from new memories usually affect search within a few seconds. -## Configure access +## Time-aware searches -Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client: +Queries can include expressions such as `yesterday`, `last week`, `tomorrow`, `currently`, and `as of March 2025`. Mem0 compares them with dates and date ranges found in stored memories. -```python -from mem0 import MemoryClient +## Example -client = MemoryClient(api_key="your-api-key") -``` +Suppose a user has these memories: -## How it works +- "Yesterday I met Maya at the Orion conference in Paris." +- "Last week I met Maya at the Orion conference in Tokyo." +- "Last year I met Maya at the Orion conference in Lisbon." -When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time. - -A query like `what did I do last week?` should return a completed past event: not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically. - -### Memory types Temporal Reasoning handles - -| Type | What it represents | Example | -| --- | --- | --- | -| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." | -| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." | -| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." | -| Relationship | A durable connection between people or entities | "Priya manages Jordan." | -| Preference | A stable preference or habit | "I prefer morning meetings." | - -Results come back in the normal search response shape: Temporal Reasoning affects ranking, not the response format. - -## Configure it - -Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle. - -Two parameters give you precise control when you need it: - -- `observation_date` (or `observation_datetime`, which takes priority when both are set) on `add()`: anchors an imported memory to the time it actually happened. Pair with `timezone` when resolving a date-only value. -- `timestamp` on `add()`: a Unix epoch that sets the memory's creation time. When present it takes priority over `observation_datetime` and `observation_date` as the anchor Temporal Reasoning ranks against, so pass only the one you want to win. -- `reference_date` on `search()`: resolves relative phrases like `last week` against a fixed point in time +Searching for `Which city did I meet Maya in at the Orion conference last week?` gives the Tokyo memory a temporal boost and ranks it first. ```python Python -from mem0 import MemoryClient - -client = MemoryClient(api_key="your-api-key") - -client.add( - [{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}], - user_id="jordan", - observation_date="2025-03-10", -) - results = client.search( - "what did I do last week?", - filters={"user_id": "jordan"}, + "Which city did I meet Maya in at the Orion conference last week?", + filters={"user_id": "maya-demo"}, +) +``` + +```javascript JavaScript +const results = await client.search( + "Which city did I meet Maya in at the Orion conference last week?", + { filters: { user_id: "maya-demo" } } +); +``` + + +Search returns the usual memory results, reordered using the temporal boost. + +## Search from a specific time + +Use `reference_date` to simulate searching at a specific date and time. Mem0 treats it as the current time for that search. + + +```python Python +results = client.search( + "What happened last week?", + filters={"user_id": "user-123"}, reference_date="2025-03-21T00:00:00Z", ) ``` ```javascript JavaScript -import { MemoryClient } from "mem0ai"; - -const client = new MemoryClient({ apiKey: "your-api-key" }); - -await client.add( - [{ role: "user", content: "I finished the Q1 review on March 10, 2025." }], - { - userId: "jordan", - observationDate: "2025-03-10", - } -); - -const results = await client.search("what did I do last week?", { - filters: { user_id: "jordan" }, +const results = await client.search("What happened last week?", { + filters: { user_id: "user-123" }, referenceDate: "2025-03-21T00:00:00Z", }); ``` - - `reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time. - +## Parameters -## Supported query patterns +| Action | Python | TypeScript | Purpose | +| --- | --- | --- | --- | +| Add memories | `timestamp` | `timestamp` | Preserve the original time of an imported conversation. | +| Search memories | `reference_date` | `referenceDate` | Simulate searching at a specific date and time. | - - - Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10` - - - Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?` - - - Examples: `right now`, `currently`, `where do I work now?` - - - Examples: `as of March 2025`, `where was I living as of 2024?` - - - Examples: `how long have I lived here?`, `since when have I worked there?` - - - -## Verify the feature is working - -- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first. -- Use `reference_date` in test queries so relative phrases resolve consistently across runs. -- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time. - -## Best practices - -- Use explicit dates in source conversations when events or plans matter temporally. -- Pass `observation_date` (or `observation_datetime`) during historical imports so the ingestion time does not become the only time anchor. Do not also pass `timestamp` on those calls, since it overrides both as the ranking anchor. -- Scope searches with `filters` so time-aware ranking operates inside the right user boundary. -- Use `reference_date` in automated tests and reproducible demos. +For all search inputs and returned fields, see the [Search Memories API reference](/api-reference/memory/search-memories). - Anchor imported memories to when they actually happened. + Preserve the original time of imported memories.