Merge remote-tracking branch 'origin/main' into mintlify/af82bda4

This commit is contained in:
mintlify[bot]
2026-08-03 18:43:02 +00:00
59 changed files with 4693 additions and 1231 deletions
+14
View File
@@ -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
+58
View File
@@ -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
+1
View File
@@ -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 |
+1 -2
View File
@@ -60,9 +60,8 @@ mem0 delete <memory-id>
| `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 <command> --help` for detailed usage on any command.
Run `mem0 <command> --help` for detailed usage on any command, or `mem0 --version` to print the CLI version.
## Agent mode
+13 -1
View File
@@ -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" },
+2 -8
View File
@@ -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 |
+1 -1
View File
@@ -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`.
+16 -1
View File
@@ -14,7 +14,10 @@ export interface AddOptions {
immutable?: boolean;
infer?: boolean;
expires?: string;
categories?: string[];
customInstructions?: string;
customCategories?: Record<string, string>[];
structuredDataSchema?: Record<string, unknown>;
timestamp?: number;
}
export interface SearchOptions {
@@ -28,6 +31,9 @@ export interface SearchOptions {
keyword?: boolean;
filters?: Record<string, unknown>;
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<string, unknown>,
opts?: UpdateOptions,
): Promise<Record<string, unknown>>;
delete(
+20 -4
View File
@@ -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<string, unknown>,
opts: UpdateOptions = {},
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
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<string, unknown>;
}
if (memoryId) {
const params: Record<string, string> = { source: "CLI" };
if (opts.deleteLinked) params.delete_linked = "true";
return (await this._request(
"DELETE",
`/v1/memories/${encodePathSegment(memoryId)}/`,
{
params: { source: "CLI" },
},
{ params },
)) as Record<string, unknown>;
}
throw new Error("Either memoryId or --all is required");
+75 -33
View File
@@ -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<void> {
setCurrentCommand("add");
if (opts.categories) {
printError(
"--categories is not supported on add. Use --custom-categories instead.",
);
process.exit(1);
}
let msgs: Record<string, unknown>[] | 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<string, unknown> | undefined;
if (opts.metadata) {
try {
@@ -117,15 +118,28 @@ export async function cmdAdd(
}
}
let cats: string[] | undefined;
if (opts.categories) {
let customCats: Record<string, string>[] | 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<string, unknown> | 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<string, unknown>;
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<void> {
@@ -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<void> {
@@ -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<void> {
setCurrentCommand("update");
let meta: Record<string, unknown> | undefined;
@@ -463,11 +495,16 @@ export async function cmdUpdate(
}
}
if (opts.expires) _validateExpires(opts.expires);
const start = performance.now();
let result: Record<string, unknown>;
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<void> {
setCurrentCommand("delete");
if (opts.dryRun) {
@@ -514,7 +556,7 @@ export async function cmdDelete(
let result: Record<string, unknown>;
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));
+1 -6
View File
@@ -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,
+57 -5
View File
@@ -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 <date>", "Expiration date (YYYY-MM-DD).")
.option("--categories <value>", "Categories (JSON array or comma-separated).")
.option(
"--categories <value>",
"Not supported on add, use --custom-categories instead.",
)
.option(
"--custom-instructions <text>",
"Custom instructions for fact extraction.",
)
.option(
"--custom-categories <json>",
"Custom categories as a JSON array of {name: description} objects.",
)
.option(
"--structured-data-schema <json>",
"Schema for structured data extraction, as JSON.",
)
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
Number.parseInt(v),
)
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
@@ -366,6 +385,16 @@ program
.option("--keyword", "Use keyword search.", false)
.option("--filter <json>", "Advanced filter expression (JSON).")
.option("--fields <list>", "Specific fields to return (comma-separated).")
.option("--show-expired", "Include expired memories.", false)
.option(
"--reference-date <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 <format>", "Output: text, json, table.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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 <name>", "Filter by category.")
.option("--after <date>", "Created after (YYYY-MM-DD).")
.option("--before <date>", "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 <format>", "Output: text, json, table.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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 <memoryId> [text]")
.description("Update a memory's text or metadata.")
.option("-m, --metadata <json>", "Update metadata (JSON).")
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
Number.parseInt(v),
)
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <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 <id>", "Scope to user.")
.option("--agent-id <id>", "Scope to agent.")
.option("--app-id <id>", "Scope to app.")
@@ -563,6 +614,7 @@ program
output,
dryRun: opts.dryRun,
force: opts.force,
deleteLinked: opts.deleteLinked,
});
return;
}
@@ -806,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");
+17 -3
View File
@@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
}
case "search":
return (data as Record<string, unknown>[]).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<string, unknown>[]).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<string, unknown>;
@@ -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<string, unknown>;
return pick(r, ["id", "memory"]);
return pick(r, ["id", "memory", "expiration_date"]);
}
case "delete":
case "delete-all":
+13
View File
@@ -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;
}
}
+17 -7
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
/**
* 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<string, Record<string, string>> = {
"/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<string, string[]> = {
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<string, string[]> = {
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"],
fields: ["--fields"],
};
const LIST_MAPPING: Record<string, string[]> = {
filters: [
"--user-id",
"--agent-id",
"--run-id",
"--category",
"--after",
"--before",
],
show_expired: ["--show-expired"],
page: ["--page"],
page_size: ["--page-size"],
};
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<string, string[]>,
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);
}
});
}
});
+205 -68
View File
@@ -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<string, unknown> = {
"/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<string, unknown> = {
"/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/",
]);
});
});
+2 -8
View File
@@ -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 |
+1 -1
View File
@@ -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
+89 -6
View File
@@ -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.",
@@ -1269,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"
+17 -2
View File
@@ -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
+43 -5
View File
@@ -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")
+67 -20
View File
@@ -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
+20 -4
View File
@@ -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
+26
View File
@@ -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
+177 -3
View File
@@ -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):
+102
View File
@@ -0,0 +1,102 @@
"""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] = {
("/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]] = {
"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"],
"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"],
}
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")
@@ -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
+2256 -448
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -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` |
<Note>
`--categories` is not supported on `add` and exits with an error pointing at `--custom-categories`.
</Note>
### `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 <memory-id>
| 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 |
@@ -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.
</Warning>
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
+45 -104
View File
@@ -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.
<Info>
**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
</Info>
It is not available in the OSS SDK.
<Warning>
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
</Warning>
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.
<CodeGroup>
```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" } }
);
```
</CodeGroup>
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.
<CodeGroup>
```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",
});
```
</CodeGroup>
<Tip>
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
</Tip>
## 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. |
<AccordionGroup>
<Accordion title="Historical questions">
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
</Accordion>
<Accordion title="Upcoming questions">
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
</Accordion>
<Accordion title="Current-state questions">
Examples: `right now`, `currently`, `where do I work now?`
</Accordion>
<Accordion title="As-of questions">
Examples: `as of March 2025`, `where was I living as of 2024?`
</Accordion>
<Accordion title="Duration questions">
Examples: `how long have I lived here?`, `since when have I worked there?`
</Accordion>
</AccordionGroup>
## 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).
<CardGroup cols={1}>
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
Anchor imported memories to when they actually happened.
Preserve the original time of imported memories.
</Card>
</CardGroup>
@@ -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()))
+7 -8
View File
@@ -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()
@@ -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
+2
View File
@@ -27,6 +27,8 @@ export interface SearchMemoryOptions {
fields?: string[];
categories?: string[];
showExpired?: boolean;
referenceDate?: string | number;
keywordSearch?: boolean;
}
export interface GetAllMemoryOptions {
@@ -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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
+14 -14
View File
@@ -133,6 +133,18 @@ function toSnakeCase(obj: Record<string, any>): Record<string, any> {
);
}
// 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<string, any>): Record<string, any> {
if (typeof obj !== "object" || obj === null) return obj;
@@ -406,13 +418,7 @@ export class RedisDB implements VectorStore {
filters?: SearchFilters,
): Promise<VectorStoreResult[]> {
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",
@@ -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}");
});
});
+6
View File
@@ -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):
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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"
+1 -13
View File
@@ -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):
+5 -3
View File
@@ -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:
+6 -3
View File
@@ -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
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
+9 -5
View File
@@ -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
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
+11 -5
View File
@@ -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
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
+6 -3
View File
@@ -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
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
+4 -2
View File
@@ -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
-1
View File
@@ -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"))
@@ -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__)
@@ -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)
+25 -1
View File
@@ -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) == ""
+32
View File
@@ -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
@@ -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)
+1 -44
View File
@@ -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)]
+51
View File
@@ -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."""
+9
View File
@@ -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"}