diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index 8c8278aa6..eaf00275c 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -38,6 +38,7 @@ jobs: cli_python: ${{ steps.filter.outputs.cli_python }} cli_node: ${{ steps.filter.outputs.cli_node }} openclaw: ${{ steps.filter.outputs.openclaw }} + mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }} opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }} pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }} @@ -73,6 +74,11 @@ jobs: - 'integrations/openclaw/**' - '.github/workflows/openclaw-checks.yml' - '.github/workflows/ci-gate.yml' + mem0_plugin: + - 'integrations/mem0-plugin/**' + - '!integrations/mem0-plugin/.opencode-plugin/**' + - '.github/workflows/mem0-plugin-checks.yml' + - '.github/workflows/ci-gate.yml' opencode_plugin: - 'integrations/mem0-plugin/.opencode-plugin/**' - '.github/workflows/opencode-plugin-checks.yml' @@ -131,6 +137,13 @@ jobs: uses: ./.github/workflows/openclaw-checks.yml secrets: inherit + mem0-plugin: + name: Mem0 Plugin + needs: changes + if: needs.changes.outputs.mem0_plugin == 'true' + uses: ./.github/workflows/mem0-plugin-checks.yml + secrets: inherit + opencode-plugin: name: OpenCode Plugin needs: changes @@ -173,6 +186,7 @@ jobs: - cli-python - cli-node - openclaw + - mem0-plugin - opencode-plugin - pi-agent-plugin - n8n-nodes-mem0 diff --git a/.github/workflows/mem0-plugin-checks.yml b/.github/workflows/mem0-plugin-checks.yml new file mode 100644 index 000000000..87eda7ada --- /dev/null +++ b/.github/workflows/mem0-plugin-checks.yml @@ -0,0 +1,58 @@ +name: Mem0 Plugin Checks + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +# +# Covers the Python plugin (scripts/ + tests/). The nested .opencode-plugin/ +# is a separate package with its own workflow (opencode-plugin-checks.yml). +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/mem0-plugin/**' + - '!integrations/mem0-plugin/.opencode-plugin/**' + - '.github/workflows/mem0-plugin-checks.yml' + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: integrations/mem0-plugin + run: | + pip install -r requirements.txt + pip install pytest + + - name: Verify hook entry points are executable + working-directory: integrations/mem0-plugin + run: | + missing=$(find scripts -name '*.sh' ! -name '_*' ! -perm -u+x -print) + if [ -n "$missing" ]; then + echo "Hook entry points must be executable:" + echo "$missing" + exit 1 + fi + + - name: Check hook manifests are valid JSON + working-directory: integrations/mem0-plugin + run: | + for f in plugin.json mcp_config.json hooks.json hooks/*.json; do + jq empty "$f" || (echo "Invalid JSON: $f" && exit 1) + done + + - name: Run tests + working-directory: integrations/mem0-plugin + run: pytest -q diff --git a/AGENTS.md b/AGENTS.md index ae9889730..384a92c0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -406,6 +406,7 @@ PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)** | Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 | | Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | | OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | +| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook entry-point exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 | | OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check | | Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 | | n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (on `integrations/n8n-nodes-mem0/`), manual | ESLint (n8n-nodes-base) + tsc build (dist artifact check) on Node 20 | diff --git a/cli/cli-spec.json b/cli/cli-spec.json index 437bb8e46..c8cecad3a 100644 --- a/cli/cli-spec.json +++ b/cli/cli-spec.json @@ -207,7 +207,11 @@ { "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." }, { "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." }, { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, - { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Categories (JSON array or comma-separated)." }, + { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Not supported on add, use --custom-categories instead." }, + { "name": "custom_instructions", "flags": ["--custom-instructions"], "type": "string", "help": "Custom instructions for fact extraction." }, + { "name": "custom_categories", "flags": ["--custom-categories"], "type": "string", "help": "Custom categories as a JSON array of {name: description} objects." }, + { "name": "structured_data_schema", "flags": ["--structured-data-schema"], "type": "string", "help": "Schema for structured data extraction, as JSON." }, + { "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" } @@ -244,6 +248,9 @@ { "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" }, { "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" }, { "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" }, + { "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Search" }, + { "name": "reference_date", "flags": ["--reference-date"], "type": "string", "help": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", "panel": "Search" }, + { "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Search" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" } @@ -296,6 +303,8 @@ { "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" }, { "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" }, { "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" }, + { "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Filters" }, + { "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Filters" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" } @@ -329,6 +338,8 @@ ], "options": [ { "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." }, + { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, + { "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." }, { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } ], "apiEndpoint": "update" @@ -356,6 +367,7 @@ { "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." }, { "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." }, { "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." }, + { "name": "delete_linked", "flags": ["--delete-linked"], "type": "boolean", "default": false, "help": "Also delete memories linked to this memory." }, { "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." }, { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." }, { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" }, diff --git a/cli/node/src/backend/base.ts b/cli/node/src/backend/base.ts index be1a0ce1f..d66a3519d 100644 --- a/cli/node/src/backend/base.ts +++ b/cli/node/src/backend/base.ts @@ -14,7 +14,10 @@ export interface AddOptions { immutable?: boolean; infer?: boolean; expires?: string; - categories?: string[]; + customInstructions?: string; + customCategories?: Record[]; + structuredDataSchema?: Record; + timestamp?: number; } export interface SearchOptions { @@ -28,6 +31,9 @@ export interface SearchOptions { keyword?: boolean; filters?: Record; fields?: string[]; + showExpired?: boolean; + referenceDate?: string | number; + latestOnly?: boolean; } export interface ListOptions { @@ -40,6 +46,8 @@ export interface ListOptions { category?: string; after?: string; before?: string; + showExpired?: boolean; + latestOnly?: boolean; } export interface DeleteOptions { @@ -48,6 +56,12 @@ export interface DeleteOptions { agentId?: string; appId?: string; runId?: string; + deleteLinked?: boolean; +} + +export interface UpdateOptions { + expirationDate?: string; + timestamp?: number; } export interface EntityIds { @@ -77,6 +91,7 @@ export interface Backend { memoryId: string, content?: string, metadata?: Record, + opts?: UpdateOptions, ): Promise>; delete( diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 0819bf3cb..f53b5dddc 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -15,6 +15,7 @@ import { type ListOptions, NotFoundError, type SearchOptions, + type UpdateOptions, } from "./base.js"; function encodePathSegment(value: unknown): string { @@ -150,7 +151,13 @@ export class PlatformBackend implements Backend { if (opts.immutable) payload.immutable = true; if (opts.infer === false) payload.infer = false; if (opts.expires) payload.expiration_date = opts.expires; - if (opts.categories) payload.categories = opts.categories; + if (opts.customInstructions) + payload.custom_instructions = opts.customInstructions; + if (opts.customCategories) + payload.custom_categories = opts.customCategories; + if (opts.structuredDataSchema) + payload.structured_data_schema = opts.structuredDataSchema; + if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp; payload.source = "CLI"; return (await this._request("POST", "/v3/memories/add/", { @@ -211,6 +218,10 @@ export class PlatformBackend implements Backend { if (opts.rerank) payload.rerank = true; if (opts.keyword) payload.keyword_search = true; if (opts.fields) payload.fields = opts.fields; + if (opts.showExpired) payload.show_expired = true; + if (opts.referenceDate !== undefined) + payload.reference_date = opts.referenceDate; + if (opts.latestOnly) payload.latest_only = true; payload.source = "CLI"; const result = (await this._request("POST", "/v3/memories/search/", { @@ -265,6 +276,8 @@ export class PlatformBackend implements Backend { extraFilters: Object.keys(extra).length > 0 ? extra : undefined, }); if (apiFilters) payload.filters = apiFilters; + if (opts.showExpired) payload.show_expired = true; + if (opts.latestOnly) payload.latest_only = true; payload.source = "CLI"; const result = (await this._request("POST", "/v3/memories/", { @@ -280,10 +293,13 @@ export class PlatformBackend implements Backend { memoryId: string, content?: string, metadata?: Record, + opts: UpdateOptions = {}, ): Promise> { const payload: Record = {}; if (content) payload.text = content; if (metadata) payload.metadata = metadata; + if (opts.expirationDate) payload.expiration_date = opts.expirationDate; + if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp; payload.source = "CLI"; return (await this._request( "PUT", @@ -309,12 +325,12 @@ export class PlatformBackend implements Backend { })) as Record; } if (memoryId) { + const params: Record = { source: "CLI" }; + if (opts.deleteLinked) params.delete_linked = "true"; return (await this._request( "DELETE", `/v1/memories/${encodePathSegment(memoryId)}/`, - { - params: { source: "CLI" }, - }, + { params }, )) as Record; } throw new Error("Either memoryId or --all is required"); diff --git a/cli/node/src/commands/memory.ts b/cli/node/src/commands/memory.ts index 3b7b67b4f..3f5e5e621 100644 --- a/cli/node/src/commands/memory.ts +++ b/cli/node/src/commands/memory.ts @@ -21,16 +21,19 @@ import { formatSingleMemory, printResultSummary, } from "../output.js"; -import { isAgentMode, setCurrentCommand } from "../state.js"; +import { isAgentMode, setCurrentCommand, stdinIsPiped } from "../state.js"; -/** True only when stdin is an actual pipe or file redirect — never in agent mode. */ -function _stdinIsPiped(): boolean { - if (isAgentMode()) return false; - try { - const stat = fs.fstatSync(0); - return stat.isFIFO() || stat.isFile(); - } catch { - return false; +/** Exit 1 if value is not a future YYYY-MM-DD date. */ +function _validateExpires(value: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + printError( + "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).", + ); + process.exit(1); + } + if (new Date(value) <= new Date()) { + printError("--expires date must be in the future."); + process.exit(1); } } @@ -49,10 +52,22 @@ export async function cmdAdd( infer?: boolean; expires?: string; categories?: string; + customInstructions?: string; + customCategories?: string; + structuredDataSchema?: string; + timestamp?: number; output: string; }, ): Promise { setCurrentCommand("add"); + + if (opts.categories) { + printError( + "--categories is not supported on add. Use --custom-categories instead.", + ); + process.exit(1); + } + let msgs: Record[] | undefined; let content = text; @@ -78,7 +93,7 @@ export async function cmdAdd( } } // Read from stdin only if stdin is an actual pipe or file redirect - else if (!content && _stdinIsPiped()) { + else if (!content && stdinIsPiped()) { content = fs.readFileSync(0, "utf-8").trim(); } @@ -93,20 +108,6 @@ export async function cmdAdd( process.exit(1); } - // Validate --expires - if (opts.expires) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) { - printError( - "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).", - ); - process.exit(1); - } - if (new Date(opts.expires) <= new Date()) { - printError("--expires date must be in the future."); - process.exit(1); - } - } - let meta: Record | undefined; if (opts.metadata) { try { @@ -117,15 +118,28 @@ export async function cmdAdd( } } - let cats: string[] | undefined; - if (opts.categories) { + let customCats: Record[] | undefined; + if (opts.customCategories) { try { - cats = JSON.parse(opts.categories); + customCats = JSON.parse(opts.customCategories); } catch { - cats = opts.categories.split(",").map((c) => c.trim()); + printError("Invalid JSON in --custom-categories."); + process.exit(1); } } + let schema: Record | undefined; + if (opts.structuredDataSchema) { + try { + schema = JSON.parse(opts.structuredDataSchema); + } catch { + printError("Invalid JSON in --structured-data-schema."); + process.exit(1); + } + } + + if (opts.expires) _validateExpires(opts.expires); + let result: Record; try { result = await timedStatus("Adding memory...", async () => { @@ -138,7 +152,10 @@ export async function cmdAdd( immutable: opts.immutable, infer: opts.infer !== false, expires: opts.expires, - categories: cats, + customInstructions: opts.customInstructions, + customCategories: customCats, + structuredDataSchema: schema, + timestamp: opts.timestamp, }); }); } catch (e) { @@ -223,6 +240,9 @@ export async function cmdSearch( keyword: boolean; filterJson?: string; fields?: string; + showExpired?: boolean; + referenceDate?: string; + latestOnly?: boolean; output: string; }, ): Promise { @@ -271,6 +291,9 @@ export async function cmdSearch( keyword: opts.keyword, filters, fields: fieldList, + showExpired: opts.showExpired, + referenceDate: opts.referenceDate, + latestOnly: opts.latestOnly, }); }); } catch (e) { @@ -364,6 +387,8 @@ export async function cmdList( category?: string; after?: string; before?: string; + showExpired?: boolean; + latestOnly?: boolean; output: string; }, ): Promise { @@ -391,6 +416,8 @@ export async function cmdList( category: opts.category, after: opts.after, before: opts.before, + showExpired: opts.showExpired, + latestOnly: opts.latestOnly, }); }); } catch (e) { @@ -450,7 +477,12 @@ export async function cmdUpdate( backend: Backend, memoryId: string, text: string | undefined, - opts: { metadata?: string; output: string }, + opts: { + metadata?: string; + expires?: string; + timestamp?: number; + output: string; + }, ): Promise { setCurrentCommand("update"); let meta: Record | undefined; @@ -463,11 +495,16 @@ export async function cmdUpdate( } } + if (opts.expires) _validateExpires(opts.expires); + const start = performance.now(); let result: Record; try { result = await timedStatus("Updating memory...", async () => { - return backend.update(memoryId, text, meta); + return backend.update(memoryId, text, meta, { + expirationDate: opts.expires, + timestamp: opts.timestamp, + }); }); } catch (e) { printError(e instanceof Error ? e.message : String(e)); @@ -493,7 +530,12 @@ export async function cmdUpdate( export async function cmdDelete( backend: Backend, memoryId: string, - opts: { output: string; dryRun?: boolean; force?: boolean }, + opts: { + output: string; + dryRun?: boolean; + force?: boolean; + deleteLinked?: boolean; + }, ): Promise { setCurrentCommand("delete"); if (opts.dryRun) { @@ -514,7 +556,7 @@ export async function cmdDelete( let result: Record; try { result = await timedStatus("Deleting...", async () => { - return backend.delete(memoryId); + return backend.delete(memoryId, { deleteLinked: opts.deleteLinked }); }); } catch (e) { printError(e instanceof Error ? e.message : String(e)); diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index b038fb171..dfe46910f 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -17,6 +17,7 @@ import { isAgentMode, setAgentMode, setCurrentCommand, + stdinIsPiped, takeNotice, } from "./state.js"; import { captureEvent } from "./telemetry.js"; @@ -319,7 +320,25 @@ program .option("--immutable", "Prevent future updates.", false) .option("--no-infer", "Skip inference, store raw.") .option("--expires ", "Expiration date (YYYY-MM-DD).") - .option("--categories ", "Categories (JSON array or comma-separated).") + .option( + "--categories ", + "Not supported on add, use --custom-categories instead.", + ) + .option( + "--custom-instructions ", + "Custom instructions for fact extraction.", + ) + .option( + "--custom-categories ", + "Custom categories as a JSON array of {name: description} objects.", + ) + .option( + "--structured-data-schema ", + "Schema for structured data extraction, as JSON.", + ) + .option("--timestamp ", "Unix timestamp for the memory.", (v) => + Number.parseInt(v), + ) .option("-o, --output ", "Output format: text, json, quiet.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -366,6 +385,16 @@ program .option("--keyword", "Use keyword search.", false) .option("--filter ", "Advanced filter expression (JSON).") .option("--fields ", "Specific fields to return (comma-separated).") + .option("--show-expired", "Include expired memories.", false) + .option( + "--reference-date ", + "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + ) + .option( + "--latest-only", + "Only return the latest version of each memory.", + false, + ) .option("-o, --output ", "Output: text, json, table.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -375,7 +404,7 @@ program ) .action(async (query, opts) => { let resolvedQuery = query; - if (!resolvedQuery && !process.stdin.isTTY) { + if (!resolvedQuery && stdinIsPiped()) { resolvedQuery = fs.readFileSync(0, "utf-8").trim(); } if (!resolvedQuery) { @@ -398,6 +427,9 @@ program keyword: opts.keyword, filterJson: opts.filter, fields: opts.fields, + showExpired: opts.showExpired, + referenceDate: opts.referenceDate, + latestOnly: opts.latestOnly, output, }); }); @@ -441,6 +473,12 @@ program .option("--category ", "Filter by category.") .option("--after ", "Created after (YYYY-MM-DD).") .option("--before ", "Created before (YYYY-MM-DD).") + .option("--show-expired", "Include expired memories.", false) + .option( + "--latest-only", + "Only return the latest version of each memory.", + false, + ) .option("-o, --output ", "Output: text, json, table.", "table") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -464,6 +502,8 @@ program category: opts.category, after: opts.after, before: opts.before, + showExpired: opts.showExpired, + latestOnly: opts.latestOnly, output, }); }); @@ -474,6 +514,10 @@ program .command("update [text]") .description("Update a memory's text or metadata.") .option("-m, --metadata ", "Update metadata (JSON).") + .option("--expires ", "Expiration date (YYYY-MM-DD).") + .option("--timestamp ", "Unix timestamp for the memory.", (v) => + Number.parseInt(v), + ) .option("-o, --output ", "Output: text, json, quiet.", "text") .option("--api-key ", "Override API key.") .option("--base-url ", "Override API base URL.") @@ -483,7 +527,7 @@ program ) .action(async (memoryId, text, opts) => { let resolvedText = text; - if (!resolvedText && !opts.metadata && !process.stdin.isTTY) { + if (!resolvedText && stdinIsPiped()) { resolvedText = fs.readFileSync(0, "utf-8").trim(); } const { cmdUpdate } = await import("./commands/memory.js"); @@ -492,6 +536,8 @@ program const output = isAgent ? "agent" : opts.output; await cmdUpdate(backend, memoryId, resolvedText, { metadata: opts.metadata, + expires: opts.expires, + timestamp: opts.timestamp, output, }); }); @@ -510,6 +556,11 @@ program .option("--project", "With --all: delete ALL memories project-wide.", false) .option("--dry-run", "Show what would be deleted without deleting.", false) .option("--force", "Skip confirmation.", false) + .option( + "--delete-linked", + "Also delete memories linked to this memory.", + false, + ) .option("-u, --user-id ", "Scope to user.") .option("--agent-id ", "Scope to agent.") .option("--app-id ", "Scope to app.") @@ -563,6 +614,7 @@ program output, dryRun: opts.dryRun, force: opts.force, + deleteLinked: opts.deleteLinked, }); return; } diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts index fa14849e7..84dffb1fe 100644 --- a/cli/node/src/output.ts +++ b/cli/node/src/output.ts @@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown { } case "search": return (data as Record[]).map((r) => - pick(r, ["id", "memory", "score", "created_at", "categories"]), + pick(r, [ + "id", + "memory", + "score", + "created_at", + "categories", + "expiration_date", + ]), ); case "list": return (data as Record[]).map((r) => - pick(r, ["id", "memory", "created_at", "categories"]), + pick(r, [ + "id", + "memory", + "created_at", + "categories", + "expiration_date", + ]), ); case "get": { const r = data as Record; @@ -297,11 +310,12 @@ export function sanitizeAgentData(command: string, data: unknown): unknown { "updated_at", "categories", "metadata", + "expiration_date", ]); } case "update": { const r = data as Record; - return pick(r, ["id", "memory"]); + return pick(r, ["id", "memory", "expiration_date"]); } case "delete": case "delete-all": diff --git a/cli/node/src/state.ts b/cli/node/src/state.ts index 724068925..03ace9a4b 100644 --- a/cli/node/src/state.ts +++ b/cli/node/src/state.ts @@ -3,6 +3,8 @@ * read by commands and branding functions. */ +import fs from "node:fs"; + let _agentMode = false; let _currentCommand = ""; let _pendingNotice = ""; @@ -38,3 +40,14 @@ export function takeNotice(): string { _pendingNotice = ""; return msg; } + +/** True only when stdin is an actual pipe or file redirect (never in agent mode). */ +export function stdinIsPiped(): boolean { + if (isAgentMode()) return false; + try { + const stat = fs.fstatSync(0); + return stat.isFIFO() || stat.isFile(); + } catch { + return false; + } +} diff --git a/cli/node/tests/commands.test.ts b/cli/node/tests/commands.test.ts index 4cd43d1be..02789f056 100644 --- a/cli/node/tests/commands.test.ts +++ b/cli/node/tests/commands.test.ts @@ -2,11 +2,11 @@ * Tests for CLI commands using mock backend. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; -import { createMockBackend } from "./setup.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Backend } from "../src/backend/base.js"; import { setAgentMode } from "../src/state.js"; +import { createMockBackend } from "./setup.js"; let mockBackend: Backend; @@ -17,450 +17,657 @@ const originalLog = console.log; const originalError = console.error; beforeEach(() => { - mockBackend = createMockBackend(); - output = ""; - errOutput = ""; - console.log = (...args: unknown[]) => { - output += args.map(String).join(" ") + "\n"; - }; - console.error = (...args: unknown[]) => { - errOutput += args.map(String).join(" ") + "\n"; - }; + mockBackend = createMockBackend(); + output = ""; + errOutput = ""; + console.log = (...args: unknown[]) => { + output += args.map(String).join(" ") + "\n"; + }; + console.error = (...args: unknown[]) => { + errOutput += args.map(String).join(" ") + "\n"; + }; }); // Restore after each test import { afterEach } from "vitest"; afterEach(() => { - console.log = originalLog; - console.error = originalError; - setAgentMode(false); + console.log = originalLog; + console.error = originalError; + setAgentMode(false); }); describe("cmdAdd", () => { - it("adds text memory", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "I prefer dark mode", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledOnce(); - }); + it("adds text memory", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "I prefer dark mode", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledOnce(); + }); - it("adds from messages JSON", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, undefined, { - userId: "alice", - messages: JSON.stringify([{ role: "user", content: "I love Python" }]), - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledOnce(); - }); + it("adds from messages JSON", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, undefined, { + userId: "alice", + messages: JSON.stringify([{ role: "user", content: "I love Python" }]), + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledOnce(); + }); - it("outputs json format", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "json", - }); - expect(output).toContain("results"); - }); + it("outputs json format", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "json", + }); + expect(output).toContain("results"); + }); - it("quiet mode produces no memory content", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "quiet", - }); - expect(output).not.toContain("dark mode"); - }); + it("quiet mode produces no memory content", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "quiet", + }); + expect(output).not.toContain("dark mode"); + }); }); describe("cmdAdd forwards --no-infer (regression for #5261)", () => { - it("forwards infer: false when --no-infer is set", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - // `infer: false` is the shape Commander produces for `--no-infer`. - await cmdAdd(mockBackend, "store me verbatim", { - userId: "alice", - immutable: false, - infer: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledWith( - "store me verbatim", - undefined, - expect.objectContaining({ infer: false }), - ); - }); + it("forwards infer: false when --no-infer is set", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "store me verbatim", { + userId: "alice", + immutable: false, + infer: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "store me verbatim", + undefined, + expect.objectContaining({ infer: false }), + ); + }); - it("forwards infer: true by default (flag absent)", async () => { - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "infer me", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(mockBackend.add).toHaveBeenCalledWith( - "infer me", - undefined, - expect.objectContaining({ infer: true }), - ); - }); + it("forwards infer: true by default (flag absent)", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "infer me", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "infer me", + undefined, + expect.objectContaining({ infer: true }), + ); + }); - it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => { - // Pins the assumption the fix relies on: Commander's `--no-X` option - // populates the positive camelCase key (`infer`), never `noInfer`. - const withFlag = new Command(); - withFlag.option("--no-infer", "Skip inference, store raw.").action(() => {}); - withFlag.parse(["--no-infer"], { from: "user" }); - expect(withFlag.opts().infer).toBe(false); - expect(withFlag.opts().noInfer).toBeUndefined(); + it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => { + const withFlag = new Command(); + withFlag + .option("--no-infer", "Skip inference, store raw.") + .action(() => {}); + withFlag.parse(["--no-infer"], { from: "user" }); + expect(withFlag.opts().infer).toBe(false); + expect(withFlag.opts().noInfer).toBeUndefined(); - const withoutFlag = new Command(); - withoutFlag.option("--no-infer", "Skip inference, store raw.").action(() => {}); - withoutFlag.parse([], { from: "user" }); - expect(withoutFlag.opts().infer).toBe(true); - }); + const withoutFlag = new Command(); + withoutFlag + .option("--no-infer", "Skip inference, store raw.") + .action(() => {}); + withoutFlag.parse([], { from: "user" }); + expect(withoutFlag.opts().infer).toBe(true); + }); +}); + +describe("cmdAdd forwards MEM-5893 option-parity flags", () => { + it("forwards customInstructions, customCategories, structuredDataSchema, timestamp", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + customInstructions: "Extract only preferences.", + customCategories: JSON.stringify([{ prefs: "user preferences" }]), + structuredDataSchema: JSON.stringify({ type: "object" }), + timestamp: 1700000000, + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "test", + undefined, + expect.objectContaining({ + customInstructions: "Extract only preferences.", + customCategories: [{ prefs: "user preferences" }], + structuredDataSchema: { type: "object" }, + timestamp: 1700000000, + }), + ); + }); + + it("regression: metadata, expires, and customCategories reach the backend together", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + metadata: JSON.stringify({ source: "test" }), + expires: "2099-01-01", + customCategories: JSON.stringify([{ prefs: "user preferences" }]), + output: "text", + }); + expect(mockBackend.add).toHaveBeenCalledWith( + "test", + undefined, + expect.objectContaining({ + metadata: { source: "test" }, + expires: "2099-01-01", + customCategories: [{ prefs: "user preferences" }], + }), + ); + }); +}); + +describe("cmdAdd rejects invalid input before calling the backend (MEM-5893)", () => { + function mockProcessExit() { + return vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + } + + it("rejects --categories with a message pointing to --custom-categories", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + categories: "prefs", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--custom-categories"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("rejects invalid JSON in --custom-categories", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + customCategories: "not-json", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--custom-categories"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it("rejects invalid JSON in --structured-data-schema", async () => { + const { cmdAdd } = await import("../src/commands/memory.js"); + const exitSpy = mockProcessExit(); + + await expect( + cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + structuredDataSchema: "not-json", + output: "text", + }), + ).rejects.toThrow("process.exit:1"); + + expect(errOutput).toContain("--structured-data-schema"); + expect(mockBackend.add).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); }); describe("cmdAdd deduplicates PENDING", () => { - const DUPLICATE_PENDING = { - results: [ - { status: "PENDING", event_id: "evt-dup" }, - { status: "PENDING", event_id: "evt-dup" }, - ], - }; + const DUPLICATE_PENDING = { + results: [ + { status: "PENDING", event_id: "evt-dup" }, + { status: "PENDING", event_id: "evt-dup" }, + ], + }; - it("text shows one pending block", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "text", - }); - expect(output.match(/Queued/g)?.length).toBe(1); - }); + it("text shows one pending block", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "text", + }); + expect(output.match(/Queued/g)?.length).toBe(1); + }); - it("json shows one pending entry", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "json", - }); - const data = JSON.parse(output); - const pending = data.results.filter((r: Record) => r.status === "PENDING"); - expect(pending).toHaveLength(1); - }); + it("json shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "json", + }); + const data = JSON.parse(output); + const pending = data.results.filter( + (r: Record) => r.status === "PENDING", + ); + expect(pending).toHaveLength(1); + }); - it("agent shows one pending entry", async () => { - (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); - setAgentMode(true); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test", { - userId: "alice", - immutable: false, - output: "agent", - }); - const data = JSON.parse(output); - expect(data.count).toBe(1); - expect(data.data).toHaveLength(1); - }); + it("agent shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue( + DUPLICATE_PENDING, + ); + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + output: "agent", + }); + const data = JSON.parse(output); + expect(data.count).toBe(1); + expect(data.data).toHaveLength(1); + }); }); describe("cmdSearch", () => { - it("searches and shows results in text mode", async () => { - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("searches and shows results in text mode", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "text", - }); - expect(output).toContain("Found 2"); - }); + output: "text", + }); + expect(output).toContain("Found 2"); + }); - it("outputs json format", async () => { - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("outputs json format", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "json", - }); - expect(output).toContain("memory"); - }); + output: "json", + }); + expect(output).toContain("memory"); + }); - it("shows no results message", async () => { - (mockBackend.search as ReturnType).mockResolvedValue([]); - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "nonexistent", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("shows no results message", async () => { + (mockBackend.search as ReturnType).mockResolvedValue([]); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "nonexistent", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "text", - }); - expect(errOutput).toContain("No memories found"); - }); + output: "text", + }); + expect(errOutput).toContain("No memories found"); + }); + + it("forwards showExpired, referenceDate, latestOnly (MEM-5893)", async () => { + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + output: "text", + }); + expect(mockBackend.search).toHaveBeenCalledWith( + "preferences", + expect.objectContaining({ + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + }), + ); + }); }); describe("cmdGet", () => { - it("gets memory in text mode", async () => { - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "text" }); - expect(output).toContain("dark mode"); - }); + it("gets memory in text mode", async () => { + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "text" }); + expect(output).toContain("dark mode"); + }); - it("gets memory in json mode", async () => { - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "json" }); - expect(output).toContain("memory"); - }); + it("gets memory in json mode", async () => { + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "json" }); + expect(output).toContain("memory"); + }); }); describe("cmdList", () => { - it("lists in table mode", async () => { - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("lists in table mode", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "table", - }); - expect(output).toContain("dark mode"); - }); + output: "table", + }); + expect(output).toContain("dark mode"); + }); - it("shows empty message", async () => { - (mockBackend.listMemories as ReturnType).mockResolvedValue([]); - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("shows empty message", async () => { + (mockBackend.listMemories as ReturnType).mockResolvedValue( + [], + ); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "text", - }); - expect(errOutput).toContain("No memories found"); - }); + output: "text", + }); + expect(errOutput).toContain("No memories found"); + }); + + it("forwards showExpired and latestOnly (MEM-5893)", async () => { + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, + showExpired: true, + latestOnly: true, + output: "text", + }); + expect(mockBackend.listMemories).toHaveBeenCalledWith( + expect.objectContaining({ showExpired: true, latestOnly: true }), + ); + }); }); describe("cmdUpdate", () => { - it("updates memory", async () => { - const { cmdUpdate } = await import("../src/commands/memory.js"); - await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" }); - expect(output.toLowerCase()).toContain("updated"); - }); + it("updates memory", async () => { + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" }); + expect(output.toLowerCase()).toContain("updated"); + }); + + it("forwards expires and timestamp (MEM-5893)", async () => { + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "New text", { + expires: "2099-01-01", + timestamp: 1700000000, + output: "text", + }); + expect(mockBackend.update).toHaveBeenCalledWith( + "abc-123", + "New text", + undefined, + expect.objectContaining({ + expirationDate: "2099-01-01", + timestamp: 1700000000, + }), + ); + }); }); describe("cmdDelete", () => { - it("deletes memory", async () => { - const { cmdDelete } = await import("../src/commands/memory.js"); - await cmdDelete(mockBackend, "abc-123", { output: "text" }); - expect(output.toLowerCase()).toContain("deleted"); - }); + it("deletes memory", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "text" }); + expect(output.toLowerCase()).toContain("deleted"); + }); + + it("forwards deleteLinked (MEM-5893)", async () => { + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { + deleteLinked: true, + output: "text", + }); + expect(mockBackend.delete).toHaveBeenCalledWith( + "abc-123", + expect.objectContaining({ deleteLinked: true }), + ); + }); }); describe("cmdDeleteAll", () => { - it("deletes all with force", async () => { - const { cmdDeleteAll } = await import("../src/commands/memory.js"); - await cmdDeleteAll(mockBackend, { - force: true, - userId: "alice", - output: "text", - }); - expect(output.toLowerCase()).toContain("deleted"); - }); + it("deletes all with force", async () => { + const { cmdDeleteAll } = await import("../src/commands/memory.js"); + await cmdDeleteAll(mockBackend, { + force: true, + userId: "alice", + output: "text", + }); + expect(output.toLowerCase()).toContain("deleted"); + }); }); - describe("cmdEntitiesList", () => { - it("lists users in table mode", async () => { - const { cmdEntitiesList } = await import("../src/commands/entities.js"); - await cmdEntitiesList(mockBackend, "users", { output: "table" }); - expect(output).toContain("alice"); - }); + it("lists users in table mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "table" }); + expect(output).toContain("alice"); + }); - it("lists in json mode", async () => { - const { cmdEntitiesList } = await import("../src/commands/entities.js"); - await cmdEntitiesList(mockBackend, "users", { output: "json" }); - expect(output).toContain("alice"); - }); + it("lists in json mode", async () => { + const { cmdEntitiesList } = await import("../src/commands/entities.js"); + await cmdEntitiesList(mockBackend, "users", { output: "json" }); + expect(output).toContain("alice"); + }); }); describe("cmdEventList", () => { - it("lists events in table mode", async () => { - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "table" }); - expect(output).toContain("evt-abc-"); - expect(output).toContain("ADD"); - expect(output).toContain("SUCCEEDED"); - }); + it("lists events in table mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect(output).toContain("evt-abc-"); + expect(output).toContain("ADD"); + expect(output).toContain("SUCCEEDED"); + }); - it("lists events in json mode", async () => { - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "json" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("evt-def-456-ghi-789"); - }); + it("lists events in json mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "json" }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("evt-def-456-ghi-789"); + }); - it("shows empty message when no events", async () => { - (mockBackend.listEvents as ReturnType).mockResolvedValueOnce([]); - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "table" }); - expect((output + errOutput).toLowerCase()).toContain("no events"); - }); + it("shows empty message when no events", async () => { + (mockBackend.listEvents as ReturnType).mockResolvedValueOnce( + [], + ); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect((output + errOutput).toLowerCase()).toContain("no events"); + }); }); describe("cmdEventStatus", () => { - it("shows event details in text mode", async () => { - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("SUCCEEDED"); - }); + it("shows event details in text mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "text", + }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("SUCCEEDED"); + }); - it("shows event details in json mode", async () => { - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" }); - expect(output).toContain("evt-abc-123-def-456"); - expect(output).toContain("ADD"); - }); + it("shows event details in json mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "json", + }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("ADD"); + }); }); describe("agent mode", () => { - it("cmdAdd outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdAdd } = await import("../src/commands/memory.js"); - await cmdAdd(mockBackend, "test preference", { - userId: "alice", - immutable: false, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("add"); - expect(parsed.data).toBeDefined(); - expect(parsed.scope).toMatchObject({ user_id: "alice" }); - expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort()); - }); + it("cmdAdd outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test preference", { + userId: "alice", + immutable: false, + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("add"); + expect(parsed.data).toBeDefined(); + expect(parsed.scope).toMatchObject({ user_id: "alice" }); + expect(Object.keys(parsed.data[0]).sort()).toEqual( + ["event", "id", "memory"].sort(), + ); + }); - it("cmdSearch outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdSearch } = await import("../src/commands/memory.js"); - await cmdSearch(mockBackend, "preferences", { - userId: "alice", - topK: 10, - threshold: 0.3, - rerank: false, - keyword: false, + it("cmdSearch outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("search"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - const keys = Object.keys(parsed.data[0]); - expect(keys).toContain("id"); - expect(keys).toContain("memory"); - expect(keys).toContain("score"); - expect(keys).toContain("created_at"); - expect(keys).toContain("categories"); - expect(keys).not.toContain("user_id"); - expect(keys).not.toContain("agent_id"); - }); + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("search"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + const keys = Object.keys(parsed.data[0]); + expect(keys).toContain("id"); + expect(keys).toContain("memory"); + expect(keys).toContain("score"); + expect(keys).toContain("created_at"); + expect(keys).toContain("categories"); + expect(keys).not.toContain("user_id"); + expect(keys).not.toContain("agent_id"); + }); - it("cmdList outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdList } = await import("../src/commands/memory.js"); - await cmdList(mockBackend, { - userId: "alice", - page: 1, - pageSize: 100, + it("cmdList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, - output: "agent", - }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("list"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]); - }); + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual([ + "categories", + "created_at", + "id", + "memory", + ]); + }); - it("cmdGet outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdGet } = await import("../src/commands/memory.js"); - await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("get"); - expect(parsed.data).toBeDefined(); - expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); - expect(Object.keys(parsed.data)).not.toContain("user_id"); - }); + it("cmdGet outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("get"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); + expect(Object.keys(parsed.data)).not.toContain("user_id"); + }); - it("cmdUpdate outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdUpdate } = await import("../src/commands/memory.js"); - await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("update"); - expect(parsed.data).toBeDefined(); - }); + it("cmdUpdate outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "Updated text", { + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("update"); + expect(parsed.data).toBeDefined(); + }); - it("cmdDelete outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdDelete } = await import("../src/commands/memory.js"); - await cmdDelete(mockBackend, "abc-123", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("delete"); - expect(parsed.data).toBeDefined(); - }); + it("cmdDelete outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("delete"); + expect(parsed.data).toBeDefined(); + }); - it("cmdEventList outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdEventList } = await import("../src/commands/events.js"); - await cmdEventList(mockBackend, { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("event list"); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.count).toBe(2); - expect(Object.keys(parsed.data[0]).sort()).toEqual( - ["created_at", "event_type", "id", "latency", "status"], - ); - expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); - }); + it("cmdEventList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual([ + "created_at", + "event_type", + "id", + "latency", + "status", + ]); + expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); + }); - it("cmdEventStatus outputs JSON envelope", async () => { - setAgentMode(true); - const { cmdEventStatus } = await import("../src/commands/events.js"); - await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" }); - const parsed = JSON.parse(output.trim()); - expect(parsed.status).toBe("success"); - expect(parsed.command).toBe("event status"); - expect(parsed.data).toBeDefined(); - expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); - expect(parsed.data.results[0]).toHaveProperty("memory"); - expect(parsed.data.results[0]).not.toHaveProperty("data"); - }); + it("cmdEventStatus outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event status"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); + expect(parsed.data.results[0]).toHaveProperty("memory"); + expect(parsed.data.results[0]).not.toHaveProperty("data"); + }); }); diff --git a/cli/node/tests/option-parity.test.ts b/cli/node/tests/option-parity.test.ts new file mode 100644 index 000000000..72f46a5f2 --- /dev/null +++ b/cli/node/tests/option-parity.test.ts @@ -0,0 +1,133 @@ +/** + * Drift test: every documented v3 add/search/list param must be reachable from the Node CLI. + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const OPENAPI_PATH = path.join( + __dirname, + "..", + "..", + "..", + "docs", + "openapi.json", +); + +const KNOWN_UNSURFACED: Record> = {}; + +const ADD_MAPPING: Record = { + messages: ["--messages", "--file", "text"], + user_id: ["--user-id"], + agent_id: ["--agent-id"], + run_id: ["--run-id"], + metadata: ["--metadata"], + expiration_date: ["--expires"], + custom_instructions: ["--custom-instructions"], + custom_categories: ["--custom-categories"], + infer: ["--no-infer"], +}; + +const SEARCH_MAPPING: Record = { + query: ["query"], + filters: ["--filter", "--user-id", "--agent-id", "--run-id"], + show_expired: ["--show-expired"], + top_k: ["--top-k"], + threshold: ["--threshold"], + rerank: ["--rerank"], + reference_date: ["--reference-date"], +}; + +const LIST_MAPPING: Record = { + filters: [ + "--user-id", + "--agent-id", + "--run-id", + "--category", + "--after", + "--before", + ], + show_expired: ["--show-expired"], +}; + +function documentedFields(endpoint: string): string[] { + const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const schema = + spec.paths[endpoint].post.requestBody.content["application/json"].schema; + return Object.keys(schema.properties); +} + +function helpText(command: string): string { + return execSync(`npx tsx src/index.ts ${command} --help`, { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + timeout: 15000, + }); +} + +function assertAllReachable( + endpoint: string, + mapping: Record, + command: string, +) { + const documented = documentedFields(endpoint); + const help = helpText(command); + for (const field of documented) { + if (KNOWN_UNSURFACED[endpoint]?.[field]) continue; + const candidates = mapping[field]; + expect( + candidates, + `${endpoint}: documented field "${field}" has no mapping entry for command "${command}"`, + ).toBeDefined(); + const reachable = candidates.some((flag) => + flag.startsWith("--") ? help.includes(flag) : true, + ); + expect( + reachable, + `${endpoint}: documented field "${field}" not reachable via any of ${JSON.stringify(candidates)} on command "${command}"`, + ).toBe(true); + } +} + +describe("Option parity: Node CLI reachability of documented v3 params", () => { + it("add covers documented fields", () => { + assertAllReachable("/v3/memories/add/", ADD_MAPPING, "add"); + }); + + it("search covers documented fields", () => { + assertAllReachable("/v3/memories/search/", SEARCH_MAPPING, "search"); + }); + + it("list covers documented fields", () => { + assertAllReachable("/v3/memories/", LIST_MAPPING, "list"); + }); +}); + +describe("stdin fallback uses the shared piped-stdin guard", () => { + const SOURCES = ["src/index.ts", "src/commands/memory.ts"]; + + for (const rel of SOURCES) { + it(`${rel} never checks process.stdin.isTTY directly`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8"); + expect( + src.includes("process.stdin.isTTY"), + `${rel}: use stdinIsPiped() from state.ts. A bare !isTTY check is also true for /dev/null and sockets, so readFileSync(0) crashes with EAGAIN in scripts, CI, and agent mode.`, + ).toBe(false); + }); + + it(`${rel} guards every readFileSync(0) with stdinIsPiped()`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8"); + const lines = src.split("\n"); + for (const [i, line] of lines.entries()) { + if (!line.includes("readFileSync(0")) continue; + const guard = lines.slice(Math.max(0, i - 3), i).join("\n"); + expect( + guard.includes("stdinIsPiped()"), + `${rel}:${i + 1}: readFileSync(0) must be guarded by stdinIsPiped()`, + ).toBe(true); + } + }); + } +}); diff --git a/cli/node/tests/platform-backend.test.ts b/cli/node/tests/platform-backend.test.ts index cb577a638..62c71305a 100644 --- a/cli/node/tests/platform-backend.test.ts +++ b/cli/node/tests/platform-backend.test.ts @@ -7,94 +7,231 @@ import { PlatformBackend } from "../src/backend/platform.js"; import { createDefaultConfig } from "../src/config.js"; function makeBackend(): PlatformBackend { - // apiKey/baseUrl only build request headers; every test spies on _request, - // so no real network calls are made. - return new PlatformBackend(createDefaultConfig().platform); + return new PlatformBackend(createDefaultConfig().platform); } function mockFetch() { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: vi.fn().mockReturnValue(null) }, - json: vi.fn().mockResolvedValue({ message: "ok" }), - }); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue(null) }, + json: vi.fn().mockResolvedValue({ message: "ok" }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; } beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("deleteEntities", () => { - it("returns all results keyed by entity type for a multi-entity delete", async () => { - const backend = makeBackend(); - const responses: Record = { - "/v2/entities/user/alice/": { message: "user deleted" }, - "/v2/entities/agent/bob/": { message: "agent deleted" }, - }; - const spy = vi - // biome-ignore lint/suspicious/noExplicitAny: spying on a private method - .spyOn(backend as any, "_request") - .mockImplementation(async (_method: string, path: string) => responses[path]); + it("returns all results keyed by entity type for a multi-entity delete", async () => { + const backend = makeBackend(); + const responses: Record = { + "/v2/entities/user/alice/": { message: "user deleted" }, + "/v2/entities/agent/bob/": { message: "agent deleted" }, + }; + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockImplementation( + async (_method: string, path: string) => responses[path], + ); - const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" }); + const result = await backend.deleteEntities({ + userId: "alice", + agentId: "bob", + }); - // Regression: previously only the last entity's response survived. - expect(result).toEqual({ - user: { message: "user deleted" }, - agent: { message: "agent deleted" }, - }); - expect(spy).toHaveBeenCalledTimes(2); - }); + expect(result).toEqual({ + user: { message: "user deleted" }, + agent: { message: "agent deleted" }, + }); + expect(spy).toHaveBeenCalledTimes(2); + }); - it("keys a single-entity delete by its type", async () => { - const backend = makeBackend(); - // biome-ignore lint/suspicious/noExplicitAny: spying on a private method - vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" }); + it("keys a single-entity delete by its type", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + vi.spyOn(backend as any, "_request").mockResolvedValue({ + message: "user deleted", + }); - const result = await backend.deleteEntities({ userId: "alice" }); - expect(result).toEqual({ user: { message: "user deleted" } }); - }); + const result = await backend.deleteEntities({ userId: "alice" }); + expect(result).toEqual({ user: { message: "user deleted" } }); + }); - it("throws when no entity id is provided", async () => { - const backend = makeBackend(); - await expect(backend.deleteEntities({})).rejects.toThrow( - "At least one entity ID is required", - ); - }); + it("throws when no entity id is provided", async () => { + const backend = makeBackend(); + await expect(backend.deleteEntities({})).rejects.toThrow( + "At least one entity ID is required", + ); + }); +}); + +describe("PlatformBackend option-parity payloads (MEM-5893)", () => { + it("add: custom_instructions, custom_categories, structured_data_schema, timestamp reach the payload alongside existing fields", async () => { + const backend = makeBackend(); + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockResolvedValue({ results: [] }); + + await backend.add("hello", undefined, { + userId: "alice", + metadata: { source: "test" }, + expires: "2099-01-01", + customInstructions: "Extract only preferences.", + customCategories: [{ prefs: "user preferences" }], + structuredDataSchema: { type: "object" }, + timestamp: 1700000000, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.custom_instructions).toBe("Extract only preferences."); + expect(payload.custom_categories).toEqual([{ prefs: "user preferences" }]); + expect(payload.structured_data_schema).toEqual({ type: "object" }); + expect(payload.timestamp).toBe(1700000000); + expect(payload.metadata).toEqual({ source: "test" }); + expect(payload.expiration_date).toBe("2099-01-01"); + }); + + it("add: omitted optional fields are absent from the payload", async () => { + const backend = makeBackend(); + const spy = vi + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + .spyOn(backend as any, "_request") + .mockResolvedValue({ results: [] }); + + await backend.add("hello", undefined, { userId: "alice" }); + + const payload = spy.mock.calls[0][2].json; + expect(payload).not.toHaveProperty("custom_instructions"); + expect(payload).not.toHaveProperty("custom_categories"); + expect(payload).not.toHaveProperty("structured_data_schema"); + expect(payload).not.toHaveProperty("timestamp"); + }); + + it("search: show_expired, reference_date, latest_only reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", { + showExpired: true, + referenceDate: "2024-01-01", + latestOnly: true, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.show_expired).toBe(true); + expect(payload.reference_date).toBe("2024-01-01"); + expect(payload.latest_only).toBe(true); + }); + + it("search: keyword_search and fields reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", { + keyword: true, + fields: ["memory", "score"], + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.keyword_search).toBe(true); + expect(payload.fields).toEqual(["memory", "score"]); + }); + + it("search: omitted keyword and fields are absent from the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.search("query", {}); + + const payload = spy.mock.calls[0][2].json; + expect(payload).not.toHaveProperty("keyword_search"); + expect(payload).not.toHaveProperty("fields"); + }); + + it("listMemories: show_expired and latest_only are top-level, not nested inside filters", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]); + + await backend.listMemories({ + userId: "alice", + showExpired: true, + latestOnly: true, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.show_expired).toBe(true); + expect(payload.latest_only).toBe(true); + expect(payload.filters ?? {}).not.toHaveProperty("show_expired"); + expect(payload.filters ?? {}).not.toHaveProperty("latest_only"); + }); + + it("update: expiration_date and timestamp reach the payload", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({}); + + await backend.update("mem-123", undefined, undefined, { + expirationDate: "2099-01-01", + timestamp: 1700000000, + }); + + const payload = spy.mock.calls[0][2].json; + expect(payload.expiration_date).toBe("2099-01-01"); + expect(payload.timestamp).toBe(1700000000); + }); + + it("delete: delete_linked is a query param, not part of the JSON body", async () => { + const backend = makeBackend(); + // biome-ignore lint/suspicious/noExplicitAny: spying on a private method + const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({}); + + await backend.delete("mem-123", { deleteLinked: true }); + + const opts = spy.mock.calls[0][2]; + expect(opts.params.delete_linked).toBe("true"); + expect(opts.json).toBeUndefined(); + }); }); describe("PlatformBackend path encoding", () => { - it("encodes memory IDs before interpolating them into paths", async () => { - const fetchMock = mockFetch(); - const backend = makeBackend(); + it("encodes memory IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); - await backend.get("mem/a?b#c"); - await backend.update("mem/a?b#c", "updated"); - await backend.delete("mem/a?b#c"); + await backend.get("mem/a?b#c"); + await backend.update("mem/a?b#c", "updated"); + await backend.delete("mem/a?b#c"); - const urls = fetchMock.mock.calls.map((call) => call[0]); - expect(urls).toEqual([ - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", - "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", - ]); - }); + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", + "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", + ]); + }); - it("encodes entity and event IDs before interpolating them into paths", async () => { - const fetchMock = mockFetch(); - const backend = makeBackend(); + it("encodes entity and event IDs before interpolating them into paths", async () => { + const fetchMock = mockFetch(); + const backend = makeBackend(); - await backend.deleteEntities({ userId: "org/team?active#frag" }); - await backend.getEvent("evt/a?b#c"); + await backend.deleteEntities({ userId: "org/team?active#frag" }); + await backend.getEvent("evt/a?b#c"); - const urls = fetchMock.mock.calls.map((call) => call[0]); - expect(urls).toEqual([ - "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", - "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", - ]); - }); + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([ + "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", + "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", + ]); + }); }); diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 850bfa0cd..9eb4f58ef 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -273,7 +273,21 @@ def add( no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."), expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), categories: str | None = typer.Option( - None, "--categories", help="Categories (JSON array or comma-separated)." + None, "--categories", help="Not supported on add, use --custom-categories instead." + ), + custom_instructions: str | None = typer.Option( + None, "--custom-instructions", help="Custom instructions for fact extraction." + ), + custom_categories: str | None = typer.Option( + None, + "--custom-categories", + help="Custom categories as a JSON array of {name: description} objects.", + ), + structured_data_schema: str | None = typer.Option( + None, "--structured-data-schema", help="Schema for structured data extraction, as JSON." + ), + timestamp: int | None = typer.Option( + None, "--timestamp", help="Unix timestamp for the memory." ), output: str = typer.Option( "text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output" @@ -312,6 +326,10 @@ def add( no_infer=no_infer, expires=expires, categories=categories, + custom_instructions=custom_instructions, + custom_categories=custom_categories, + structured_data_schema=structured_data_schema, + timestamp=timestamp, output=output, ) @@ -355,6 +373,21 @@ def search( help="Specific fields to return (comma-separated).", rich_help_panel="Search", ), + show_expired: bool = typer.Option( + False, "--show-expired", help="Include expired memories.", rich_help_panel="Search" + ), + reference_date: str | None = typer.Option( + None, + "--reference-date", + help="Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + rich_help_panel="Search", + ), + latest_only: bool = typer.Option( + False, + "--latest-only", + help="Only return the latest version of each memory.", + rich_help_panel="Search", + ), output: str = typer.Option( "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" ), @@ -398,6 +431,9 @@ def search( keyword=keyword, filter_json=filter_json, fields=fields, + show_expired=show_expired, + reference_date=reference_date, + latest_only=latest_only, output=output, ) @@ -464,6 +500,15 @@ def list_cmd( before: str | None = typer.Option( None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters" ), + show_expired: bool = typer.Option( + False, "--show-expired", help="Include expired memories.", rich_help_panel="Filters" + ), + latest_only: bool = typer.Option( + False, + "--latest-only", + help="Only return the latest version of each memory.", + rich_help_panel="Filters", + ), output: str = typer.Option( "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" ), @@ -497,6 +542,8 @@ def list_cmd( category=category, after=after, before=before, + show_expired=show_expired, + latest_only=latest_only, output=output, ) @@ -509,6 +556,10 @@ def update( memory_id: str = typer.Argument(..., help="Memory ID to update."), text: str | None = typer.Argument(None, help="New memory text."), metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."), + expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), + timestamp: int | None = typer.Option( + None, "--timestamp", help="Unix timestamp for the memory." + ), output: str = typer.Option( "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" ), @@ -537,7 +588,15 @@ def update( text = _read_stdin() backend = _get_backend(api_key, base_url) - cmd_update(backend, memory_id, text, metadata=metadata, output=output) + cmd_update( + backend, + memory_id, + text, + metadata=metadata, + expires=expires, + timestamp=timestamp, + output=output, + ) # ── Memory: delete ──────────────────────────────────────────────────────── @@ -559,6 +618,9 @@ def delete( False, "--dry-run", help="Show what would be deleted without deleting." ), force: bool = typer.Option(False, "--force", help="Skip confirmation."), + delete_linked: bool = typer.Option( + False, "--delete-linked", help="Also delete memories linked to this memory." + ), user_id: str | None = typer.Option( None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" ), @@ -616,7 +678,14 @@ def delete( from mem0_cli.commands.memory import cmd_delete backend = _get_backend(api_key, base_url) - cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output) + cmd_delete( + backend, + memory_id, + dry_run=dry_run, + force=force, + delete_linked=delete_linked, + output=output, + ) elif all_: _fire_telemetry("delete", {"delete_mode": "all"}) @@ -1068,7 +1137,11 @@ def _build_help_json() -> dict: "--immutable": "Prevent future updates.", "--no-infer": "Skip inference, store raw.", "--expires": "Expiration date (YYYY-MM-DD).", - "--categories": "Categories (JSON array or comma-separated).", + "--categories": "Not supported on add, use --custom-categories instead.", + "--custom-instructions": "Custom instructions for fact extraction.", + "--custom-categories": "Custom categories as a JSON array of {name: description} objects.", + "--structured-data-schema": "Schema for structured data extraction, as JSON.", + "--timestamp": "Unix timestamp for the memory.", "--graph": "Enable graph memory extraction.", "--no-graph": "Disable graph memory extraction.", "--output, -o": "Output format: text, json, quiet.", @@ -1087,6 +1160,9 @@ def _build_help_json() -> dict: "--keyword": "Use keyword search instead of semantic.", "--filter": "Advanced filter expression (JSON).", "--fields": "Specific fields to return (comma-separated).", + "--show-expired": "Include expired memories.", + "--reference-date": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", + "--latest-only": "Only return the latest version of each memory.", "--graph": "Enable graph in search.", "--no-graph": "Disable graph in search.", "--output, -o": "Output format: text, json, table.", @@ -1110,6 +1186,8 @@ def _build_help_json() -> dict: "--category": "Filter by category.", "--after": "Created after (YYYY-MM-DD).", "--before": "Created before (YYYY-MM-DD).", + "--show-expired": "Include expired memories.", + "--latest-only": "Only return the latest version of each memory.", "--graph": "Enable graph in listing.", "--no-graph": "Disable graph in listing.", "--output, -o": "Output format: text, json, table.", @@ -1124,6 +1202,8 @@ def _build_help_json() -> dict: }, "options": { "--metadata, -m": "Update metadata (JSON).", + "--expires": "Expiration date (YYYY-MM-DD).", + "--timestamp": "Unix timestamp for the memory.", "--output, -o": "Output format: text, json, quiet.", }, }, @@ -1140,6 +1220,7 @@ def _build_help_json() -> dict: "--all": "Delete all memories matching scope filters.", "--entity": "Delete the entity itself and all its memories (cascade).", "--project": "With --all: delete ALL memories project-wide.", + "--delete-linked": "Also delete memories linked to this memory.", "--dry-run": "Show what would be deleted without deleting.", "--force": "Skip confirmation.", "--user-id, -u": "Scope to user.", diff --git a/cli/python/src/mem0_cli/backend/base.py b/cli/python/src/mem0_cli/backend/base.py index 183e0bfa1..e5788b056 100644 --- a/cli/python/src/mem0_cli/backend/base.py +++ b/cli/python/src/mem0_cli/backend/base.py @@ -25,7 +25,10 @@ class Backend(ABC): immutable: bool = False, infer: bool = True, expires: str | None = None, - categories: list[str] | None = None, + custom_instructions: str | None = None, + custom_categories: list[dict] | None = None, + structured_data_schema: dict | None = None, + timestamp: int | None = None, ) -> dict: ... @abstractmethod @@ -43,6 +46,9 @@ class Backend(ABC): keyword: bool = False, filters: dict | None = None, fields: list[str] | None = None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, ) -> list[dict]: ... @abstractmethod @@ -61,11 +67,19 @@ class Backend(ABC): category: str | None = None, after: str | None = None, before: str | None = None, + show_expired: bool = False, + latest_only: bool = False, ) -> list[dict]: ... @abstractmethod def update( - self, memory_id: str, content: str | None = None, metadata: dict | None = None + self, + memory_id: str, + content: str | None = None, + metadata: dict | None = None, + *, + expiration_date: str | None = None, + timestamp: int | None = None, ) -> dict: ... @abstractmethod @@ -78,6 +92,7 @@ class Backend(ABC): agent_id: str | None = None, app_id: str | None = None, run_id: str | None = None, + delete_linked: bool = False, ) -> dict: ... @abstractmethod diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 1df3deadd..083c804e6 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -87,7 +87,10 @@ class PlatformBackend(Backend): immutable: bool = False, infer: bool = True, expires: str | None = None, - categories: list[str] | None = None, + custom_instructions: str | None = None, + custom_categories: list[dict] | None = None, + structured_data_schema: dict | None = None, + timestamp: int | None = None, ) -> dict: payload: dict[str, Any] = {} @@ -112,8 +115,14 @@ class PlatformBackend(Backend): payload["infer"] = False if expires: payload["expiration_date"] = expires - if categories: - payload["categories"] = categories + if custom_instructions: + payload["custom_instructions"] = custom_instructions + if custom_categories: + payload["custom_categories"] = custom_categories + if structured_data_schema: + payload["structured_data_schema"] = structured_data_schema + if timestamp is not None: + payload["timestamp"] = timestamp payload["source"] = "CLI" return self._request("POST", "/v3/memories/add/", json=payload) @@ -173,6 +182,9 @@ class PlatformBackend(Backend): keyword: bool = False, filters: dict | None = None, fields: list[str] | None = None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, ) -> list[dict]: payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold} @@ -191,6 +203,12 @@ class PlatformBackend(Backend): payload["keyword_search"] = True if fields: payload["fields"] = fields + if show_expired: + payload["show_expired"] = True + if reference_date is not None: + payload["reference_date"] = reference_date + if latest_only: + payload["latest_only"] = True payload["source"] = "CLI" result = self._request("POST", "/v3/memories/search/", json=payload) @@ -219,6 +237,8 @@ class PlatformBackend(Backend): category: str | None = None, after: str | None = None, before: str | None = None, + show_expired: bool = False, + latest_only: bool = False, ) -> list[dict]: payload: dict[str, Any] = {} params = {"page": str(page), "page_size": str(page_size)} @@ -241,6 +261,10 @@ class PlatformBackend(Backend): ) if api_filters: payload["filters"] = api_filters + if show_expired: + payload["show_expired"] = True + if latest_only: + payload["latest_only"] = True payload["source"] = "CLI" result = self._request("POST", "/v3/memories/", json=payload, params=params) @@ -251,13 +275,23 @@ class PlatformBackend(Backend): ) def update( - self, memory_id: str, content: str | None = None, metadata: dict | None = None + self, + memory_id: str, + content: str | None = None, + metadata: dict | None = None, + *, + expiration_date: str | None = None, + timestamp: int | None = None, ) -> dict: payload: dict[str, Any] = {} if content: payload["text"] = content if metadata: payload["metadata"] = metadata + if expiration_date: + payload["expiration_date"] = expiration_date + if timestamp is not None: + payload["timestamp"] = timestamp payload["source"] = "CLI" return self._request( "PUT", @@ -274,6 +308,7 @@ class PlatformBackend(Backend): agent_id: str | None = None, app_id: str | None = None, run_id: str | None = None, + delete_linked: bool = False, ) -> dict: if all: params: dict[str, str] = {"source": "CLI"} @@ -287,10 +322,13 @@ class PlatformBackend(Backend): params["run_id"] = run_id return self._request("DELETE", "/v1/memories/", params=params) elif memory_id: + params = {"source": "CLI"} + if delete_linked: + params["delete_linked"] = "true" return self._request( "DELETE", f"/v1/memories/{_encode_path_segment(memory_id)}/", - params={"source": "CLI"}, + params=params, ) else: raise ValueError("Either memory_id or --all is required") diff --git a/cli/python/src/mem0_cli/commands/memory.py b/cli/python/src/mem0_cli/commands/memory.py index 7e9ace6e4..315cde280 100644 --- a/cli/python/src/mem0_cli/commands/memory.py +++ b/cli/python/src/mem0_cli/commands/memory.py @@ -4,9 +4,11 @@ from __future__ import annotations import json import os +import re import stat as _stat_mod import sys import time as _time +from datetime import date from pathlib import Path import typer @@ -47,6 +49,18 @@ def _stdin_is_piped() -> bool: return False +def _validate_expires(value: str) -> None: + """Exit 1 if value is not a future YYYY-MM-DD date.""" + if not re.match(r"^\d{4}-\d{2}-\d{2}$", value): + print_error( + err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)." + ) + raise typer.Exit(1) + if date.fromisoformat(value) <= date.today(): + print_error(err_console, "--expires date must be in the future.") + raise typer.Exit(1) + + def cmd_add( backend: Backend, text: str | None, @@ -62,6 +76,10 @@ def cmd_add( no_infer: bool, expires: str | None, categories: str | None, + custom_instructions: str | None = None, + custom_categories: str | None = None, + structured_data_schema: str | None = None, + timestamp: int | None = None, output: str = "text", ) -> None: """Add a memory.""" @@ -70,6 +88,13 @@ def cmd_add( set_current_command("add") if is_agent_mode(): output = "agent" + + if categories: + print_error( + err_console, "--categories is not supported on add. Use --custom-categories instead." + ) + raise typer.Exit(1) + msgs = None content = text @@ -108,27 +133,24 @@ def cmd_add( print_error(err_console, "Invalid JSON in --metadata.") raise typer.Exit(1) from None - cats = None - if categories: + custom_cats = None + if custom_categories: try: - cats = json.loads(categories) + custom_cats = json.loads(custom_categories) except json.JSONDecodeError: - cats = [c.strip() for c in categories.split(",")] + print_error(err_console, "Invalid JSON in --custom-categories.") + raise typer.Exit(1) from None + + schema = None + if structured_data_schema: + try: + schema = json.loads(structured_data_schema) + except json.JSONDecodeError: + print_error(err_console, "Invalid JSON in --structured-data-schema.") + raise typer.Exit(1) from None - # Validate --expires if expires: - import re - - if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires): - print_error( - err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)." - ) - raise typer.Exit(1) - from datetime import date - - if date.fromisoformat(expires) <= date.today(): - print_error(err_console, "--expires date must be in the future.") - raise typer.Exit(1) + _validate_expires(expires) with timed_status(err_console, "Adding memory...") as ts: try: @@ -143,7 +165,10 @@ def cmd_add( immutable=immutable, infer=not no_infer, expires=expires, - categories=cats, + custom_instructions=custom_instructions, + custom_categories=custom_cats, + structured_data_schema=schema, + timestamp=timestamp, ) except Exception as e: ts.error_msg = str(e) @@ -224,6 +249,9 @@ def cmd_search( keyword: bool, filter_json: str | None, fields: str | None, + show_expired: bool = False, + reference_date: str | None = None, + latest_only: bool = False, output: str = "text", ) -> None: """Search memories.""" @@ -266,6 +294,9 @@ def cmd_search( keyword=keyword, filters=filters, fields=field_list, + show_expired=show_expired, + reference_date=reference_date, + latest_only=latest_only, ) except Exception as e: print_error(err_console, str(e)) @@ -352,6 +383,8 @@ def cmd_list( category: str | None, after: str | None, before: str | None, + show_expired: bool = False, + latest_only: bool = False, output: str = "table", ) -> None: """List memories.""" @@ -380,6 +413,8 @@ def cmd_list( category=category, after=after, before=before, + show_expired=show_expired, + latest_only=latest_only, ) except Exception as e: print_error(err_console, str(e)) @@ -446,6 +481,8 @@ def cmd_update( text: str | None, *, metadata: str | None, + expires: str | None = None, + timestamp: int | None = None, output: str, ) -> None: """Update a memory.""" @@ -462,10 +499,19 @@ def cmd_update( print_error(err_console, "Invalid JSON in --metadata.") raise typer.Exit(1) from None + if expires: + _validate_expires(expires) + _start = _time.perf_counter() with timed_status(err_console, "Updating memory...") as _ts: try: - result = backend.update(memory_id, content=text, metadata=meta) + result = backend.update( + memory_id, + content=text, + metadata=meta, + expiration_date=expires, + timestamp=timestamp, + ) except Exception as e: print_error(err_console, str(e)) raise typer.Exit(1) from None @@ -490,6 +536,7 @@ def cmd_delete( *, dry_run: bool = False, force: bool = False, + delete_linked: bool = False, output: str, ) -> None: """Delete a single memory by ID.""" @@ -512,7 +559,7 @@ def cmd_delete( _start = _time.perf_counter() with timed_status(err_console, "Deleting...") as _ts: try: - result = backend.delete(memory_id=memory_id) + result = backend.delete(memory_id=memory_id, delete_linked=delete_linked) except Exception as e: print_error(err_console, str(e)) raise typer.Exit(1) from None diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py index 8aad44bbb..dc1524d33 100644 --- a/cli/python/src/mem0_cli/output.py +++ b/cli/python/src/mem0_cli/output.py @@ -262,16 +262,32 @@ def sanitize_agent_data(command: str, data: Any) -> Any: return result if command == "search": - return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data] + return [ + pick(r, ["id", "memory", "score", "created_at", "categories", "expiration_date"]) + for r in data + ] if command == "list": - return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data] + return [ + pick(r, ["id", "memory", "created_at", "categories", "expiration_date"]) for r in data + ] if command == "get": - return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"]) + return pick( + data, + [ + "id", + "memory", + "created_at", + "updated_at", + "categories", + "metadata", + "expiration_date", + ], + ) if command == "update": - return pick(data, ["id", "memory"]) + return pick(data, ["id", "memory", "expiration_date"]) if command in ("delete", "delete-all", "entity delete"): return data diff --git a/cli/python/tests/test_commands.py b/cli/python/tests/test_commands.py index 4fd6a0c39..ad900f605 100644 --- a/cli/python/tests/test_commands.py +++ b/cli/python/tests/test_commands.py @@ -252,12 +252,13 @@ class TestAddCommand: ) mock_backend.add.assert_called_once() - def test_add_categories_csv(self, mock_backend): + def test_add_categories_rejected(self, mock_backend): console, _buf = _make_console() - err_console, _err_buf = _make_err_console() + err_console, err_buf = _make_err_console() with ( patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), ): cmd_add( mock_backend, @@ -275,7 +276,93 @@ class TestAddCommand: categories="health,prefs", output="text", ) - mock_backend.add.assert_called_once() + assert "--custom-categories" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_invalid_custom_categories_json(self, mock_backend): + console, _buf = _make_console() + err_console, err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + custom_categories="not-json", + output="text", + ) + assert "--custom-categories" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_invalid_structured_data_schema_json(self, mock_backend): + console, _buf = _make_console() + err_console, err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + pytest.raises((SystemExit, TyperExit)), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + structured_data_schema="not-json", + output="text", + ) + assert "--structured-data-schema" in err_buf.getvalue() + mock_backend.add.assert_not_called() + + def test_add_regression_metadata_expiration_custom_categories_together(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata='{"source": "test"}', + immutable=False, + no_infer=False, + expires="2099-01-01", + categories=None, + custom_categories='[{"prefs": "user preferences"}]', + output="text", + ) + call_kwargs = mock_backend.add.call_args.kwargs + assert call_kwargs["metadata"] == {"source": "test"} + assert call_kwargs["expires"] == "2099-01-01" + assert call_kwargs["custom_categories"] == [{"prefs": "user preferences"}] class TestAddDeduplicatesPending: @@ -464,6 +551,36 @@ class TestSearchCommand: ) mock_backend.search.assert_called_once() + def test_search_new_flags_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "preferences", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + show_expired=True, + reference_date="2024-01-01", + latest_only=True, + output="text", + ) + call_kwargs = mock_backend.search.call_args.kwargs + assert call_kwargs["show_expired"] is True + assert call_kwargs["reference_date"] == "2024-01-01" + assert call_kwargs["latest_only"] is True + class TestGetCommand: def test_get_text(self, mock_backend): @@ -561,6 +678,32 @@ class TestListCommand: output = buf.getvalue() assert "No memories found" in output + def test_list_new_flags_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + show_expired=True, + latest_only=True, + output="table", + ) + call_kwargs = mock_backend.list_memories.call_args.kwargs + assert call_kwargs["show_expired"] is True + assert call_kwargs["latest_only"] is True + class TestUpdateCommand: def test_update(self, mock_backend): @@ -585,6 +728,26 @@ class TestUpdateCommand: output = buf.getvalue() assert '"memory"' in output + def test_update_new_fields_reach_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_update( + mock_backend, + "abc-123", + "New text", + metadata=None, + expires="2099-01-01", + timestamp=1700000000, + output="text", + ) + call_kwargs = mock_backend.update.call_args.kwargs + assert call_kwargs["expiration_date"] == "2099-01-01" + assert call_kwargs["timestamp"] == 1700000000 + class TestDeleteCommand: def test_delete_single(self, mock_backend): @@ -610,6 +773,17 @@ class TestDeleteCommand: assert "dry run" in output.lower() mock_backend.delete.assert_not_called() + def test_delete_linked_reaches_backend(self, mock_backend): + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete(mock_backend, "abc-123", delete_linked=True, output="text") + call_kwargs = mock_backend.delete.call_args.kwargs + assert call_kwargs["delete_linked"] is True + class TestDeleteAllCommand: def test_delete_all_force(self, mock_backend): diff --git a/cli/python/tests/test_option_parity.py b/cli/python/tests/test_option_parity.py new file mode 100644 index 000000000..191cb32f4 --- /dev/null +++ b/cli/python/tests/test_option_parity.py @@ -0,0 +1,78 @@ +"""Drift test: every documented v3 add/search/list param must be reachable from the Python CLI.""" + +import json +from pathlib import Path + +import typer.main + +from mem0_cli.app import app + +REPO_ROOT = Path(__file__).resolve().parents[3] +OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json" + +KNOWN_UNSURFACED: dict[tuple[str, str], str] = {} + +ADD_MAPPING: dict[str, list[str]] = { + "messages": ["messages", "file", "text"], + "user_id": ["user_id"], + "agent_id": ["agent_id"], + "run_id": ["run_id"], + "metadata": ["metadata"], + "expiration_date": ["expires"], + "custom_instructions": ["custom_instructions"], + "custom_categories": ["custom_categories"], + "infer": ["no_infer"], +} + +SEARCH_MAPPING: dict[str, list[str]] = { + "query": ["query"], + "filters": ["filter_json", "user_id", "agent_id", "run_id"], + "show_expired": ["show_expired"], + "top_k": ["top_k"], + "threshold": ["threshold"], + "rerank": ["rerank"], + "reference_date": ["reference_date"], +} + +LIST_MAPPING: dict[str, list[str]] = { + "filters": ["user_id", "agent_id", "run_id", "category", "after", "before"], + "show_expired": ["show_expired"], +} + + +def _documented_fields(endpoint: str) -> set[str]: + spec = json.loads(OPENAPI_PATH.read_text()) + schema = spec["paths"][endpoint]["post"]["requestBody"]["content"]["application/json"]["schema"] + return set(schema["properties"]) + + +def _cli_param_names(command_name: str) -> set[str]: + click_app = typer.main.get_command(app) + command = click_app.commands[command_name] + return {param.name for param in command.params} + + +def _assert_all_reachable(endpoint: str, mapping: dict[str, list[str]], command_name: str) -> None: + documented = _documented_fields(endpoint) + reachable = _cli_param_names(command_name) + for field in documented: + if (endpoint, field) in KNOWN_UNSURFACED: + continue + candidates = mapping.get(field) + assert candidates, ( + f"{endpoint}: documented field {field!r} has no mapping entry for command {command_name!r}" + ) + assert any(candidate in reachable for candidate in candidates), ( + f"{endpoint}: documented field {field!r} not reachable via any of {candidates} on command {command_name!r}" + ) + + +class TestOptionParity: + def test_add_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/add/", ADD_MAPPING, "add") + + def test_search_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/search/", SEARCH_MAPPING, "search") + + def test_list_covers_documented_fields(self): + _assert_all_reachable("/v3/memories/", LIST_MAPPING, "list") diff --git a/cli/python/tests/test_platform_backend_options.py b/cli/python/tests/test_platform_backend_options.py new file mode 100644 index 000000000..9559864d2 --- /dev/null +++ b/cli/python/tests/test_platform_backend_options.py @@ -0,0 +1,109 @@ +"""Tests that the MEM-5893 option-parity flags reach the correct request payload/params.""" + +from __future__ import annotations + +from unittest.mock import patch + +from mem0_cli.backend.platform import PlatformBackend +from mem0_cli.config import PlatformConfig + + +def _make_backend() -> PlatformBackend: + return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai")) + + +class TestAddOptions: + def test_new_fields_and_existing_fields_land_in_payload_together(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={"results": []}) as mock_request: + backend.add( + content="hello", + user_id="alice", + metadata={"source": "test"}, + expires="2099-01-01", + custom_instructions="Extract only preferences.", + custom_categories=[{"prefs": "user preferences"}], + structured_data_schema={"type": "object"}, + timestamp=1700000000, + ) + payload = mock_request.call_args.kwargs["json"] + assert payload["custom_instructions"] == "Extract only preferences." + assert payload["custom_categories"] == [{"prefs": "user preferences"}] + assert payload["structured_data_schema"] == {"type": "object"} + assert payload["timestamp"] == 1700000000 + assert payload["metadata"] == {"source": "test"} + assert payload["expiration_date"] == "2099-01-01" + + def test_omitted_fields_are_absent_from_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={"results": []}) as mock_request: + backend.add(content="hello", user_id="alice") + payload = mock_request.call_args.kwargs["json"] + assert "custom_instructions" not in payload + assert "custom_categories" not in payload + assert "structured_data_schema" not in payload + assert "timestamp" not in payload + + +class TestSearchOptions: + def test_show_expired_reference_date_latest_only_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search( + "query", + show_expired=True, + reference_date="2024-01-01", + latest_only=True, + ) + payload = mock_request.call_args.kwargs["json"] + assert payload["show_expired"] is True + assert payload["reference_date"] == "2024-01-01" + assert payload["latest_only"] is True + + def test_keyword_and_fields_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search("query", keyword=True, fields=["memory", "score"]) + payload = mock_request.call_args.kwargs["json"] + assert payload["keyword_search"] is True + assert payload["fields"] == ["memory", "score"] + + def test_keyword_and_fields_omitted_are_absent_from_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.search("query") + payload = mock_request.call_args.kwargs["json"] + assert "keyword_search" not in payload + assert "fields" not in payload + + +class TestListOptions: + def test_show_expired_and_latest_only_are_top_level_not_in_filters(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value=[]) as mock_request: + backend.list_memories(user_id="alice", show_expired=True, latest_only=True) + payload = mock_request.call_args.kwargs["json"] + assert payload["show_expired"] is True + assert payload["latest_only"] is True + assert "show_expired" not in payload.get("filters", {}) + assert "latest_only" not in payload.get("filters", {}) + + +class TestUpdateOptions: + def test_expires_and_timestamp_reach_payload(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={}) as mock_request: + backend.update("mem-123", expiration_date="2099-01-01", timestamp=1700000000) + payload = mock_request.call_args.kwargs["json"] + assert payload["expiration_date"] == "2099-01-01" + assert payload["timestamp"] == 1700000000 + + +class TestDeleteOptions: + def test_delete_linked_is_a_query_param_not_json_body(self): + backend = _make_backend() + with patch.object(backend, "_request", return_value={}) as mock_request: + backend.delete(memory_id="mem-123", delete_linked=True) + call = mock_request.call_args + assert call.kwargs["params"]["delete_linked"] == "true" + assert "json" not in call.kwargs diff --git a/docs/platform/cli.mdx b/docs/platform/cli.mdx index 399216632..f2547997d 100644 --- a/docs/platform/cli.mdx +++ b/docs/platform/cli.mdx @@ -122,12 +122,20 @@ echo "Loves hiking on weekends" | mem0 add --user-id alice | `--messages` | Conversation messages as JSON | | `-f, --file` | Read messages from a JSON file | | `-m, --metadata` | Custom metadata as JSON | -| `--categories` | Categories (JSON array or comma-separated) | +| `--categories` | Rejected on `add`. Use `--custom-categories` instead | +| `--custom-categories` | Custom categories as a JSON array of `{name: description}` objects | +| `--custom-instructions` | Custom instructions for fact extraction | +| `--structured-data-schema` | Schema for structured data extraction, as JSON | +| `--timestamp` | Unix timestamp for the memory | | `--expires` | Expiration date, after which the memory stops being returned | | `--immutable` | Store the memory so it can never be updated or overwritten | | `--no-infer` | Store the text exactly as given, skipping fact extraction | | `-o, --output` | Output format: `text`, `json`, `quiet` | + +`--categories` is not supported on `add` and exits with an error pointing at `--custom-categories`. + + ### `mem0 search` Search memories using natural language. @@ -149,6 +157,9 @@ mem0 search "preferred tools" --user-id alice --output json --top-k 5 | `--keyword` | Use keyword search instead of semantic | | `--filter` | Advanced filter expression (JSON) | | `--fields` | Return only the named fields | +| `--show-expired` | Include expired memories | +| `--reference-date` | Reference date for relative queries (`YYYY-MM-DD` or Unix timestamp) | +| `--latest-only` | Only return the latest version of each memory | | `-o, --output` | Output format: `text`, `json`, `table` | ### `mem0 list` @@ -172,6 +183,8 @@ mem0 list --user-id alice --after 2024-01-01 --page-size 50 | `--category` | Filter by category | | `--after` | Created after date (YYYY-MM-DD) | | `--before` | Created before date (YYYY-MM-DD) | +| `--show-expired` | Include expired memories | +| `--latest-only` | Only return the latest version of each memory | | `-o, --output` | Output format: `text`, `json`, `table` | ### `mem0 get` @@ -200,6 +213,8 @@ echo "new text" | mem0 update | Flag | Description | |------|-------------| | `-m, --metadata` | Replace the memory's metadata with this JSON | +| `--expires` | Expiration date (YYYY-MM-DD) | +| `--timestamp` | Unix timestamp for the memory | | `-o, --output` | Output format: `text`, `json`, `quiet` | ### `mem0 delete` @@ -229,6 +244,7 @@ mem0 delete --all --user-id alice --dry-run | `--all` | Delete all memories matching scope filters | | `--entity` | Delete the entity and all its memories | | `--project` | With `--all`: delete all memories project-wide | +| `--delete-linked` | Also delete memories linked to this memory | | `--dry-run` | Preview without deleting | | `--force` | Skip confirmation prompt | diff --git a/integrations/mem0-plugin/scripts/load_settings.py b/integrations/mem0-plugin/scripts/load_settings.py index 58a53b569..139d180c6 100644 --- a/integrations/mem0-plugin/scripts/load_settings.py +++ b/integrations/mem0-plugin/scripts/load_settings.py @@ -27,24 +27,45 @@ def load_settings() -> dict: try: with open(SETTINGS_PATH) as f: user = json.load(f) - settings.update({k: v for k, v in user.items() if k in DEFAULTS}) except (json.JSONDecodeError, OSError): - pass + return settings + if isinstance(user, dict): + settings.update({k: v for k, v in user.items() if k in DEFAULTS}) return settings -def create_default_settings() -> None: +def create_default_settings() -> bool: + """Write the default settings file if absent. Returns True if it was created.""" SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + if SETTINGS_PATH.exists(): + return False + with open(SETTINGS_PATH, "w") as f: + json.dump(DEFAULTS, f, indent=2) + f.write("\n") + return True + + +def unknown_keys() -> list[str]: + """Keys present in the user's settings file that no code reads.""" if not SETTINGS_PATH.exists(): - with open(SETTINGS_PATH, "w") as f: - json.dump(DEFAULTS, f, indent=2) - f.write("\n") + return [] + try: + with open(SETTINGS_PATH) as f: + user = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + if not isinstance(user, dict): + return [] + return sorted(k for k in user if k not in DEFAULTS) if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "init": - create_default_settings() - print(f"Created {SETTINGS_PATH}") + if create_default_settings(): + print(f"Created {SETTINGS_PATH}") + ignored = unknown_keys() + if ignored: + print(f"Ignoring unrecognized settings in {SETTINGS_PATH}: {', '.join(ignored)}") else: print(json.dumps(load_settings())) diff --git a/integrations/mem0-plugin/tests/conftest.py b/integrations/mem0-plugin/tests/conftest.py index ab3eb809c..a170017f5 100644 --- a/integrations/mem0-plugin/tests/conftest.py +++ b/integrations/mem0-plugin/tests/conftest.py @@ -23,15 +23,14 @@ def _scripts_on_path(): @pytest.fixture(autouse=True) -def _clean_project_map(monkeypatch): - """Remove project_map.json and clear MEM0_PROJECT_ID before each test.""" +def _isolated_home(tmp_path, monkeypatch): + """Point HOME at a tmp dir so ~/.mem0 writes never touch the real home.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) - map_path = os.path.expanduser("~/.mem0/project_map.json") - if os.path.isfile(map_path): - os.remove(map_path) - yield - if os.path.isfile(map_path): - os.remove(map_path) + yield home @pytest.fixture() diff --git a/integrations/mem0-plugin/tests/test_load_settings.py b/integrations/mem0-plugin/tests/test_load_settings.py new file mode 100644 index 000000000..0f3216256 --- /dev/null +++ b/integrations/mem0-plugin/tests/test_load_settings.py @@ -0,0 +1,86 @@ +"""Tests for scripts/load_settings.py.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + + +@pytest.fixture() +def settings(tmp_path, monkeypatch): + import load_settings + + path = tmp_path / ".mem0" / "settings.json" + monkeypatch.setattr(load_settings, "SETTINGS_PATH", path) + return load_settings + + +def test_creates_file_when_absent(settings): + assert settings.create_default_settings() is True + assert settings.SETTINGS_PATH.exists() + assert json.loads(settings.SETTINGS_PATH.read_text()) == settings.DEFAULTS + + +def test_does_not_recreate_or_overwrite_existing(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"search_limit": 42}') + + assert settings.create_default_settings() is False + assert json.loads(settings.SETTINGS_PATH.read_text()) == {"search_limit": 42} + + +def test_user_values_override_defaults(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"search_limit": 3, "auto_save": false}') + + loaded = settings.load_settings() + assert loaded["search_limit"] == 3 + assert loaded["auto_save"] is False + assert loaded["global_search"] == settings.DEFAULTS["global_search"] + + +def test_unknown_keys_are_dropped_but_reported(settings): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text('{"skip_tools": ["Read"], "output_style": "compact"}') + + assert "skip_tools" not in settings.load_settings() + assert settings.unknown_keys() == ["output_style", "skip_tools"] + + +def test_unknown_keys_empty_for_clean_file(settings): + settings.create_default_settings() + assert settings.unknown_keys() == [] + + +@pytest.mark.parametrize("body", ["{not json", '["a", "list"]']) +def test_malformed_file_falls_back_to_defaults(settings, body): + settings.SETTINGS_PATH.parent.mkdir(parents=True) + settings.SETTINGS_PATH.write_text(body) + + assert settings.load_settings() == settings.DEFAULTS + assert settings.unknown_keys() == [] + + +def test_init_announces_creation_only_once(_isolated_home): + import load_settings + + home = _isolated_home + + def run_init(): + return subprocess.run( + [sys.executable, load_settings.__file__, "init"], + capture_output=True, + text=True, + check=True, + env={**os.environ, "HOME": str(home)}, + ).stdout + + first = run_init() + second = run_init() + + assert "Created" in first + assert "Created" not in second diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index d4f54b53f..b66d4a444 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -27,6 +27,8 @@ export interface SearchMemoryOptions { fields?: string[]; categories?: string[]; showExpired?: boolean; + referenceDate?: string | number; + keywordSearch?: boolean; } export interface GetAllMemoryOptions { diff --git a/mem0-ts/src/client/tests/memoryClient.search.test.ts b/mem0-ts/src/client/tests/memoryClient.search.test.ts index 9b36babe3..021187867 100644 --- a/mem0-ts/src/client/tests/memoryClient.search.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.search.test.ts @@ -99,6 +99,42 @@ describe("MemoryClient - search()", () => { expect(getFetchBody(call!).show_expired).toBe(true); }); + test("serializes referenceDate as reference_date (MEM-5893)", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { + filters: { user_id: "u1" }, + referenceDate: "2024-01-01", + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).reference_date).toBe("2024-01-01"); + }); + + test("serializes keywordSearch as keyword_search (MEM-5893)", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("test", { + filters: { user_id: "u1" }, + keywordSearch: true, + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).keyword_search).toBe(true); + }); + test("passes complex OR filters through to the API body", async () => { const extra = new Map(); extra.set("/v3/memories/search/", { diff --git a/mem0/client/types.py b/mem0/client/types.py index 62df27629..527bc2fc6 100644 --- a/mem0/client/types.py +++ b/mem0/client/types.py @@ -52,6 +52,11 @@ class SearchMemoryOptions(BaseModel): fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response") categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") + reference_date: Optional[Union[str, int]] = Field( + default=None, description="Reference date for relative time queries (YYYY-MM-DD or Unix timestamp)" + ) + latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version") + keyword_search: Optional[bool] = Field(default=None, description="Whether to use keyword search") class GetAllMemoryOptions(BaseModel): @@ -74,6 +79,7 @@ class GetAllMemoryOptions(BaseModel): ) categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") + latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version") class DeleteAllMemoryOptions(BaseModel): diff --git a/tests/test_client.py b/tests/test_client.py index 8aa554763..aa8c16ce5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,8 @@ import httpx import pytest import requests +from mem0.client.types import GetAllMemoryOptions, SearchMemoryOptions + @pytest.fixture def mock_memory_client(): @@ -130,6 +132,55 @@ class TestGetAllEntityParamRejection: ) +class TestSearchTypedOptionsParity: + """MEM-5893: SearchMemoryOptions' new typed fields serialize to their v3 snake_case keys.""" + + def test_search_options_pass_reference_date_latest_only_keyword_search(self, mock_memory_client): + """search(options=SearchMemoryOptions(...)) should forward the new fields verbatim.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + options = SearchMemoryOptions( + filters={"user_id": "u1"}, + reference_date="2024-01-01", + latest_only=True, + keyword_search=True, + ) + mock_memory_client.search("test query", options=options) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/search/", + json={ + "query": "test query", + "filters": {"user_id": "u1"}, + "reference_date": "2024-01-01", + "latest_only": True, + "keyword_search": True, + }, + ) + + +class TestGetAllTypedOptionsParity: + """MEM-5893: GetAllMemoryOptions.latest_only serializes to its v3 snake_case key.""" + + def test_get_all_options_pass_latest_only(self, mock_memory_client): + """get_all(options=GetAllMemoryOptions(...)) should forward latest_only verbatim.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + options = GetAllMemoryOptions(filters={"user_id": "u1"}, latest_only=True) + mock_memory_client.get_all(options=options) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/", + json={"filters": {"user_id": "u1"}, "latest_only": True}, + ) + + class TestUpdateExpirationDate: """Tests for update expiration_date payload handling."""