Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c224425d3 | ||
|
|
6e6f5b8d59 | ||
|
|
dd54e387de | ||
|
|
fd32b980b4 | ||
|
|
21aae599be | ||
|
|
ea6fd3b457 | ||
|
|
8ca9a0f2c0 | ||
|
|
5f77d86caf | ||
|
|
9ef409222f | ||
|
|
c90bdbdce0 |
@@ -38,6 +38,7 @@ jobs:
|
|||||||
cli_python: ${{ steps.filter.outputs.cli_python }}
|
cli_python: ${{ steps.filter.outputs.cli_python }}
|
||||||
cli_node: ${{ steps.filter.outputs.cli_node }}
|
cli_node: ${{ steps.filter.outputs.cli_node }}
|
||||||
openclaw: ${{ steps.filter.outputs.openclaw }}
|
openclaw: ${{ steps.filter.outputs.openclaw }}
|
||||||
|
mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }}
|
||||||
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
|
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
|
||||||
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
|
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
|
||||||
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
|
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
|
||||||
@@ -73,6 +74,11 @@ jobs:
|
|||||||
- 'integrations/openclaw/**'
|
- 'integrations/openclaw/**'
|
||||||
- '.github/workflows/openclaw-checks.yml'
|
- '.github/workflows/openclaw-checks.yml'
|
||||||
- '.github/workflows/ci-gate.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:
|
opencode_plugin:
|
||||||
- 'integrations/mem0-plugin/.opencode-plugin/**'
|
- 'integrations/mem0-plugin/.opencode-plugin/**'
|
||||||
- '.github/workflows/opencode-plugin-checks.yml'
|
- '.github/workflows/opencode-plugin-checks.yml'
|
||||||
@@ -131,6 +137,13 @@ jobs:
|
|||||||
uses: ./.github/workflows/openclaw-checks.yml
|
uses: ./.github/workflows/openclaw-checks.yml
|
||||||
secrets: inherit
|
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:
|
opencode-plugin:
|
||||||
name: OpenCode Plugin
|
name: OpenCode Plugin
|
||||||
needs: changes
|
needs: changes
|
||||||
@@ -173,6 +186,7 @@ jobs:
|
|||||||
- cli-python
|
- cli-python
|
||||||
- cli-node
|
- cli-node
|
||||||
- openclaw
|
- openclaw
|
||||||
|
- mem0-plugin
|
||||||
- opencode-plugin
|
- opencode-plugin
|
||||||
- pi-agent-plugin
|
- pi-agent-plugin
|
||||||
- n8n-nodes-mem0
|
- n8n-nodes-mem0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (on `integrations/n8n-nodes-mem0/`), manual | ESLint (n8n-nodes-base) + tsc build (dist artifact check) on Node 20 |
|
||||||
|
|||||||
+1
-2
@@ -60,9 +60,8 @@ mem0 delete <memory-id>
|
|||||||
| `mem0 entity` | List or delete entities (users, agents, apps, runs) |
|
| `mem0 entity` | List or delete entities (users, agents, apps, runs) |
|
||||||
| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) |
|
| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) |
|
||||||
| `mem0 status` | Verify API connection and display current project |
|
| `mem0 status` | Verify API connection and display current project |
|
||||||
| `mem0 version` | Print the CLI version |
|
|
||||||
|
|
||||||
Run `mem0 <command> --help` for detailed usage on any command.
|
Run `mem0 <command> --help` for detailed usage on any command, or `mem0 --version` to print the CLI version.
|
||||||
|
|
||||||
## Agent mode
|
## Agent mode
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -207,7 +207,11 @@
|
|||||||
{ "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." },
|
{ "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": "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": "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": "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": "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" }
|
{ "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": "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": "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": "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": "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": "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" }
|
{ "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": "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": "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": "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": "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": "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" }
|
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" }
|
||||||
@@ -329,6 +338,8 @@
|
|||||||
],
|
],
|
||||||
"options": [
|
"options": [
|
||||||
{ "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." },
|
{ "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" }
|
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" }
|
||||||
],
|
],
|
||||||
"apiEndpoint": "update"
|
"apiEndpoint": "update"
|
||||||
@@ -356,6 +367,7 @@
|
|||||||
{ "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." },
|
{ "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": "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": "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": "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": "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" },
|
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" },
|
||||||
|
|||||||
+2
-8
@@ -232,14 +232,6 @@ Verify your API connection and display the current project.
|
|||||||
mem0 status
|
mem0 status
|
||||||
```
|
```
|
||||||
|
|
||||||
### `mem0 version`
|
|
||||||
|
|
||||||
Print the CLI version.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mem0 version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Agent mode
|
## Agent mode
|
||||||
|
|
||||||
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
|
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
|
||||||
@@ -298,6 +290,8 @@ These flags are available on all commands:
|
|||||||
| `--base-url` | Override the configured API base URL for this request |
|
| `--base-url` | Override the configured API base URL for this request |
|
||||||
| `-o, --output` | Set the output format |
|
| `-o, --output` | Set the output format |
|
||||||
|
|
||||||
|
`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one.
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ pnpm link --global
|
|||||||
|
|
||||||
# Now use it like a normal CLI
|
# Now use it like a normal CLI
|
||||||
mem0 --help
|
mem0 --help
|
||||||
mem0 version
|
mem0 --version
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`.
|
> **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`.
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export interface AddOptions {
|
|||||||
immutable?: boolean;
|
immutable?: boolean;
|
||||||
infer?: boolean;
|
infer?: boolean;
|
||||||
expires?: string;
|
expires?: string;
|
||||||
categories?: string[];
|
customInstructions?: string;
|
||||||
|
customCategories?: Record<string, string>[];
|
||||||
|
structuredDataSchema?: Record<string, unknown>;
|
||||||
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchOptions {
|
export interface SearchOptions {
|
||||||
@@ -28,6 +31,9 @@ export interface SearchOptions {
|
|||||||
keyword?: boolean;
|
keyword?: boolean;
|
||||||
filters?: Record<string, unknown>;
|
filters?: Record<string, unknown>;
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
|
showExpired?: boolean;
|
||||||
|
referenceDate?: string | number;
|
||||||
|
latestOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListOptions {
|
export interface ListOptions {
|
||||||
@@ -40,6 +46,8 @@ export interface ListOptions {
|
|||||||
category?: string;
|
category?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
|
showExpired?: boolean;
|
||||||
|
latestOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteOptions {
|
export interface DeleteOptions {
|
||||||
@@ -48,6 +56,12 @@ export interface DeleteOptions {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
deleteLinked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOptions {
|
||||||
|
expirationDate?: string;
|
||||||
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntityIds {
|
export interface EntityIds {
|
||||||
@@ -77,6 +91,7 @@ export interface Backend {
|
|||||||
memoryId: string,
|
memoryId: string,
|
||||||
content?: string,
|
content?: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
|
opts?: UpdateOptions,
|
||||||
): Promise<Record<string, unknown>>;
|
): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
delete(
|
delete(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type ListOptions,
|
type ListOptions,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
type SearchOptions,
|
type SearchOptions,
|
||||||
|
type UpdateOptions,
|
||||||
} from "./base.js";
|
} from "./base.js";
|
||||||
|
|
||||||
function encodePathSegment(value: unknown): string {
|
function encodePathSegment(value: unknown): string {
|
||||||
@@ -150,7 +151,13 @@ export class PlatformBackend implements Backend {
|
|||||||
if (opts.immutable) payload.immutable = true;
|
if (opts.immutable) payload.immutable = true;
|
||||||
if (opts.infer === false) payload.infer = false;
|
if (opts.infer === false) payload.infer = false;
|
||||||
if (opts.expires) payload.expiration_date = opts.expires;
|
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";
|
payload.source = "CLI";
|
||||||
|
|
||||||
return (await this._request("POST", "/v3/memories/add/", {
|
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.rerank) payload.rerank = true;
|
||||||
if (opts.keyword) payload.keyword_search = true;
|
if (opts.keyword) payload.keyword_search = true;
|
||||||
if (opts.fields) payload.fields = opts.fields;
|
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";
|
payload.source = "CLI";
|
||||||
|
|
||||||
const result = (await this._request("POST", "/v3/memories/search/", {
|
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,
|
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
||||||
});
|
});
|
||||||
if (apiFilters) payload.filters = apiFilters;
|
if (apiFilters) payload.filters = apiFilters;
|
||||||
|
if (opts.showExpired) payload.show_expired = true;
|
||||||
|
if (opts.latestOnly) payload.latest_only = true;
|
||||||
payload.source = "CLI";
|
payload.source = "CLI";
|
||||||
|
|
||||||
const result = (await this._request("POST", "/v3/memories/", {
|
const result = (await this._request("POST", "/v3/memories/", {
|
||||||
@@ -280,10 +293,13 @@ export class PlatformBackend implements Backend {
|
|||||||
memoryId: string,
|
memoryId: string,
|
||||||
content?: string,
|
content?: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
|
opts: UpdateOptions = {},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (content) payload.text = content;
|
if (content) payload.text = content;
|
||||||
if (metadata) payload.metadata = metadata;
|
if (metadata) payload.metadata = metadata;
|
||||||
|
if (opts.expirationDate) payload.expiration_date = opts.expirationDate;
|
||||||
|
if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp;
|
||||||
payload.source = "CLI";
|
payload.source = "CLI";
|
||||||
return (await this._request(
|
return (await this._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -309,12 +325,12 @@ export class PlatformBackend implements Backend {
|
|||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
if (memoryId) {
|
if (memoryId) {
|
||||||
|
const params: Record<string, string> = { source: "CLI" };
|
||||||
|
if (opts.deleteLinked) params.delete_linked = "true";
|
||||||
return (await this._request(
|
return (await this._request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
`/v1/memories/${encodePathSegment(memoryId)}/`,
|
`/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||||
{
|
{ params },
|
||||||
params: { source: "CLI" },
|
|
||||||
},
|
|
||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
throw new Error("Either memoryId or --all is required");
|
throw new Error("Either memoryId or --all is required");
|
||||||
|
|||||||
@@ -21,16 +21,19 @@ import {
|
|||||||
formatSingleMemory,
|
formatSingleMemory,
|
||||||
printResultSummary,
|
printResultSummary,
|
||||||
} from "../output.js";
|
} 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. */
|
/** Exit 1 if value is not a future YYYY-MM-DD date. */
|
||||||
function _stdinIsPiped(): boolean {
|
function _validateExpires(value: string): void {
|
||||||
if (isAgentMode()) return false;
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
try {
|
printError(
|
||||||
const stat = fs.fstatSync(0);
|
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
|
||||||
return stat.isFIFO() || stat.isFile();
|
);
|
||||||
} catch {
|
process.exit(1);
|
||||||
return false;
|
}
|
||||||
|
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;
|
infer?: boolean;
|
||||||
expires?: string;
|
expires?: string;
|
||||||
categories?: string;
|
categories?: string;
|
||||||
|
customInstructions?: string;
|
||||||
|
customCategories?: string;
|
||||||
|
structuredDataSchema?: string;
|
||||||
|
timestamp?: number;
|
||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
setCurrentCommand("add");
|
setCurrentCommand("add");
|
||||||
|
|
||||||
|
if (opts.categories) {
|
||||||
|
printError(
|
||||||
|
"--categories is not supported on add. Use --custom-categories instead.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
let msgs: Record<string, unknown>[] | undefined;
|
let msgs: Record<string, unknown>[] | undefined;
|
||||||
let content = text;
|
let content = text;
|
||||||
|
|
||||||
@@ -78,7 +93,7 @@ export async function cmdAdd(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Read from stdin only if stdin is an actual pipe or file redirect
|
// 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();
|
content = fs.readFileSync(0, "utf-8").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,20 +108,6 @@ export async function cmdAdd(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate --expires
|
|
||||||
if (opts.expires) {
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) {
|
|
||||||
printError(
|
|
||||||
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (new Date(opts.expires) <= new Date()) {
|
|
||||||
printError("--expires date must be in the future.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let meta: Record<string, unknown> | undefined;
|
let meta: Record<string, unknown> | undefined;
|
||||||
if (opts.metadata) {
|
if (opts.metadata) {
|
||||||
try {
|
try {
|
||||||
@@ -117,15 +118,28 @@ export async function cmdAdd(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cats: string[] | undefined;
|
let customCats: Record<string, string>[] | undefined;
|
||||||
if (opts.categories) {
|
if (opts.customCategories) {
|
||||||
try {
|
try {
|
||||||
cats = JSON.parse(opts.categories);
|
customCats = JSON.parse(opts.customCategories);
|
||||||
} catch {
|
} catch {
|
||||||
cats = opts.categories.split(",").map((c) => c.trim());
|
printError("Invalid JSON in --custom-categories.");
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let schema: Record<string, unknown> | undefined;
|
||||||
|
if (opts.structuredDataSchema) {
|
||||||
|
try {
|
||||||
|
schema = JSON.parse(opts.structuredDataSchema);
|
||||||
|
} catch {
|
||||||
|
printError("Invalid JSON in --structured-data-schema.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.expires) _validateExpires(opts.expires);
|
||||||
|
|
||||||
let result: Record<string, unknown>;
|
let result: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
result = await timedStatus("Adding memory...", async () => {
|
result = await timedStatus("Adding memory...", async () => {
|
||||||
@@ -138,7 +152,10 @@ export async function cmdAdd(
|
|||||||
immutable: opts.immutable,
|
immutable: opts.immutable,
|
||||||
infer: opts.infer !== false,
|
infer: opts.infer !== false,
|
||||||
expires: opts.expires,
|
expires: opts.expires,
|
||||||
categories: cats,
|
customInstructions: opts.customInstructions,
|
||||||
|
customCategories: customCats,
|
||||||
|
structuredDataSchema: schema,
|
||||||
|
timestamp: opts.timestamp,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -223,6 +240,9 @@ export async function cmdSearch(
|
|||||||
keyword: boolean;
|
keyword: boolean;
|
||||||
filterJson?: string;
|
filterJson?: string;
|
||||||
fields?: string;
|
fields?: string;
|
||||||
|
showExpired?: boolean;
|
||||||
|
referenceDate?: string;
|
||||||
|
latestOnly?: boolean;
|
||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -271,6 +291,9 @@ export async function cmdSearch(
|
|||||||
keyword: opts.keyword,
|
keyword: opts.keyword,
|
||||||
filters,
|
filters,
|
||||||
fields: fieldList,
|
fields: fieldList,
|
||||||
|
showExpired: opts.showExpired,
|
||||||
|
referenceDate: opts.referenceDate,
|
||||||
|
latestOnly: opts.latestOnly,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -364,6 +387,8 @@ export async function cmdList(
|
|||||||
category?: string;
|
category?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
|
showExpired?: boolean;
|
||||||
|
latestOnly?: boolean;
|
||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -391,6 +416,8 @@ export async function cmdList(
|
|||||||
category: opts.category,
|
category: opts.category,
|
||||||
after: opts.after,
|
after: opts.after,
|
||||||
before: opts.before,
|
before: opts.before,
|
||||||
|
showExpired: opts.showExpired,
|
||||||
|
latestOnly: opts.latestOnly,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -450,7 +477,12 @@ export async function cmdUpdate(
|
|||||||
backend: Backend,
|
backend: Backend,
|
||||||
memoryId: string,
|
memoryId: string,
|
||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
opts: { metadata?: string; output: string },
|
opts: {
|
||||||
|
metadata?: string;
|
||||||
|
expires?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
output: string;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
setCurrentCommand("update");
|
setCurrentCommand("update");
|
||||||
let meta: Record<string, unknown> | undefined;
|
let meta: Record<string, unknown> | undefined;
|
||||||
@@ -463,11 +495,16 @@ export async function cmdUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.expires) _validateExpires(opts.expires);
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
let result: Record<string, unknown>;
|
let result: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
result = await timedStatus("Updating memory...", async () => {
|
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) {
|
} catch (e) {
|
||||||
printError(e instanceof Error ? e.message : String(e));
|
printError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -493,7 +530,12 @@ export async function cmdUpdate(
|
|||||||
export async function cmdDelete(
|
export async function cmdDelete(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
memoryId: string,
|
memoryId: string,
|
||||||
opts: { output: string; dryRun?: boolean; force?: boolean },
|
opts: {
|
||||||
|
output: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
deleteLinked?: boolean;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
setCurrentCommand("delete");
|
setCurrentCommand("delete");
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
@@ -514,7 +556,7 @@ export async function cmdDelete(
|
|||||||
let result: Record<string, unknown>;
|
let result: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
result = await timedStatus("Deleting...", async () => {
|
result = await timedStatus("Deleting...", async () => {
|
||||||
return backend.delete(memoryId);
|
return backend.delete(memoryId, { deleteLinked: opts.deleteLinked });
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
printError(e instanceof Error ? e.message : String(e));
|
printError(e instanceof Error ? e.message : String(e));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Utility commands: status, version, import.
|
* Utility commands: status, import.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -8,7 +8,6 @@ import type { Backend } from "../backend/base.js";
|
|||||||
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
|
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
|
||||||
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
|
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
|
||||||
import { setCurrentCommand } from "../state.js";
|
import { setCurrentCommand } from "../state.js";
|
||||||
import { CLI_VERSION } from "../version.js";
|
|
||||||
|
|
||||||
const { brand, dim, success, error: errorColor } = colors;
|
const { brand, dim, success, error: errorColor } = colors;
|
||||||
|
|
||||||
@@ -82,10 +81,6 @@ export async function cmdStatus(
|
|||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cmdVersion(): void {
|
|
||||||
console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdImport(
|
export async function cmdImport(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|||||||
+57
-5
@@ -17,6 +17,7 @@ import {
|
|||||||
isAgentMode,
|
isAgentMode,
|
||||||
setAgentMode,
|
setAgentMode,
|
||||||
setCurrentCommand,
|
setCurrentCommand,
|
||||||
|
stdinIsPiped,
|
||||||
takeNotice,
|
takeNotice,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
import { captureEvent } from "./telemetry.js";
|
import { captureEvent } from "./telemetry.js";
|
||||||
@@ -319,7 +320,25 @@ program
|
|||||||
.option("--immutable", "Prevent future updates.", false)
|
.option("--immutable", "Prevent future updates.", false)
|
||||||
.option("--no-infer", "Skip inference, store raw.")
|
.option("--no-infer", "Skip inference, store raw.")
|
||||||
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
|
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
|
||||||
.option("--categories <value>", "Categories (JSON array or comma-separated).")
|
.option(
|
||||||
|
"--categories <value>",
|
||||||
|
"Not supported on add, use --custom-categories instead.",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--custom-instructions <text>",
|
||||||
|
"Custom instructions for fact extraction.",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--custom-categories <json>",
|
||||||
|
"Custom categories as a JSON array of {name: description} objects.",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--structured-data-schema <json>",
|
||||||
|
"Schema for structured data extraction, as JSON.",
|
||||||
|
)
|
||||||
|
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
|
||||||
|
Number.parseInt(v),
|
||||||
|
)
|
||||||
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
|
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -366,6 +385,16 @@ program
|
|||||||
.option("--keyword", "Use keyword search.", false)
|
.option("--keyword", "Use keyword search.", false)
|
||||||
.option("--filter <json>", "Advanced filter expression (JSON).")
|
.option("--filter <json>", "Advanced filter expression (JSON).")
|
||||||
.option("--fields <list>", "Specific fields to return (comma-separated).")
|
.option("--fields <list>", "Specific fields to return (comma-separated).")
|
||||||
|
.option("--show-expired", "Include expired memories.", false)
|
||||||
|
.option(
|
||||||
|
"--reference-date <date>",
|
||||||
|
"Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--latest-only",
|
||||||
|
"Only return the latest version of each memory.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("-o, --output <format>", "Output: text, json, table.", "text")
|
.option("-o, --output <format>", "Output: text, json, table.", "text")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -375,7 +404,7 @@ program
|
|||||||
)
|
)
|
||||||
.action(async (query, opts) => {
|
.action(async (query, opts) => {
|
||||||
let resolvedQuery = query;
|
let resolvedQuery = query;
|
||||||
if (!resolvedQuery && !process.stdin.isTTY) {
|
if (!resolvedQuery && stdinIsPiped()) {
|
||||||
resolvedQuery = fs.readFileSync(0, "utf-8").trim();
|
resolvedQuery = fs.readFileSync(0, "utf-8").trim();
|
||||||
}
|
}
|
||||||
if (!resolvedQuery) {
|
if (!resolvedQuery) {
|
||||||
@@ -398,6 +427,9 @@ program
|
|||||||
keyword: opts.keyword,
|
keyword: opts.keyword,
|
||||||
filterJson: opts.filter,
|
filterJson: opts.filter,
|
||||||
fields: opts.fields,
|
fields: opts.fields,
|
||||||
|
showExpired: opts.showExpired,
|
||||||
|
referenceDate: opts.referenceDate,
|
||||||
|
latestOnly: opts.latestOnly,
|
||||||
output,
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -441,6 +473,12 @@ program
|
|||||||
.option("--category <name>", "Filter by category.")
|
.option("--category <name>", "Filter by category.")
|
||||||
.option("--after <date>", "Created after (YYYY-MM-DD).")
|
.option("--after <date>", "Created after (YYYY-MM-DD).")
|
||||||
.option("--before <date>", "Created before (YYYY-MM-DD).")
|
.option("--before <date>", "Created before (YYYY-MM-DD).")
|
||||||
|
.option("--show-expired", "Include expired memories.", false)
|
||||||
|
.option(
|
||||||
|
"--latest-only",
|
||||||
|
"Only return the latest version of each memory.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("-o, --output <format>", "Output: text, json, table.", "table")
|
.option("-o, --output <format>", "Output: text, json, table.", "table")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -464,6 +502,8 @@ program
|
|||||||
category: opts.category,
|
category: opts.category,
|
||||||
after: opts.after,
|
after: opts.after,
|
||||||
before: opts.before,
|
before: opts.before,
|
||||||
|
showExpired: opts.showExpired,
|
||||||
|
latestOnly: opts.latestOnly,
|
||||||
output,
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -474,6 +514,10 @@ program
|
|||||||
.command("update <memoryId> [text]")
|
.command("update <memoryId> [text]")
|
||||||
.description("Update a memory's text or metadata.")
|
.description("Update a memory's text or metadata.")
|
||||||
.option("-m, --metadata <json>", "Update metadata (JSON).")
|
.option("-m, --metadata <json>", "Update metadata (JSON).")
|
||||||
|
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
|
||||||
|
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
|
||||||
|
Number.parseInt(v),
|
||||||
|
)
|
||||||
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
|
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -483,7 +527,7 @@ program
|
|||||||
)
|
)
|
||||||
.action(async (memoryId, text, opts) => {
|
.action(async (memoryId, text, opts) => {
|
||||||
let resolvedText = text;
|
let resolvedText = text;
|
||||||
if (!resolvedText && !opts.metadata && !process.stdin.isTTY) {
|
if (!resolvedText && stdinIsPiped()) {
|
||||||
resolvedText = fs.readFileSync(0, "utf-8").trim();
|
resolvedText = fs.readFileSync(0, "utf-8").trim();
|
||||||
}
|
}
|
||||||
const { cmdUpdate } = await import("./commands/memory.js");
|
const { cmdUpdate } = await import("./commands/memory.js");
|
||||||
@@ -492,6 +536,8 @@ program
|
|||||||
const output = isAgent ? "agent" : opts.output;
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdUpdate(backend, memoryId, resolvedText, {
|
await cmdUpdate(backend, memoryId, resolvedText, {
|
||||||
metadata: opts.metadata,
|
metadata: opts.metadata,
|
||||||
|
expires: opts.expires,
|
||||||
|
timestamp: opts.timestamp,
|
||||||
output,
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -510,6 +556,11 @@ program
|
|||||||
.option("--project", "With --all: delete ALL memories project-wide.", false)
|
.option("--project", "With --all: delete ALL memories project-wide.", false)
|
||||||
.option("--dry-run", "Show what would be deleted without deleting.", false)
|
.option("--dry-run", "Show what would be deleted without deleting.", false)
|
||||||
.option("--force", "Skip confirmation.", false)
|
.option("--force", "Skip confirmation.", false)
|
||||||
|
.option(
|
||||||
|
"--delete-linked",
|
||||||
|
"Also delete memories linked to this memory.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("-u, --user-id <id>", "Scope to user.")
|
.option("-u, --user-id <id>", "Scope to user.")
|
||||||
.option("--agent-id <id>", "Scope to agent.")
|
.option("--agent-id <id>", "Scope to agent.")
|
||||||
.option("--app-id <id>", "Scope to app.")
|
.option("--app-id <id>", "Scope to app.")
|
||||||
@@ -563,6 +614,7 @@ program
|
|||||||
output,
|
output,
|
||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
force: opts.force,
|
force: opts.force,
|
||||||
|
deleteLinked: opts.deleteLinked,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -806,8 +858,8 @@ program
|
|||||||
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
|
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
|
||||||
.action((opts) => {
|
.action((opts) => {
|
||||||
// opts.json is set when `mem0 help --json` is used (subcommand flag).
|
// opts.json is set when `mem0 help --json` is used (subcommand flag).
|
||||||
// program.opts().json is set when the root --json global flag was used first.
|
// program.opts().json/.agent is set when a root global flag was used first.
|
||||||
if (opts.json || program.opts().json) {
|
if (opts.json || program.opts().json || program.opts().agent) {
|
||||||
// Load spec from parent directory
|
// Load spec from parent directory
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
|
const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
|
||||||
|
|||||||
+17
-3
@@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
|
|||||||
}
|
}
|
||||||
case "search":
|
case "search":
|
||||||
return (data as Record<string, unknown>[]).map((r) =>
|
return (data as Record<string, unknown>[]).map((r) =>
|
||||||
pick(r, ["id", "memory", "score", "created_at", "categories"]),
|
pick(r, [
|
||||||
|
"id",
|
||||||
|
"memory",
|
||||||
|
"score",
|
||||||
|
"created_at",
|
||||||
|
"categories",
|
||||||
|
"expiration_date",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
case "list":
|
case "list":
|
||||||
return (data as Record<string, unknown>[]).map((r) =>
|
return (data as Record<string, unknown>[]).map((r) =>
|
||||||
pick(r, ["id", "memory", "created_at", "categories"]),
|
pick(r, [
|
||||||
|
"id",
|
||||||
|
"memory",
|
||||||
|
"created_at",
|
||||||
|
"categories",
|
||||||
|
"expiration_date",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
case "get": {
|
case "get": {
|
||||||
const r = data as Record<string, unknown>;
|
const r = data as Record<string, unknown>;
|
||||||
@@ -297,11 +310,12 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"categories",
|
"categories",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"expiration_date",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
const r = data as Record<string, unknown>;
|
const r = data as Record<string, unknown>;
|
||||||
return pick(r, ["id", "memory"]);
|
return pick(r, ["id", "memory", "expiration_date"]);
|
||||||
}
|
}
|
||||||
case "delete":
|
case "delete":
|
||||||
case "delete-all":
|
case "delete-all":
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* read by commands and branding functions.
|
* read by commands and branding functions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
let _agentMode = false;
|
let _agentMode = false;
|
||||||
let _currentCommand = "";
|
let _currentCommand = "";
|
||||||
let _pendingNotice = "";
|
let _pendingNotice = "";
|
||||||
@@ -38,3 +40,14 @@ export function takeNotice(): string {
|
|||||||
_pendingNotice = "";
|
_pendingNotice = "";
|
||||||
return msg;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,15 +44,25 @@ describe("CLI Integration — help and version", () => {
|
|||||||
expect(result.stdout).toContain("search");
|
expect(result.stdout).toContain("search");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("help --json produces valid JSON", () => {
|
it("prints the version with --version, and has no version subcommand", () => {
|
||||||
const result = run(["help", "--json"]);
|
const flag = run(["--version"]);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(flag.exitCode).toBe(0);
|
||||||
const parsed = JSON.parse(result.stdout);
|
expect(flag.stdout).toContain("Mem0");
|
||||||
// spec may have cli.name or top-level name
|
expect(run(["version"]).exitCode).not.toBe(0);
|
||||||
const name = parsed.name ?? parsed.cli?.name;
|
|
||||||
expect(name).toBe("mem0");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([["help", "--json"], ["--json", "help"], ["--agent", "help"]])(
|
||||||
|
"%s %s produces valid JSON",
|
||||||
|
(...args) => {
|
||||||
|
const result = run(args);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const parsed = JSON.parse(result.stdout);
|
||||||
|
// spec may have cli.name or top-level name
|
||||||
|
const name = parsed.name ?? parsed.cli?.name;
|
||||||
|
expect(name).toBe("mem0");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("shows add help", () => {
|
it("shows add help", () => {
|
||||||
const result = run(["add", "--help"]);
|
const result = run(["add", "--help"]);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
|
|||||||
+580
-373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Drift test: every documented v3 add/search/list param must be reachable from the Node CLI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const OPENAPI_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"docs",
|
||||||
|
"openapi.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
const KNOWN_UNSURFACED: Record<string, Record<string, string>> = {
|
||||||
|
"/v3/memories/add/": {
|
||||||
|
includes: "extraction hint, no CLI flag yet",
|
||||||
|
excludes: "extraction hint, no CLI flag yet",
|
||||||
|
enable_graph: "graph memory toggle, no CLI flag yet",
|
||||||
|
output_format: "response envelope is pinned by the CLI",
|
||||||
|
prompt_profile_id: "no CLI flag yet",
|
||||||
|
temporal_reasoning: "no CLI flag yet",
|
||||||
|
timezone: "no CLI flag yet",
|
||||||
|
observation_datetime: "no CLI flag yet, --timestamp backdates instead",
|
||||||
|
observation_date: "no CLI flag yet, --timestamp backdates instead",
|
||||||
|
},
|
||||||
|
"/v3/memories/search/": {
|
||||||
|
categories: "expressible through --filter",
|
||||||
|
metadata: "expressible through --filter",
|
||||||
|
},
|
||||||
|
"/v3/memories/": {
|
||||||
|
start_date: "covered by --after via filters.created_at.gte",
|
||||||
|
end_date: "covered by --before via filters.created_at.lte",
|
||||||
|
categories: "covered by --category via filters.categories",
|
||||||
|
fields: "no CLI flag yet",
|
||||||
|
keywords: "no CLI flag yet",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADD_MAPPING: Record<string, string[]> = {
|
||||||
|
messages: ["--messages", "--file", "text"],
|
||||||
|
user_id: ["--user-id"],
|
||||||
|
agent_id: ["--agent-id"],
|
||||||
|
app_id: ["--app-id"],
|
||||||
|
run_id: ["--run-id"],
|
||||||
|
metadata: ["--metadata"],
|
||||||
|
expiration_date: ["--expires"],
|
||||||
|
custom_instructions: ["--custom-instructions"],
|
||||||
|
custom_categories: ["--custom-categories"],
|
||||||
|
infer: ["--no-infer"],
|
||||||
|
immutable: ["--immutable"],
|
||||||
|
structured_data_schema: ["--structured-data-schema"],
|
||||||
|
timestamp: ["--timestamp"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_MAPPING: Record<string, string[]> = {
|
||||||
|
query: ["query"],
|
||||||
|
filters: ["--filter", "--user-id", "--agent-id", "--run-id"],
|
||||||
|
show_expired: ["--show-expired"],
|
||||||
|
top_k: ["--top-k"],
|
||||||
|
threshold: ["--threshold"],
|
||||||
|
rerank: ["--rerank"],
|
||||||
|
reference_date: ["--reference-date"],
|
||||||
|
fields: ["--fields"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_MAPPING: Record<string, string[]> = {
|
||||||
|
filters: [
|
||||||
|
"--user-id",
|
||||||
|
"--agent-id",
|
||||||
|
"--run-id",
|
||||||
|
"--category",
|
||||||
|
"--after",
|
||||||
|
"--before",
|
||||||
|
],
|
||||||
|
show_expired: ["--show-expired"],
|
||||||
|
page: ["--page"],
|
||||||
|
page_size: ["--page-size"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function documentedFields(endpoint: string): string[] {
|
||||||
|
const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
||||||
|
const schema =
|
||||||
|
spec.paths[endpoint].post.requestBody.content["application/json"].schema;
|
||||||
|
return Object.keys(schema.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function helpText(command: string): string {
|
||||||
|
return execSync(`npx tsx src/index.ts ${command} --help`, {
|
||||||
|
cwd: path.join(__dirname, ".."),
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAllReachable(
|
||||||
|
endpoint: string,
|
||||||
|
mapping: Record<string, string[]>,
|
||||||
|
command: string,
|
||||||
|
) {
|
||||||
|
const documented = documentedFields(endpoint);
|
||||||
|
const help = helpText(command);
|
||||||
|
for (const field of documented) {
|
||||||
|
if (KNOWN_UNSURFACED[endpoint]?.[field]) continue;
|
||||||
|
const candidates = mapping[field];
|
||||||
|
expect(
|
||||||
|
candidates,
|
||||||
|
`${endpoint}: documented field "${field}" has no mapping entry for command "${command}"`,
|
||||||
|
).toBeDefined();
|
||||||
|
const reachable = candidates.some((flag) =>
|
||||||
|
flag.startsWith("--") ? help.includes(flag) : true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
reachable,
|
||||||
|
`${endpoint}: documented field "${field}" not reachable via any of ${JSON.stringify(candidates)} on command "${command}"`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Option parity: Node CLI reachability of documented v3 params", () => {
|
||||||
|
it("add covers documented fields", () => {
|
||||||
|
assertAllReachable("/v3/memories/add/", ADD_MAPPING, "add");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search covers documented fields", () => {
|
||||||
|
assertAllReachable("/v3/memories/search/", SEARCH_MAPPING, "search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list covers documented fields", () => {
|
||||||
|
assertAllReachable("/v3/memories/", LIST_MAPPING, "list");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stdin fallback uses the shared piped-stdin guard", () => {
|
||||||
|
const SOURCES = ["src/index.ts", "src/commands/memory.ts"];
|
||||||
|
|
||||||
|
for (const rel of SOURCES) {
|
||||||
|
it(`${rel} never checks process.stdin.isTTY directly`, () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8");
|
||||||
|
expect(
|
||||||
|
src.includes("process.stdin.isTTY"),
|
||||||
|
`${rel}: use stdinIsPiped() from state.ts. A bare !isTTY check is also true for /dev/null and sockets, so readFileSync(0) crashes with EAGAIN in scripts, CI, and agent mode.`,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${rel} guards every readFileSync(0) with stdinIsPiped()`, () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8");
|
||||||
|
const lines = src.split("\n");
|
||||||
|
for (const [i, line] of lines.entries()) {
|
||||||
|
if (!line.includes("readFileSync(0")) continue;
|
||||||
|
const guard = lines.slice(Math.max(0, i - 3), i).join("\n");
|
||||||
|
expect(
|
||||||
|
guard.includes("stdinIsPiped()"),
|
||||||
|
`${rel}:${i + 1}: readFileSync(0) must be guarded by stdinIsPiped()`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -7,94 +7,231 @@ import { PlatformBackend } from "../src/backend/platform.js";
|
|||||||
import { createDefaultConfig } from "../src/config.js";
|
import { createDefaultConfig } from "../src/config.js";
|
||||||
|
|
||||||
function makeBackend(): PlatformBackend {
|
function makeBackend(): PlatformBackend {
|
||||||
// apiKey/baseUrl only build request headers; every test spies on _request,
|
return new PlatformBackend(createDefaultConfig().platform);
|
||||||
// so no real network calls are made.
|
|
||||||
return new PlatformBackend(createDefaultConfig().platform);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockFetch() {
|
function mockFetch() {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { get: vi.fn().mockReturnValue(null) },
|
headers: { get: vi.fn().mockReturnValue(null) },
|
||||||
json: vi.fn().mockResolvedValue({ message: "ok" }),
|
json: vi.fn().mockResolvedValue({ message: "ok" }),
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
return fetchMock;
|
return fetchMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteEntities", () => {
|
describe("deleteEntities", () => {
|
||||||
it("returns all results keyed by entity type for a multi-entity delete", async () => {
|
it("returns all results keyed by entity type for a multi-entity delete", async () => {
|
||||||
const backend = makeBackend();
|
const backend = makeBackend();
|
||||||
const responses: Record<string, unknown> = {
|
const responses: Record<string, unknown> = {
|
||||||
"/v2/entities/user/alice/": { message: "user deleted" },
|
"/v2/entities/user/alice/": { message: "user deleted" },
|
||||||
"/v2/entities/agent/bob/": { message: "agent deleted" },
|
"/v2/entities/agent/bob/": { message: "agent deleted" },
|
||||||
};
|
};
|
||||||
const spy = vi
|
const spy = vi
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
|
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
|
||||||
.spyOn(backend as any, "_request")
|
.spyOn(backend as any, "_request")
|
||||||
.mockImplementation(async (_method: string, path: string) => responses[path]);
|
.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({
|
||||||
expect(result).toEqual({
|
user: { message: "user deleted" },
|
||||||
user: { message: "user deleted" },
|
agent: { message: "agent deleted" },
|
||||||
agent: { message: "agent deleted" },
|
});
|
||||||
});
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("keys a single-entity delete by its type", async () => {
|
it("keys a single-entity delete by its type", async () => {
|
||||||
const backend = makeBackend();
|
const backend = makeBackend();
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
|
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
|
||||||
vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" });
|
vi.spyOn(backend as any, "_request").mockResolvedValue({
|
||||||
|
message: "user deleted",
|
||||||
|
});
|
||||||
|
|
||||||
const result = await backend.deleteEntities({ userId: "alice" });
|
const result = await backend.deleteEntities({ userId: "alice" });
|
||||||
expect(result).toEqual({ user: { message: "user deleted" } });
|
expect(result).toEqual({ user: { message: "user deleted" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when no entity id is provided", async () => {
|
it("throws when no entity id is provided", async () => {
|
||||||
const backend = makeBackend();
|
const backend = makeBackend();
|
||||||
await expect(backend.deleteEntities({})).rejects.toThrow(
|
await expect(backend.deleteEntities({})).rejects.toThrow(
|
||||||
"At least one entity ID is required",
|
"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", () => {
|
describe("PlatformBackend path encoding", () => {
|
||||||
it("encodes memory IDs before interpolating them into paths", async () => {
|
it("encodes memory IDs before interpolating them into paths", async () => {
|
||||||
const fetchMock = mockFetch();
|
const fetchMock = mockFetch();
|
||||||
const backend = makeBackend();
|
const backend = makeBackend();
|
||||||
|
|
||||||
await backend.get("mem/a?b#c");
|
await backend.get("mem/a?b#c");
|
||||||
await backend.update("mem/a?b#c", "updated");
|
await backend.update("mem/a?b#c", "updated");
|
||||||
await backend.delete("mem/a?b#c");
|
await backend.delete("mem/a?b#c");
|
||||||
|
|
||||||
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
||||||
expect(urls).toEqual([
|
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/?source=CLI",
|
||||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
|
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
|
||||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
|
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes entity and event IDs before interpolating them into paths", async () => {
|
it("encodes entity and event IDs before interpolating them into paths", async () => {
|
||||||
const fetchMock = mockFetch();
|
const fetchMock = mockFetch();
|
||||||
const backend = makeBackend();
|
const backend = makeBackend();
|
||||||
|
|
||||||
await backend.deleteEntities({ userId: "org/team?active#frag" });
|
await backend.deleteEntities({ userId: "org/team?active#frag" });
|
||||||
await backend.getEvent("evt/a?b#c");
|
await backend.getEvent("evt/a?b#c");
|
||||||
|
|
||||||
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
||||||
expect(urls).toEqual([
|
expect(urls).toEqual([
|
||||||
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
|
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
|
||||||
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
|
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -241,14 +241,6 @@ Verify your API connection and display the current project.
|
|||||||
mem0 status
|
mem0 status
|
||||||
```
|
```
|
||||||
|
|
||||||
### `mem0 version`
|
|
||||||
|
|
||||||
Print the CLI version.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mem0 version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Agent mode
|
## Agent mode
|
||||||
|
|
||||||
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
|
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
|
||||||
@@ -307,6 +299,8 @@ These flags are available on all commands:
|
|||||||
| `--base-url` | Override the configured API base URL for this request |
|
| `--base-url` | Override the configured API base URL for this request |
|
||||||
| `-o, --output` | Set the output format |
|
| `-o, --output` | Set the output format |
|
||||||
|
|
||||||
|
`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one.
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ mem0 --help
|
|||||||
# Using Python directly (with venv activated)
|
# Using Python directly (with venv activated)
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
mem0 --help
|
mem0 --help
|
||||||
mem0 version
|
mem0 --version
|
||||||
|
|
||||||
# Or run without activating
|
# Or run without activating
|
||||||
.venv/bin/mem0 --help
|
.venv/bin/mem0 --help
|
||||||
|
|||||||
@@ -273,7 +273,21 @@ def add(
|
|||||||
no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."),
|
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)."),
|
expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."),
|
||||||
categories: str | None = typer.Option(
|
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(
|
output: str = typer.Option(
|
||||||
"text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
|
"text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
|
||||||
@@ -312,6 +326,10 @@ def add(
|
|||||||
no_infer=no_infer,
|
no_infer=no_infer,
|
||||||
expires=expires,
|
expires=expires,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
|
custom_instructions=custom_instructions,
|
||||||
|
custom_categories=custom_categories,
|
||||||
|
structured_data_schema=structured_data_schema,
|
||||||
|
timestamp=timestamp,
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -355,6 +373,21 @@ def search(
|
|||||||
help="Specific fields to return (comma-separated).",
|
help="Specific fields to return (comma-separated).",
|
||||||
rich_help_panel="Search",
|
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(
|
output: str = typer.Option(
|
||||||
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
||||||
),
|
),
|
||||||
@@ -398,6 +431,9 @@ def search(
|
|||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
filter_json=filter_json,
|
filter_json=filter_json,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
|
show_expired=show_expired,
|
||||||
|
reference_date=reference_date,
|
||||||
|
latest_only=latest_only,
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -464,6 +500,15 @@ def list_cmd(
|
|||||||
before: str | None = typer.Option(
|
before: str | None = typer.Option(
|
||||||
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
|
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(
|
output: str = typer.Option(
|
||||||
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
||||||
),
|
),
|
||||||
@@ -497,6 +542,8 @@ def list_cmd(
|
|||||||
category=category,
|
category=category,
|
||||||
after=after,
|
after=after,
|
||||||
before=before,
|
before=before,
|
||||||
|
show_expired=show_expired,
|
||||||
|
latest_only=latest_only,
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -509,6 +556,10 @@ def update(
|
|||||||
memory_id: str = typer.Argument(..., help="Memory ID to update."),
|
memory_id: str = typer.Argument(..., help="Memory ID to update."),
|
||||||
text: str | None = typer.Argument(None, help="New memory text."),
|
text: str | None = typer.Argument(None, help="New memory text."),
|
||||||
metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."),
|
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(
|
output: str = typer.Option(
|
||||||
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
|
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
|
||||||
),
|
),
|
||||||
@@ -537,7 +588,15 @@ def update(
|
|||||||
text = _read_stdin()
|
text = _read_stdin()
|
||||||
|
|
||||||
backend = _get_backend(api_key, base_url)
|
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 ────────────────────────────────────────────────────────
|
# ── Memory: delete ────────────────────────────────────────────────────────
|
||||||
@@ -559,6 +618,9 @@ def delete(
|
|||||||
False, "--dry-run", help="Show what would be deleted without deleting."
|
False, "--dry-run", help="Show what would be deleted without deleting."
|
||||||
),
|
),
|
||||||
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
|
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(
|
user_id: str | None = typer.Option(
|
||||||
None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope"
|
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
|
from mem0_cli.commands.memory import cmd_delete
|
||||||
|
|
||||||
backend = _get_backend(api_key, base_url)
|
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_:
|
elif all_:
|
||||||
_fire_telemetry("delete", {"delete_mode": "all"})
|
_fire_telemetry("delete", {"delete_mode": "all"})
|
||||||
@@ -1068,7 +1137,11 @@ def _build_help_json() -> dict:
|
|||||||
"--immutable": "Prevent future updates.",
|
"--immutable": "Prevent future updates.",
|
||||||
"--no-infer": "Skip inference, store raw.",
|
"--no-infer": "Skip inference, store raw.",
|
||||||
"--expires": "Expiration date (YYYY-MM-DD).",
|
"--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.",
|
"--graph": "Enable graph memory extraction.",
|
||||||
"--no-graph": "Disable graph memory extraction.",
|
"--no-graph": "Disable graph memory extraction.",
|
||||||
"--output, -o": "Output format: text, json, quiet.",
|
"--output, -o": "Output format: text, json, quiet.",
|
||||||
@@ -1087,6 +1160,9 @@ def _build_help_json() -> dict:
|
|||||||
"--keyword": "Use keyword search instead of semantic.",
|
"--keyword": "Use keyword search instead of semantic.",
|
||||||
"--filter": "Advanced filter expression (JSON).",
|
"--filter": "Advanced filter expression (JSON).",
|
||||||
"--fields": "Specific fields to return (comma-separated).",
|
"--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.",
|
"--graph": "Enable graph in search.",
|
||||||
"--no-graph": "Disable graph in search.",
|
"--no-graph": "Disable graph in search.",
|
||||||
"--output, -o": "Output format: text, json, table.",
|
"--output, -o": "Output format: text, json, table.",
|
||||||
@@ -1110,6 +1186,8 @@ def _build_help_json() -> dict:
|
|||||||
"--category": "Filter by category.",
|
"--category": "Filter by category.",
|
||||||
"--after": "Created after (YYYY-MM-DD).",
|
"--after": "Created after (YYYY-MM-DD).",
|
||||||
"--before": "Created before (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.",
|
"--graph": "Enable graph in listing.",
|
||||||
"--no-graph": "Disable graph in listing.",
|
"--no-graph": "Disable graph in listing.",
|
||||||
"--output, -o": "Output format: text, json, table.",
|
"--output, -o": "Output format: text, json, table.",
|
||||||
@@ -1124,6 +1202,8 @@ def _build_help_json() -> dict:
|
|||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"--metadata, -m": "Update metadata (JSON).",
|
"--metadata, -m": "Update metadata (JSON).",
|
||||||
|
"--expires": "Expiration date (YYYY-MM-DD).",
|
||||||
|
"--timestamp": "Unix timestamp for the memory.",
|
||||||
"--output, -o": "Output format: text, json, quiet.",
|
"--output, -o": "Output format: text, json, quiet.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1140,6 +1220,7 @@ def _build_help_json() -> dict:
|
|||||||
"--all": "Delete all memories matching scope filters.",
|
"--all": "Delete all memories matching scope filters.",
|
||||||
"--entity": "Delete the entity itself and all its memories (cascade).",
|
"--entity": "Delete the entity itself and all its memories (cascade).",
|
||||||
"--project": "With --all: delete ALL memories project-wide.",
|
"--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.",
|
"--dry-run": "Show what would be deleted without deleting.",
|
||||||
"--force": "Skip confirmation.",
|
"--force": "Skip confirmation.",
|
||||||
"--user-id, -u": "Scope to user.",
|
"--user-id, -u": "Scope to user.",
|
||||||
@@ -1269,8 +1350,10 @@ def help(
|
|||||||
mem0 help
|
mem0 help
|
||||||
mem0 help --json
|
mem0 help --json
|
||||||
"""
|
"""
|
||||||
if json:
|
from mem0_cli.state import is_agent_mode
|
||||||
console.print(_json.dumps(_build_help_json(), indent=2))
|
|
||||||
|
if json or is_agent_mode():
|
||||||
|
console.print_json(_json.dumps(_build_help_json()))
|
||||||
else:
|
else:
|
||||||
console.print(
|
console.print(
|
||||||
f"[{BRAND_COLOR}]◆ mem0 CLI[/] v{__version__} — The Memory Layer for AI Agents\n"
|
f"[{BRAND_COLOR}]◆ mem0 CLI[/] v{__version__} — The Memory Layer for AI Agents\n"
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ class Backend(ABC):
|
|||||||
immutable: bool = False,
|
immutable: bool = False,
|
||||||
infer: bool = True,
|
infer: bool = True,
|
||||||
expires: str | None = None,
|
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: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -43,6 +46,9 @@ class Backend(ABC):
|
|||||||
keyword: bool = False,
|
keyword: bool = False,
|
||||||
filters: dict | None = None,
|
filters: dict | None = None,
|
||||||
fields: list[str] | None = None,
|
fields: list[str] | None = None,
|
||||||
|
show_expired: bool = False,
|
||||||
|
reference_date: str | None = None,
|
||||||
|
latest_only: bool = False,
|
||||||
) -> list[dict]: ...
|
) -> list[dict]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -61,11 +67,19 @@ class Backend(ABC):
|
|||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
after: str | None = None,
|
after: str | None = None,
|
||||||
before: str | None = None,
|
before: str | None = None,
|
||||||
|
show_expired: bool = False,
|
||||||
|
latest_only: bool = False,
|
||||||
) -> list[dict]: ...
|
) -> list[dict]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def update(
|
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: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -78,6 +92,7 @@ class Backend(ABC):
|
|||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
app_id: str | None = None,
|
app_id: str | None = None,
|
||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
|
delete_linked: bool = False,
|
||||||
) -> dict: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ class PlatformBackend(Backend):
|
|||||||
immutable: bool = False,
|
immutable: bool = False,
|
||||||
infer: bool = True,
|
infer: bool = True,
|
||||||
expires: str | None = None,
|
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:
|
) -> dict:
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -112,8 +115,14 @@ class PlatformBackend(Backend):
|
|||||||
payload["infer"] = False
|
payload["infer"] = False
|
||||||
if expires:
|
if expires:
|
||||||
payload["expiration_date"] = expires
|
payload["expiration_date"] = expires
|
||||||
if categories:
|
if custom_instructions:
|
||||||
payload["categories"] = categories
|
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"
|
payload["source"] = "CLI"
|
||||||
|
|
||||||
return self._request("POST", "/v3/memories/add/", json=payload)
|
return self._request("POST", "/v3/memories/add/", json=payload)
|
||||||
@@ -173,6 +182,9 @@ class PlatformBackend(Backend):
|
|||||||
keyword: bool = False,
|
keyword: bool = False,
|
||||||
filters: dict | None = None,
|
filters: dict | None = None,
|
||||||
fields: list[str] | None = None,
|
fields: list[str] | None = None,
|
||||||
|
show_expired: bool = False,
|
||||||
|
reference_date: str | None = None,
|
||||||
|
latest_only: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
|
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
|
||||||
|
|
||||||
@@ -191,6 +203,12 @@ class PlatformBackend(Backend):
|
|||||||
payload["keyword_search"] = True
|
payload["keyword_search"] = True
|
||||||
if fields:
|
if fields:
|
||||||
payload["fields"] = 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"
|
payload["source"] = "CLI"
|
||||||
|
|
||||||
result = self._request("POST", "/v3/memories/search/", json=payload)
|
result = self._request("POST", "/v3/memories/search/", json=payload)
|
||||||
@@ -219,6 +237,8 @@ class PlatformBackend(Backend):
|
|||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
after: str | None = None,
|
after: str | None = None,
|
||||||
before: str | None = None,
|
before: str | None = None,
|
||||||
|
show_expired: bool = False,
|
||||||
|
latest_only: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
params = {"page": str(page), "page_size": str(page_size)}
|
params = {"page": str(page), "page_size": str(page_size)}
|
||||||
@@ -241,6 +261,10 @@ class PlatformBackend(Backend):
|
|||||||
)
|
)
|
||||||
if api_filters:
|
if api_filters:
|
||||||
payload["filters"] = api_filters
|
payload["filters"] = api_filters
|
||||||
|
if show_expired:
|
||||||
|
payload["show_expired"] = True
|
||||||
|
if latest_only:
|
||||||
|
payload["latest_only"] = True
|
||||||
payload["source"] = "CLI"
|
payload["source"] = "CLI"
|
||||||
|
|
||||||
result = self._request("POST", "/v3/memories/", json=payload, params=params)
|
result = self._request("POST", "/v3/memories/", json=payload, params=params)
|
||||||
@@ -251,13 +275,23 @@ class PlatformBackend(Backend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update(
|
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:
|
) -> dict:
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
if content:
|
if content:
|
||||||
payload["text"] = content
|
payload["text"] = content
|
||||||
if metadata:
|
if metadata:
|
||||||
payload["metadata"] = metadata
|
payload["metadata"] = metadata
|
||||||
|
if expiration_date:
|
||||||
|
payload["expiration_date"] = expiration_date
|
||||||
|
if timestamp is not None:
|
||||||
|
payload["timestamp"] = timestamp
|
||||||
payload["source"] = "CLI"
|
payload["source"] = "CLI"
|
||||||
return self._request(
|
return self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -274,6 +308,7 @@ class PlatformBackend(Backend):
|
|||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
app_id: str | None = None,
|
app_id: str | None = None,
|
||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
|
delete_linked: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if all:
|
if all:
|
||||||
params: dict[str, str] = {"source": "CLI"}
|
params: dict[str, str] = {"source": "CLI"}
|
||||||
@@ -287,10 +322,13 @@ class PlatformBackend(Backend):
|
|||||||
params["run_id"] = run_id
|
params["run_id"] = run_id
|
||||||
return self._request("DELETE", "/v1/memories/", params=params)
|
return self._request("DELETE", "/v1/memories/", params=params)
|
||||||
elif memory_id:
|
elif memory_id:
|
||||||
|
params = {"source": "CLI"}
|
||||||
|
if delete_linked:
|
||||||
|
params["delete_linked"] = "true"
|
||||||
return self._request(
|
return self._request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
f"/v1/memories/{_encode_path_segment(memory_id)}/",
|
f"/v1/memories/{_encode_path_segment(memory_id)}/",
|
||||||
params={"source": "CLI"},
|
params=params,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Either memory_id or --all is required")
|
raise ValueError("Either memory_id or --all is required")
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import stat as _stat_mod
|
import stat as _stat_mod
|
||||||
import sys
|
import sys
|
||||||
import time as _time
|
import time as _time
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -47,6 +49,18 @@ def _stdin_is_piped() -> bool:
|
|||||||
return False
|
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(
|
def cmd_add(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
text: str | None,
|
text: str | None,
|
||||||
@@ -62,6 +76,10 @@ def cmd_add(
|
|||||||
no_infer: bool,
|
no_infer: bool,
|
||||||
expires: str | None,
|
expires: str | None,
|
||||||
categories: 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",
|
output: str = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a memory."""
|
"""Add a memory."""
|
||||||
@@ -70,6 +88,13 @@ def cmd_add(
|
|||||||
set_current_command("add")
|
set_current_command("add")
|
||||||
if is_agent_mode():
|
if is_agent_mode():
|
||||||
output = "agent"
|
output = "agent"
|
||||||
|
|
||||||
|
if categories:
|
||||||
|
print_error(
|
||||||
|
err_console, "--categories is not supported on add. Use --custom-categories instead."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
msgs = None
|
msgs = None
|
||||||
content = text
|
content = text
|
||||||
|
|
||||||
@@ -108,27 +133,24 @@ def cmd_add(
|
|||||||
print_error(err_console, "Invalid JSON in --metadata.")
|
print_error(err_console, "Invalid JSON in --metadata.")
|
||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
cats = None
|
custom_cats = None
|
||||||
if categories:
|
if custom_categories:
|
||||||
try:
|
try:
|
||||||
cats = json.loads(categories)
|
custom_cats = json.loads(custom_categories)
|
||||||
except json.JSONDecodeError:
|
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:
|
if expires:
|
||||||
import re
|
_validate_expires(expires)
|
||||||
|
|
||||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
|
|
||||||
print_error(
|
|
||||||
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
if date.fromisoformat(expires) <= date.today():
|
|
||||||
print_error(err_console, "--expires date must be in the future.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
with timed_status(err_console, "Adding memory...") as ts:
|
with timed_status(err_console, "Adding memory...") as ts:
|
||||||
try:
|
try:
|
||||||
@@ -143,7 +165,10 @@ def cmd_add(
|
|||||||
immutable=immutable,
|
immutable=immutable,
|
||||||
infer=not no_infer,
|
infer=not no_infer,
|
||||||
expires=expires,
|
expires=expires,
|
||||||
categories=cats,
|
custom_instructions=custom_instructions,
|
||||||
|
custom_categories=custom_cats,
|
||||||
|
structured_data_schema=schema,
|
||||||
|
timestamp=timestamp,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ts.error_msg = str(e)
|
ts.error_msg = str(e)
|
||||||
@@ -224,6 +249,9 @@ def cmd_search(
|
|||||||
keyword: bool,
|
keyword: bool,
|
||||||
filter_json: str | None,
|
filter_json: str | None,
|
||||||
fields: str | None,
|
fields: str | None,
|
||||||
|
show_expired: bool = False,
|
||||||
|
reference_date: str | None = None,
|
||||||
|
latest_only: bool = False,
|
||||||
output: str = "text",
|
output: str = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Search memories."""
|
"""Search memories."""
|
||||||
@@ -266,6 +294,9 @@ def cmd_search(
|
|||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
fields=field_list,
|
fields=field_list,
|
||||||
|
show_expired=show_expired,
|
||||||
|
reference_date=reference_date,
|
||||||
|
latest_only=latest_only,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
@@ -352,6 +383,8 @@ def cmd_list(
|
|||||||
category: str | None,
|
category: str | None,
|
||||||
after: str | None,
|
after: str | None,
|
||||||
before: str | None,
|
before: str | None,
|
||||||
|
show_expired: bool = False,
|
||||||
|
latest_only: bool = False,
|
||||||
output: str = "table",
|
output: str = "table",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""List memories."""
|
"""List memories."""
|
||||||
@@ -380,6 +413,8 @@ def cmd_list(
|
|||||||
category=category,
|
category=category,
|
||||||
after=after,
|
after=after,
|
||||||
before=before,
|
before=before,
|
||||||
|
show_expired=show_expired,
|
||||||
|
latest_only=latest_only,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
@@ -446,6 +481,8 @@ def cmd_update(
|
|||||||
text: str | None,
|
text: str | None,
|
||||||
*,
|
*,
|
||||||
metadata: str | None,
|
metadata: str | None,
|
||||||
|
expires: str | None = None,
|
||||||
|
timestamp: int | None = None,
|
||||||
output: str,
|
output: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update a memory."""
|
"""Update a memory."""
|
||||||
@@ -462,10 +499,19 @@ def cmd_update(
|
|||||||
print_error(err_console, "Invalid JSON in --metadata.")
|
print_error(err_console, "Invalid JSON in --metadata.")
|
||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
if expires:
|
||||||
|
_validate_expires(expires)
|
||||||
|
|
||||||
_start = _time.perf_counter()
|
_start = _time.perf_counter()
|
||||||
with timed_status(err_console, "Updating memory...") as _ts:
|
with timed_status(err_console, "Updating memory...") as _ts:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
@@ -490,6 +536,7 @@ def cmd_delete(
|
|||||||
*,
|
*,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
delete_linked: bool = False,
|
||||||
output: str,
|
output: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a single memory by ID."""
|
"""Delete a single memory by ID."""
|
||||||
@@ -512,7 +559,7 @@ def cmd_delete(
|
|||||||
_start = _time.perf_counter()
|
_start = _time.perf_counter()
|
||||||
with timed_status(err_console, "Deleting...") as _ts:
|
with timed_status(err_console, "Deleting...") as _ts:
|
||||||
try:
|
try:
|
||||||
result = backend.delete(memory_id=memory_id)
|
result = backend.delete(memory_id=memory_id, delete_linked=delete_linked)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
|
|||||||
@@ -262,16 +262,32 @@ def sanitize_agent_data(command: str, data: Any) -> Any:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
if command == "search":
|
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":
|
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":
|
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":
|
if command == "update":
|
||||||
return pick(data, ["id", "memory"])
|
return pick(data, ["id", "memory", "expiration_date"])
|
||||||
|
|
||||||
if command in ("delete", "delete-all", "entity delete"):
|
if command in ("delete", "delete-all", "entity delete"):
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ boundaries).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -84,6 +85,31 @@ class TestCLIIntegration:
|
|||||||
assert "add" in result.stdout
|
assert "add" in result.stdout
|
||||||
assert "search" in result.stdout
|
assert "search" in result.stdout
|
||||||
|
|
||||||
|
def test_version_flag_only(self):
|
||||||
|
from mem0_cli import __version__
|
||||||
|
|
||||||
|
flag = _run(["--version"])
|
||||||
|
assert flag.returncode == 0
|
||||||
|
assert __version__ in flag.stdout
|
||||||
|
assert _run(["version"]).returncode != 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"args",
|
||||||
|
[["help", "--json"], ["--json", "help"], ["help", "--agent"], ["--agent", "help"]],
|
||||||
|
)
|
||||||
|
def test_help_json_produces_valid_json(self, args):
|
||||||
|
result = _run(args)
|
||||||
|
assert result.returncode == 0
|
||||||
|
spec = json.loads(result.stdout)
|
||||||
|
assert spec["name"] == "mem0"
|
||||||
|
assert "add" in spec["commands"]
|
||||||
|
|
||||||
|
def test_help_without_json_is_text(self):
|
||||||
|
result = _run(["help"])
|
||||||
|
assert result.returncode == 0
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
json.loads(result.stdout)
|
||||||
|
|
||||||
def test_add_help(self):
|
def test_add_help(self):
|
||||||
result = _run(["add", "--help"])
|
result = _run(["add", "--help"])
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
|
|||||||
@@ -252,12 +252,13 @@ class TestAddCommand:
|
|||||||
)
|
)
|
||||||
mock_backend.add.assert_called_once()
|
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()
|
console, _buf = _make_console()
|
||||||
err_console, _err_buf = _make_err_console()
|
err_console, err_buf = _make_err_console()
|
||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
):
|
):
|
||||||
cmd_add(
|
cmd_add(
|
||||||
mock_backend,
|
mock_backend,
|
||||||
@@ -275,7 +276,93 @@ class TestAddCommand:
|
|||||||
categories="health,prefs",
|
categories="health,prefs",
|
||||||
output="text",
|
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:
|
class TestAddDeduplicatesPending:
|
||||||
@@ -464,6 +551,36 @@ class TestSearchCommand:
|
|||||||
)
|
)
|
||||||
mock_backend.search.assert_called_once()
|
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:
|
class TestGetCommand:
|
||||||
def test_get_text(self, mock_backend):
|
def test_get_text(self, mock_backend):
|
||||||
@@ -561,6 +678,32 @@ class TestListCommand:
|
|||||||
output = buf.getvalue()
|
output = buf.getvalue()
|
||||||
assert "No memories found" in output
|
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:
|
class TestUpdateCommand:
|
||||||
def test_update(self, mock_backend):
|
def test_update(self, mock_backend):
|
||||||
@@ -585,6 +728,26 @@ class TestUpdateCommand:
|
|||||||
output = buf.getvalue()
|
output = buf.getvalue()
|
||||||
assert '"memory"' in output
|
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:
|
class TestDeleteCommand:
|
||||||
def test_delete_single(self, mock_backend):
|
def test_delete_single(self, mock_backend):
|
||||||
@@ -610,6 +773,17 @@ class TestDeleteCommand:
|
|||||||
assert "dry run" in output.lower()
|
assert "dry run" in output.lower()
|
||||||
mock_backend.delete.assert_not_called()
|
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:
|
class TestDeleteAllCommand:
|
||||||
def test_delete_all_force(self, mock_backend):
|
def test_delete_all_force(self, mock_backend):
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Drift test: every documented v3 add/search/list param must be reachable from the Python CLI."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer.main
|
||||||
|
|
||||||
|
from mem0_cli.app import app
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json"
|
||||||
|
|
||||||
|
KNOWN_UNSURFACED: dict[tuple[str, str], str] = {
|
||||||
|
("/v3/memories/add/", "includes"): "extraction hint, no CLI flag yet",
|
||||||
|
("/v3/memories/add/", "excludes"): "extraction hint, no CLI flag yet",
|
||||||
|
("/v3/memories/add/", "enable_graph"): "graph memory toggle, no CLI flag yet",
|
||||||
|
("/v3/memories/add/", "output_format"): "response envelope is pinned by the CLI",
|
||||||
|
("/v3/memories/add/", "prompt_profile_id"): "no CLI flag yet",
|
||||||
|
("/v3/memories/add/", "temporal_reasoning"): "no CLI flag yet",
|
||||||
|
("/v3/memories/add/", "timezone"): "no CLI flag yet",
|
||||||
|
("/v3/memories/add/", "observation_datetime"): "no CLI flag yet, --timestamp backdates instead",
|
||||||
|
("/v3/memories/add/", "observation_date"): "no CLI flag yet, --timestamp backdates instead",
|
||||||
|
("/v3/memories/search/", "categories"): "expressible through --filter",
|
||||||
|
("/v3/memories/search/", "metadata"): "expressible through --filter",
|
||||||
|
("/v3/memories/", "start_date"): "covered by --after via filters.created_at.gte",
|
||||||
|
("/v3/memories/", "end_date"): "covered by --before via filters.created_at.lte",
|
||||||
|
("/v3/memories/", "categories"): "covered by --category via filters.categories",
|
||||||
|
("/v3/memories/", "fields"): "no CLI flag yet",
|
||||||
|
("/v3/memories/", "keywords"): "no CLI flag yet",
|
||||||
|
}
|
||||||
|
|
||||||
|
ADD_MAPPING: dict[str, list[str]] = {
|
||||||
|
"messages": ["messages", "file", "text"],
|
||||||
|
"user_id": ["user_id"],
|
||||||
|
"agent_id": ["agent_id"],
|
||||||
|
"app_id": ["app_id"],
|
||||||
|
"run_id": ["run_id"],
|
||||||
|
"metadata": ["metadata"],
|
||||||
|
"expiration_date": ["expires"],
|
||||||
|
"custom_instructions": ["custom_instructions"],
|
||||||
|
"custom_categories": ["custom_categories"],
|
||||||
|
"infer": ["no_infer"],
|
||||||
|
"immutable": ["immutable"],
|
||||||
|
"structured_data_schema": ["structured_data_schema"],
|
||||||
|
"timestamp": ["timestamp"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SEARCH_MAPPING: dict[str, list[str]] = {
|
||||||
|
"query": ["query"],
|
||||||
|
"filters": ["filter_json", "user_id", "agent_id", "run_id"],
|
||||||
|
"show_expired": ["show_expired"],
|
||||||
|
"top_k": ["top_k"],
|
||||||
|
"threshold": ["threshold"],
|
||||||
|
"rerank": ["rerank"],
|
||||||
|
"reference_date": ["reference_date"],
|
||||||
|
"fields": ["fields"],
|
||||||
|
}
|
||||||
|
|
||||||
|
LIST_MAPPING: dict[str, list[str]] = {
|
||||||
|
"filters": ["user_id", "agent_id", "run_id", "category", "after", "before"],
|
||||||
|
"show_expired": ["show_expired"],
|
||||||
|
"page": ["page"],
|
||||||
|
"page_size": ["page_size"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _documented_fields(endpoint: str) -> set[str]:
|
||||||
|
spec = json.loads(OPENAPI_PATH.read_text())
|
||||||
|
schema = spec["paths"][endpoint]["post"]["requestBody"]["content"]["application/json"]["schema"]
|
||||||
|
return set(schema["properties"])
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_param_names(command_name: str) -> set[str]:
|
||||||
|
click_app = typer.main.get_command(app)
|
||||||
|
command = click_app.commands[command_name]
|
||||||
|
return {param.name for param in command.params}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_all_reachable(endpoint: str, mapping: dict[str, list[str]], command_name: str) -> None:
|
||||||
|
documented = _documented_fields(endpoint)
|
||||||
|
reachable = _cli_param_names(command_name)
|
||||||
|
for field in documented:
|
||||||
|
if (endpoint, field) in KNOWN_UNSURFACED:
|
||||||
|
continue
|
||||||
|
candidates = mapping.get(field)
|
||||||
|
assert candidates, (
|
||||||
|
f"{endpoint}: documented field {field!r} has no mapping entry for command {command_name!r}"
|
||||||
|
)
|
||||||
|
assert any(candidate in reachable for candidate in candidates), (
|
||||||
|
f"{endpoint}: documented field {field!r} not reachable via any of {candidates} on command {command_name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionParity:
|
||||||
|
def test_add_covers_documented_fields(self):
|
||||||
|
_assert_all_reachable("/v3/memories/add/", ADD_MAPPING, "add")
|
||||||
|
|
||||||
|
def test_search_covers_documented_fields(self):
|
||||||
|
_assert_all_reachable("/v3/memories/search/", SEARCH_MAPPING, "search")
|
||||||
|
|
||||||
|
def test_list_covers_documented_fields(self):
|
||||||
|
_assert_all_reachable("/v3/memories/", LIST_MAPPING, "list")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests that the MEM-5893 option-parity flags reach the correct request payload/params."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from mem0_cli.backend.platform import PlatformBackend
|
||||||
|
from mem0_cli.config import PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _make_backend() -> PlatformBackend:
|
||||||
|
return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddOptions:
|
||||||
|
def test_new_fields_and_existing_fields_land_in_payload_together(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value={"results": []}) as mock_request:
|
||||||
|
backend.add(
|
||||||
|
content="hello",
|
||||||
|
user_id="alice",
|
||||||
|
metadata={"source": "test"},
|
||||||
|
expires="2099-01-01",
|
||||||
|
custom_instructions="Extract only preferences.",
|
||||||
|
custom_categories=[{"prefs": "user preferences"}],
|
||||||
|
structured_data_schema={"type": "object"},
|
||||||
|
timestamp=1700000000,
|
||||||
|
)
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert payload["custom_instructions"] == "Extract only preferences."
|
||||||
|
assert payload["custom_categories"] == [{"prefs": "user preferences"}]
|
||||||
|
assert payload["structured_data_schema"] == {"type": "object"}
|
||||||
|
assert payload["timestamp"] == 1700000000
|
||||||
|
assert payload["metadata"] == {"source": "test"}
|
||||||
|
assert payload["expiration_date"] == "2099-01-01"
|
||||||
|
|
||||||
|
def test_omitted_fields_are_absent_from_payload(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value={"results": []}) as mock_request:
|
||||||
|
backend.add(content="hello", user_id="alice")
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert "custom_instructions" not in payload
|
||||||
|
assert "custom_categories" not in payload
|
||||||
|
assert "structured_data_schema" not in payload
|
||||||
|
assert "timestamp" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchOptions:
|
||||||
|
def test_show_expired_reference_date_latest_only_reach_payload(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value=[]) as mock_request:
|
||||||
|
backend.search(
|
||||||
|
"query",
|
||||||
|
show_expired=True,
|
||||||
|
reference_date="2024-01-01",
|
||||||
|
latest_only=True,
|
||||||
|
)
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert payload["show_expired"] is True
|
||||||
|
assert payload["reference_date"] == "2024-01-01"
|
||||||
|
assert payload["latest_only"] is True
|
||||||
|
|
||||||
|
def test_keyword_and_fields_reach_payload(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value=[]) as mock_request:
|
||||||
|
backend.search("query", keyword=True, fields=["memory", "score"])
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert payload["keyword_search"] is True
|
||||||
|
assert payload["fields"] == ["memory", "score"]
|
||||||
|
|
||||||
|
def test_keyword_and_fields_omitted_are_absent_from_payload(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value=[]) as mock_request:
|
||||||
|
backend.search("query")
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert "keyword_search" not in payload
|
||||||
|
assert "fields" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestListOptions:
|
||||||
|
def test_show_expired_and_latest_only_are_top_level_not_in_filters(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value=[]) as mock_request:
|
||||||
|
backend.list_memories(user_id="alice", show_expired=True, latest_only=True)
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert payload["show_expired"] is True
|
||||||
|
assert payload["latest_only"] is True
|
||||||
|
assert "show_expired" not in payload.get("filters", {})
|
||||||
|
assert "latest_only" not in payload.get("filters", {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateOptions:
|
||||||
|
def test_expires_and_timestamp_reach_payload(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value={}) as mock_request:
|
||||||
|
backend.update("mem-123", expiration_date="2099-01-01", timestamp=1700000000)
|
||||||
|
payload = mock_request.call_args.kwargs["json"]
|
||||||
|
assert payload["expiration_date"] == "2099-01-01"
|
||||||
|
assert payload["timestamp"] == 1700000000
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteOptions:
|
||||||
|
def test_delete_linked_is_a_query_param_not_json_body(self):
|
||||||
|
backend = _make_backend()
|
||||||
|
with patch.object(backend, "_request", return_value={}) as mock_request:
|
||||||
|
backend.delete(memory_id="mem-123", delete_linked=True)
|
||||||
|
call = mock_request.call_args
|
||||||
|
assert call.kwargs["params"]["delete_linked"] == "true"
|
||||||
|
assert "json" not in call.kwargs
|
||||||
+2256
-448
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -122,12 +122,20 @@ echo "Loves hiking on weekends" | mem0 add --user-id alice
|
|||||||
| `--messages` | Conversation messages as JSON |
|
| `--messages` | Conversation messages as JSON |
|
||||||
| `-f, --file` | Read messages from a JSON file |
|
| `-f, --file` | Read messages from a JSON file |
|
||||||
| `-m, --metadata` | Custom metadata as JSON |
|
| `-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 |
|
| `--expires` | Expiration date, after which the memory stops being returned |
|
||||||
| `--immutable` | Store the memory so it can never be updated or overwritten |
|
| `--immutable` | Store the memory so it can never be updated or overwritten |
|
||||||
| `--no-infer` | Store the text exactly as given, skipping fact extraction |
|
| `--no-infer` | Store the text exactly as given, skipping fact extraction |
|
||||||
| `-o, --output` | Output format: `text`, `json`, `quiet` |
|
| `-o, --output` | Output format: `text`, `json`, `quiet` |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
`--categories` is not supported on `add` and exits with an error pointing at `--custom-categories`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
### `mem0 search`
|
### `mem0 search`
|
||||||
|
|
||||||
Search memories using natural language.
|
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 |
|
| `--keyword` | Use keyword search instead of semantic |
|
||||||
| `--filter` | Advanced filter expression (JSON) |
|
| `--filter` | Advanced filter expression (JSON) |
|
||||||
| `--fields` | Return only the named fields |
|
| `--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` |
|
| `-o, --output` | Output format: `text`, `json`, `table` |
|
||||||
|
|
||||||
### `mem0 list`
|
### `mem0 list`
|
||||||
@@ -172,6 +183,8 @@ mem0 list --user-id alice --after 2024-01-01 --page-size 50
|
|||||||
| `--category` | Filter by category |
|
| `--category` | Filter by category |
|
||||||
| `--after` | Created after date (YYYY-MM-DD) |
|
| `--after` | Created after date (YYYY-MM-DD) |
|
||||||
| `--before` | Created before 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` |
|
| `-o, --output` | Output format: `text`, `json`, `table` |
|
||||||
|
|
||||||
### `mem0 get`
|
### `mem0 get`
|
||||||
@@ -200,6 +213,8 @@ echo "new text" | mem0 update <memory-id>
|
|||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `-m, --metadata` | Replace the memory's metadata with this JSON |
|
| `-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` |
|
| `-o, --output` | Output format: `text`, `json`, `quiet` |
|
||||||
|
|
||||||
### `mem0 delete`
|
### `mem0 delete`
|
||||||
@@ -229,6 +244,7 @@ mem0 delete --all --user-id alice --dry-run
|
|||||||
| `--all` | Delete all memories matching scope filters |
|
| `--all` | Delete all memories matching scope filters |
|
||||||
| `--entity` | Delete the entity and all its memories |
|
| `--entity` | Delete the entity and all its memories |
|
||||||
| `--project` | With `--all`: delete all memories project-wide |
|
| `--project` | With `--all`: delete all memories project-wide |
|
||||||
|
| `--delete-linked` | Also delete memories linked to this memory |
|
||||||
| `--dry-run` | Preview without deleting |
|
| `--dry-run` | Preview without deleting |
|
||||||
| `--force` | Skip confirmation prompt |
|
| `--force` | Skip confirmation prompt |
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ Passing both `user_id` and `agent_id` to `client.add` does **not** produce recor
|
|||||||
As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting.
|
As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting.
|
||||||
</Warning>
|
</Warning>
|
||||||
|
|
||||||
The HTTP equivalent uses `POST /v1/memories/` with the same identifiers in the JSON body. See the Add Memories API reference for REST details.
|
The HTTP equivalent uses `POST /v3/memories/add/` with the same identifiers in the JSON body. See the [Add Memories](/api-reference/memory/add-memories) API reference for REST details.
|
||||||
|
|
||||||
## See it in action
|
## See it in action
|
||||||
|
|
||||||
|
|||||||
@@ -1,139 +1,80 @@
|
|||||||
---
|
---
|
||||||
title: Temporal Reasoning
|
title: Temporal Reasoning
|
||||||
description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories."
|
description: "Boost memories whose dates match the time in a search."
|
||||||
badge: "v3"
|
badge: "v3"
|
||||||
---
|
---
|
||||||
|
|
||||||
Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results.
|
Temporal Reasoning gives a ranking boost to memories whose event dates match the time in a search. Event dates are when something described in a memory happened or will happen. It runs automatically on Mem0 Platform v3.
|
||||||
|
|
||||||
<Info>
|
It is not available in the OSS SDK.
|
||||||
**Use Temporal Reasoning when…**
|
|
||||||
- Users ask questions like "what happened last week?" or "what do I have coming up?"
|
|
||||||
- Your app stores both past events and future plans for the same person
|
|
||||||
- You want time-aware retrieval without building your own date-parsing layer
|
|
||||||
</Info>
|
|
||||||
|
|
||||||
<Warning>
|
Dates from new memories usually affect search within a few seconds.
|
||||||
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
|
|
||||||
</Warning>
|
|
||||||
|
|
||||||
## Configure access
|
## Time-aware searches
|
||||||
|
|
||||||
Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client:
|
Queries can include expressions such as `yesterday`, `last week`, `tomorrow`, `currently`, and `as of March 2025`. Mem0 compares them with dates and date ranges found in stored memories.
|
||||||
|
|
||||||
```python
|
## Example
|
||||||
from mem0 import MemoryClient
|
|
||||||
|
|
||||||
client = MemoryClient(api_key="your-api-key")
|
Suppose a user has these memories:
|
||||||
```
|
|
||||||
|
|
||||||
## How it works
|
- "Yesterday I met Maya at the Orion conference in Paris."
|
||||||
|
- "Last week I met Maya at the Orion conference in Tokyo."
|
||||||
|
- "Last year I met Maya at the Orion conference in Lisbon."
|
||||||
|
|
||||||
When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time.
|
Searching for `Which city did I meet Maya in at the Orion conference last week?` gives the Tokyo memory a temporal boost and ranks it first.
|
||||||
|
|
||||||
A query like `what did I do last week?` should return a completed past event: not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically.
|
|
||||||
|
|
||||||
### Memory types Temporal Reasoning handles
|
|
||||||
|
|
||||||
| Type | What it represents | Example |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." |
|
|
||||||
| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." |
|
|
||||||
| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." |
|
|
||||||
| Relationship | A durable connection between people or entities | "Priya manages Jordan." |
|
|
||||||
| Preference | A stable preference or habit | "I prefer morning meetings." |
|
|
||||||
|
|
||||||
Results come back in the normal search response shape: Temporal Reasoning affects ranking, not the response format.
|
|
||||||
|
|
||||||
## Configure it
|
|
||||||
|
|
||||||
Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle.
|
|
||||||
|
|
||||||
Two parameters give you precise control when you need it:
|
|
||||||
|
|
||||||
- `observation_date` (or `observation_datetime`, which takes priority when both are set) on `add()`: anchors an imported memory to the time it actually happened. Pair with `timezone` when resolving a date-only value.
|
|
||||||
- `timestamp` on `add()`: a Unix epoch that sets the memory's creation time. When present it takes priority over `observation_datetime` and `observation_date` as the anchor Temporal Reasoning ranks against, so pass only the one you want to win.
|
|
||||||
- `reference_date` on `search()`: resolves relative phrases like `last week` against a fixed point in time
|
|
||||||
|
|
||||||
<CodeGroup>
|
<CodeGroup>
|
||||||
```python Python
|
```python Python
|
||||||
from mem0 import MemoryClient
|
|
||||||
|
|
||||||
client = MemoryClient(api_key="your-api-key")
|
|
||||||
|
|
||||||
client.add(
|
|
||||||
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
|
|
||||||
user_id="jordan",
|
|
||||||
observation_date="2025-03-10",
|
|
||||||
)
|
|
||||||
|
|
||||||
results = client.search(
|
results = client.search(
|
||||||
"what did I do last week?",
|
"Which city did I meet Maya in at the Orion conference last week?",
|
||||||
filters={"user_id": "jordan"},
|
filters={"user_id": "maya-demo"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript JavaScript
|
||||||
|
const results = await client.search(
|
||||||
|
"Which city did I meet Maya in at the Orion conference last week?",
|
||||||
|
{ filters: { user_id: "maya-demo" } }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
Search returns the usual memory results, reordered using the temporal boost.
|
||||||
|
|
||||||
|
## Search from a specific time
|
||||||
|
|
||||||
|
Use `reference_date` to simulate searching at a specific date and time. Mem0 treats it as the current time for that search.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
results = client.search(
|
||||||
|
"What happened last week?",
|
||||||
|
filters={"user_id": "user-123"},
|
||||||
reference_date="2025-03-21T00:00:00Z",
|
reference_date="2025-03-21T00:00:00Z",
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
```javascript JavaScript
|
```javascript JavaScript
|
||||||
import { MemoryClient } from "mem0ai";
|
const results = await client.search("What happened last week?", {
|
||||||
|
filters: { user_id: "user-123" },
|
||||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
|
||||||
|
|
||||||
await client.add(
|
|
||||||
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
|
|
||||||
{
|
|
||||||
userId: "jordan",
|
|
||||||
observationDate: "2025-03-10",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await client.search("what did I do last week?", {
|
|
||||||
filters: { user_id: "jordan" },
|
|
||||||
referenceDate: "2025-03-21T00:00:00Z",
|
referenceDate: "2025-03-21T00:00:00Z",
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
<Tip>
|
## Parameters
|
||||||
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
## Supported query patterns
|
| Action | Python | TypeScript | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Add memories | `timestamp` | `timestamp` | Preserve the original time of an imported conversation. |
|
||||||
|
| Search memories | `reference_date` | `referenceDate` | Simulate searching at a specific date and time. |
|
||||||
|
|
||||||
<AccordionGroup>
|
For all search inputs and returned fields, see the [Search Memories API reference](/api-reference/memory/search-memories).
|
||||||
<Accordion title="Historical questions">
|
|
||||||
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
|
|
||||||
</Accordion>
|
|
||||||
<Accordion title="Upcoming questions">
|
|
||||||
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
|
|
||||||
</Accordion>
|
|
||||||
<Accordion title="Current-state questions">
|
|
||||||
Examples: `right now`, `currently`, `where do I work now?`
|
|
||||||
</Accordion>
|
|
||||||
<Accordion title="As-of questions">
|
|
||||||
Examples: `as of March 2025`, `where was I living as of 2024?`
|
|
||||||
</Accordion>
|
|
||||||
<Accordion title="Duration questions">
|
|
||||||
Examples: `how long have I lived here?`, `since when have I worked there?`
|
|
||||||
</Accordion>
|
|
||||||
</AccordionGroup>
|
|
||||||
|
|
||||||
## Verify the feature is working
|
|
||||||
|
|
||||||
- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first.
|
|
||||||
- Use `reference_date` in test queries so relative phrases resolve consistently across runs.
|
|
||||||
- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time.
|
|
||||||
|
|
||||||
## Best practices
|
|
||||||
|
|
||||||
- Use explicit dates in source conversations when events or plans matter temporally.
|
|
||||||
- Pass `observation_date` (or `observation_datetime`) during historical imports so the ingestion time does not become the only time anchor. Do not also pass `timestamp` on those calls, since it overrides both as the ranking anchor.
|
|
||||||
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
|
|
||||||
- Use `reference_date` in automated tests and reproducible demos.
|
|
||||||
|
|
||||||
<CardGroup cols={1}>
|
<CardGroup cols={1}>
|
||||||
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
|
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
|
||||||
Anchor imported memories to when they actually happened.
|
Preserve the original time of imported memories.
|
||||||
</Card>
|
</Card>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -27,24 +27,45 @@ def load_settings() -> dict:
|
|||||||
try:
|
try:
|
||||||
with open(SETTINGS_PATH) as f:
|
with open(SETTINGS_PATH) as f:
|
||||||
user = json.load(f)
|
user = json.load(f)
|
||||||
settings.update({k: v for k, v in user.items() if k in DEFAULTS})
|
|
||||||
except (json.JSONDecodeError, OSError):
|
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
|
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)
|
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():
|
if not SETTINGS_PATH.exists():
|
||||||
with open(SETTINGS_PATH, "w") as f:
|
return []
|
||||||
json.dump(DEFAULTS, f, indent=2)
|
try:
|
||||||
f.write("\n")
|
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__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
||||||
create_default_settings()
|
if create_default_settings():
|
||||||
print(f"Created {SETTINGS_PATH}")
|
print(f"Created {SETTINGS_PATH}")
|
||||||
|
ignored = unknown_keys()
|
||||||
|
if ignored:
|
||||||
|
print(f"Ignoring unrecognized settings in {SETTINGS_PATH}: {', '.join(ignored)}")
|
||||||
else:
|
else:
|
||||||
print(json.dumps(load_settings()))
|
print(json.dumps(load_settings()))
|
||||||
|
|||||||
@@ -23,15 +23,14 @@ def _scripts_on_path():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clean_project_map(monkeypatch):
|
def _isolated_home(tmp_path, monkeypatch):
|
||||||
"""Remove project_map.json and clear MEM0_PROJECT_ID before each test."""
|
"""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)
|
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
|
||||||
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
yield home
|
||||||
if os.path.isfile(map_path):
|
|
||||||
os.remove(map_path)
|
|
||||||
yield
|
|
||||||
if os.path.isfile(map_path):
|
|
||||||
os.remove(map_path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests for scripts/load_settings.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def settings(tmp_path, monkeypatch):
|
||||||
|
import load_settings
|
||||||
|
|
||||||
|
path = tmp_path / ".mem0" / "settings.json"
|
||||||
|
monkeypatch.setattr(load_settings, "SETTINGS_PATH", path)
|
||||||
|
return load_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_creates_file_when_absent(settings):
|
||||||
|
assert settings.create_default_settings() is True
|
||||||
|
assert settings.SETTINGS_PATH.exists()
|
||||||
|
assert json.loads(settings.SETTINGS_PATH.read_text()) == settings.DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_recreate_or_overwrite_existing(settings):
|
||||||
|
settings.SETTINGS_PATH.parent.mkdir(parents=True)
|
||||||
|
settings.SETTINGS_PATH.write_text('{"search_limit": 42}')
|
||||||
|
|
||||||
|
assert settings.create_default_settings() is False
|
||||||
|
assert json.loads(settings.SETTINGS_PATH.read_text()) == {"search_limit": 42}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_values_override_defaults(settings):
|
||||||
|
settings.SETTINGS_PATH.parent.mkdir(parents=True)
|
||||||
|
settings.SETTINGS_PATH.write_text('{"search_limit": 3, "auto_save": false}')
|
||||||
|
|
||||||
|
loaded = settings.load_settings()
|
||||||
|
assert loaded["search_limit"] == 3
|
||||||
|
assert loaded["auto_save"] is False
|
||||||
|
assert loaded["global_search"] == settings.DEFAULTS["global_search"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_keys_are_dropped_but_reported(settings):
|
||||||
|
settings.SETTINGS_PATH.parent.mkdir(parents=True)
|
||||||
|
settings.SETTINGS_PATH.write_text('{"skip_tools": ["Read"], "output_style": "compact"}')
|
||||||
|
|
||||||
|
assert "skip_tools" not in settings.load_settings()
|
||||||
|
assert settings.unknown_keys() == ["output_style", "skip_tools"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_keys_empty_for_clean_file(settings):
|
||||||
|
settings.create_default_settings()
|
||||||
|
assert settings.unknown_keys() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", ["{not json", '["a", "list"]'])
|
||||||
|
def test_malformed_file_falls_back_to_defaults(settings, body):
|
||||||
|
settings.SETTINGS_PATH.parent.mkdir(parents=True)
|
||||||
|
settings.SETTINGS_PATH.write_text(body)
|
||||||
|
|
||||||
|
assert settings.load_settings() == settings.DEFAULTS
|
||||||
|
assert settings.unknown_keys() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_announces_creation_only_once(_isolated_home):
|
||||||
|
import load_settings
|
||||||
|
|
||||||
|
home = _isolated_home
|
||||||
|
|
||||||
|
def run_init():
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, load_settings.__file__, "init"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
env={**os.environ, "HOME": str(home)},
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
first = run_init()
|
||||||
|
second = run_init()
|
||||||
|
|
||||||
|
assert "Created" in first
|
||||||
|
assert "Created" not in second
|
||||||
@@ -115,7 +115,7 @@ export default {
|
|||||||
choices: { user: 'User', assistant: 'Assistant', system: 'System' },
|
choices: { user: 'User', assistant: 'Assistant', system: 'System' },
|
||||||
default: 'user',
|
default: 'user',
|
||||||
},
|
},
|
||||||
{ key: 'user_id', label: 'User ID', type: 'string' },
|
{ key: 'user_id', label: 'User ID', type: 'string', required: true, helpText: 'Scope this memory to a user. Mem0 requires at least one entity ID.' },
|
||||||
{ key: 'agent_id', label: 'Agent ID', type: 'string' },
|
{ key: 'agent_id', label: 'Agent ID', type: 'string' },
|
||||||
{ key: 'run_id', label: 'Run ID', type: 'string' },
|
{ key: 'run_id', label: 'Run ID', type: 'string' },
|
||||||
{ key: 'metadata', label: 'Metadata (JSON)', type: 'string' },
|
{ key: 'metadata', label: 'Metadata (JSON)', type: 'string' },
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface SearchMemoryOptions {
|
|||||||
fields?: string[];
|
fields?: string[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
showExpired?: boolean;
|
showExpired?: boolean;
|
||||||
|
referenceDate?: string | number;
|
||||||
|
keywordSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAllMemoryOptions {
|
export interface GetAllMemoryOptions {
|
||||||
|
|||||||
@@ -99,6 +99,42 @@ describe("MemoryClient - search()", () => {
|
|||||||
expect(getFetchBody(call!).show_expired).toBe(true);
|
expect(getFetchBody(call!).show_expired).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("serializes referenceDate as reference_date (MEM-5893)", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/v3/memories/search/", {
|
||||||
|
status: 200,
|
||||||
|
body: { results: [] },
|
||||||
|
});
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||||
|
await client.search("test", {
|
||||||
|
filters: { user_id: "u1" },
|
||||||
|
referenceDate: "2024-01-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
|
||||||
|
expect(getFetchBody(call!).reference_date).toBe("2024-01-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializes keywordSearch as keyword_search (MEM-5893)", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/v3/memories/search/", {
|
||||||
|
status: 200,
|
||||||
|
body: { results: [] },
|
||||||
|
});
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||||
|
await client.search("test", {
|
||||||
|
filters: { user_id: "u1" },
|
||||||
|
keywordSearch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
|
||||||
|
expect(getFetchBody(call!).keyword_search).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("passes complex OR filters through to the API body", async () => {
|
test("passes complex OR filters through to the API body", async () => {
|
||||||
const extra = new Map<string, { status: number; body: unknown }>();
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
extra.set("/v3/memories/search/", {
|
extra.set("/v3/memories/search/", {
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ function toSnakeCase(obj: Record<string, any>): Record<string, any> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a RediSearch pre-filter expression from filters. Returns "*" (match
|
||||||
|
// all) when there are no usable conditions — including an empty filters object
|
||||||
|
// or one whose values are all null/undefined — since an empty expression is an
|
||||||
|
// invalid RediSearch query (e.g. ` =>[KNN ...]`).
|
||||||
|
export function buildRedisFilterExpr(filters?: SearchFilters): string {
|
||||||
|
if (!filters) return "*";
|
||||||
|
const conditions = Object.entries(toSnakeCase(filters))
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined)
|
||||||
|
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`);
|
||||||
|
return conditions.length > 0 ? conditions.join(" ") : "*";
|
||||||
|
}
|
||||||
|
|
||||||
// Utility function to convert object keys to camelCase
|
// Utility function to convert object keys to camelCase
|
||||||
function toCamelCase(obj: Record<string, any>): Record<string, any> {
|
function toCamelCase(obj: Record<string, any>): Record<string, any> {
|
||||||
if (typeof obj !== "object" || obj === null) return obj;
|
if (typeof obj !== "object" || obj === null) return obj;
|
||||||
@@ -406,13 +418,7 @@ export class RedisDB implements VectorStore {
|
|||||||
filters?: SearchFilters,
|
filters?: SearchFilters,
|
||||||
): Promise<VectorStoreResult[]> {
|
): Promise<VectorStoreResult[]> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
|
const filterExpr = buildRedisFilterExpr(filters);
|
||||||
const filterExpr = snakeFilters
|
|
||||||
? Object.entries(snakeFilters)
|
|
||||||
.filter(([_, value]) => value !== null && value !== undefined)
|
|
||||||
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
|
|
||||||
.join(" ")
|
|
||||||
: "*";
|
|
||||||
|
|
||||||
const queryVector = new Float32Array(query).buffer;
|
const queryVector = new Float32Array(query).buffer;
|
||||||
|
|
||||||
@@ -655,13 +661,7 @@ export class RedisDB implements VectorStore {
|
|||||||
topK: number = 100,
|
topK: number = 100,
|
||||||
): Promise<[VectorStoreResult[], number]> {
|
): Promise<[VectorStoreResult[], number]> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
|
const filterExpr = buildRedisFilterExpr(filters);
|
||||||
const filterExpr = snakeFilters
|
|
||||||
? Object.entries(snakeFilters)
|
|
||||||
.filter(([_, value]) => value !== null && value !== undefined)
|
|
||||||
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
|
|
||||||
.join(" ")
|
|
||||||
: "*";
|
|
||||||
|
|
||||||
const searchOptions = {
|
const searchOptions = {
|
||||||
SORTBY: "created_at",
|
SORTBY: "created_at",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { buildRedisFilterExpr } from "../src/vector_stores/redis";
|
||||||
|
|
||||||
|
describe("buildRedisFilterExpr", () => {
|
||||||
|
it("returns '*' when filters is undefined", () => {
|
||||||
|
expect(buildRedisFilterExpr(undefined)).toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '*' for an empty filters object", () => {
|
||||||
|
// Regression: previously produced "" -> invalid RediSearch query
|
||||||
|
// (` =>[KNN ...]`).
|
||||||
|
expect(buildRedisFilterExpr({})).toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '*' when every filter value is null/undefined", () => {
|
||||||
|
expect(
|
||||||
|
buildRedisFilterExpr({ user_id: null as any, agent_id: undefined }),
|
||||||
|
).toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a tag clause for a single filter", () => {
|
||||||
|
expect(buildRedisFilterExpr({ user_id: "alice" })).toBe("@user_id:{alice}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snake-cases keys and joins multiple clauses", () => {
|
||||||
|
expect(buildRedisFilterExpr({ userId: "alice", runId: "r1" })).toBe(
|
||||||
|
"@user_id:{alice} @run_id:{r1}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops null/undefined values but keeps the rest", () => {
|
||||||
|
expect(
|
||||||
|
buildRedisFilterExpr({ user_id: "alice", agent_id: null as any }),
|
||||||
|
).toBe("@user_id:{alice}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,11 @@ class SearchMemoryOptions(BaseModel):
|
|||||||
fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response")
|
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")
|
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")
|
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):
|
class GetAllMemoryOptions(BaseModel):
|
||||||
@@ -74,6 +79,7 @@ class GetAllMemoryOptions(BaseModel):
|
|||||||
)
|
)
|
||||||
categories: Optional[List[str]] = Field(default=None, description="Categories to filter by")
|
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")
|
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):
|
class DeleteAllMemoryOptions(BaseModel):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Optional, Literal
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from mem0.embeddings.base import EmbeddingBase
|
|
||||||
from mem0.configs.embeddings.base import BaseEmbedderConfig
|
from mem0.configs.embeddings.base import BaseEmbedderConfig
|
||||||
|
from mem0.embeddings.base import EmbeddingBase
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastembed import TextEmbedding
|
from fastembed import TextEmbedding
|
||||||
@@ -29,4 +29,4 @@ class FastEmbedEmbedding(EmbeddingBase):
|
|||||||
"""
|
"""
|
||||||
text = text.replace("\n", " ")
|
text = text.replace("\n", " ")
|
||||||
embeddings = list(self.dense_model.embed(text))
|
embeddings = list(self.dense_model.embed(text))
|
||||||
return embeddings[0]
|
return embeddings[0].tolist()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class HuggingFaceEmbedding(EmbeddingBase):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
|
|
||||||
if self.config.huggingface_base_url:
|
if self.config.huggingface_base_url:
|
||||||
self.client = OpenAI(base_url=self.config.huggingface_base_url)
|
self.client = OpenAI(base_url=self.config.huggingface_base_url, api_key=self.config.api_key)
|
||||||
self.config.model = self.config.model or "tei"
|
self.config.model = self.config.model or "tei"
|
||||||
else:
|
else:
|
||||||
self.config.model = self.config.model or "multi-qa-MiniLM-L6-cos-v1"
|
self.config.model = self.config.model or "multi-qa-MiniLM-L6-cos-v1"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from mem0.configs.embeddings.base import BaseEmbedderConfig
|
from mem0.configs.embeddings.base import BaseEmbedderConfig
|
||||||
@@ -8,17 +6,7 @@ from mem0.embeddings.base import EmbeddingBase
|
|||||||
try:
|
try:
|
||||||
from ollama import Client
|
from ollama import Client
|
||||||
except ImportError:
|
except ImportError:
|
||||||
user_input = input("The 'ollama' library is required. Install it now? [y/N]: ")
|
raise ImportError("The 'ollama' library is required. Please install it using 'pip install ollama'.")
|
||||||
if user_input.lower() == "y":
|
|
||||||
try:
|
|
||||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "ollama"])
|
|
||||||
from ollama import Client
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
print("Failed to install 'ollama'. Please install it manually using 'pip install ollama'.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("The required 'ollama' library is not installed.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaEmbedding(EmbeddingBase):
|
class OllamaEmbedding(EmbeddingBase):
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ def remove_code_blocks(content: str) -> str:
|
|||||||
- If a code block is detected, it returns only the inner content, stripping out the markers.
|
- If a code block is detected, it returns only the inner content, stripping out the markers.
|
||||||
- If no code block markers are found, the original content is returned as-is.
|
- If no code block markers are found, the original content is returned as-is.
|
||||||
"""
|
"""
|
||||||
|
if content is None:
|
||||||
|
return ""
|
||||||
pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$"
|
pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$"
|
||||||
match = re.match(pattern, content.strip())
|
match = re.match(pattern, content.strip())
|
||||||
match_res=match.group(1).strip() if match else content.strip()
|
match_res=match.group(1).strip() if match else content.strip()
|
||||||
@@ -213,8 +215,8 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"):
|
|||||||
try:
|
try:
|
||||||
description = get_image_description(image_url, llm, vision_details)
|
description = get_image_description(image_url, llm, vision_details)
|
||||||
returned_messages.append({"role": role, "content": description})
|
returned_messages.append({"role": role, "content": description})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
raise Exception(f"Error while downloading {image_url}.")
|
raise Exception(f"Error while downloading {image_url}.") from e
|
||||||
else:
|
else:
|
||||||
# Regular text content
|
# Regular text content
|
||||||
returned_messages.append(msg)
|
returned_messages.append(msg)
|
||||||
@@ -227,7 +229,7 @@ def process_telemetry_filters(filters):
|
|||||||
Process the telemetry filters
|
Process the telemetry filters
|
||||||
"""
|
"""
|
||||||
if filters is None:
|
if filters is None:
|
||||||
return {}
|
return [], {}
|
||||||
|
|
||||||
encoded_ids = {}
|
encoded_ids = {}
|
||||||
if "user_id" in filters:
|
if "user_id" in filters:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from mem0.reranker.base import BaseReranker
|
from mem0.reranker.base import BaseReranker
|
||||||
|
|
||||||
@@ -84,7 +84,10 @@ class CohereReranker(BaseReranker):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback to original order if reranking fails
|
# Fallback to original order if reranking fails
|
||||||
logger.warning("Cohere reranking failed, falling back to original order: %s", e)
|
logger.warning("Cohere reranking failed, falling back to original order: %s", e)
|
||||||
|
fallback_docs = []
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
doc['rerank_score'] = 0.0
|
fallback_doc = doc.copy()
|
||||||
|
fallback_doc['rerank_score'] = 0.0
|
||||||
|
fallback_docs.append(fallback_doc)
|
||||||
final_top_k = top_k or self.config.top_k
|
final_top_k = top_k or self.config.top_k
|
||||||
return documents[:final_top_k] if final_top_k else documents
|
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Union
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from mem0.reranker.base import BaseReranker
|
|
||||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||||
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
||||||
|
from mem0.reranker.base import BaseReranker
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
|
||||||
import torch
|
import torch
|
||||||
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||||
TRANSFORMERS_AVAILABLE = True
|
TRANSFORMERS_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
TRANSFORMERS_AVAILABLE = False
|
TRANSFORMERS_AVAILABLE = False
|
||||||
@@ -163,7 +164,10 @@ class HuggingFaceReranker(BaseReranker):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback to original order if reranking fails
|
# Fallback to original order if reranking fails
|
||||||
logger.warning("HuggingFace reranking failed, falling back to original order: %s", e)
|
logger.warning("HuggingFace reranking failed, falling back to original order: %s", e)
|
||||||
|
fallback_docs = []
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
doc['rerank_score'] = 0.0
|
fallback_doc = doc.copy()
|
||||||
|
fallback_doc['rerank_score'] = 0.0
|
||||||
|
fallback_docs.append(fallback_doc)
|
||||||
final_top_k = top_k or self.config.top_k
|
final_top_k = top_k or self.config.top_k
|
||||||
return documents[:final_top_k] if final_top_k else documents
|
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Union
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from mem0.reranker.base import BaseReranker
|
|
||||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||||
from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig
|
from mem0.configs.rerankers.sentence_transformer import (
|
||||||
|
SentenceTransformerRerankerConfig,
|
||||||
|
)
|
||||||
|
from mem0.reranker.base import BaseReranker
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from sentence_transformers import CrossEncoder
|
from sentence_transformers import CrossEncoder
|
||||||
@@ -108,7 +111,10 @@ class SentenceTransformerReranker(BaseReranker):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback to original order if reranking fails
|
# Fallback to original order if reranking fails
|
||||||
logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e)
|
logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e)
|
||||||
|
fallback_docs = []
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
doc['rerank_score'] = 0.0
|
fallback_doc = doc.copy()
|
||||||
|
fallback_doc['rerank_score'] = 0.0
|
||||||
|
fallback_docs.append(fallback_doc)
|
||||||
final_top_k = top_k or self.config.top_k
|
final_top_k = top_k or self.config.top_k
|
||||||
return documents[:final_top_k] if final_top_k else documents
|
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from mem0.reranker.base import BaseReranker
|
from mem0.reranker.base import BaseReranker
|
||||||
|
|
||||||
@@ -95,7 +95,10 @@ class ZeroEntropyReranker(BaseReranker):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback to original order if reranking fails
|
# Fallback to original order if reranking fails
|
||||||
logger.warning("Zero Entropy reranking failed, falling back to original order: %s", e)
|
logger.warning("Zero Entropy reranking failed, falling back to original order: %s", e)
|
||||||
|
fallback_docs = []
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
doc['rerank_score'] = 0.0
|
fallback_doc = doc.copy()
|
||||||
|
fallback_doc['rerank_score'] = 0.0
|
||||||
|
fallback_docs.append(fallback_doc)
|
||||||
final_top_k = top_k or self.config.top_k
|
final_top_k = top_k or self.config.top_k
|
||||||
return documents[:final_top_k] if final_top_k else documents
|
return fallback_docs[:final_top_k] if final_top_k else fallback_docs
|
||||||
@@ -19,7 +19,9 @@ from mem0.configs.rerankers.base import BaseRerankerConfig
|
|||||||
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
||||||
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
||||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||||
from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig
|
from mem0.configs.rerankers.sentence_transformer import (
|
||||||
|
SentenceTransformerRerankerConfig,
|
||||||
|
)
|
||||||
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
||||||
from mem0.embeddings.mock import MockEmbeddings
|
from mem0.embeddings.mock import MockEmbeddings
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@ class LlmFactory:
|
|||||||
config = config_class(**kwargs)
|
config = config_class(**kwargs)
|
||||||
elif isinstance(config, dict):
|
elif isinstance(config, dict):
|
||||||
# Merge dict config with kwargs
|
# Merge dict config with kwargs
|
||||||
config.update(kwargs)
|
config = {**config, **kwargs}
|
||||||
config = config_class(**config)
|
config = config_class(**config)
|
||||||
elif isinstance(config, BaseLlmConfig):
|
elif isinstance(config, BaseLlmConfig):
|
||||||
# Convert base config to provider-specific config if needed
|
# Convert base config to provider-specific config if needed
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ except ImportError:
|
|||||||
from mem0.vector_stores.base import VectorStoreBase
|
from mem0.vector_stores.base import VectorStoreBase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
_DRIVER_METADATA = DriverInfo(name="Mem0", version=version("mem0ai"))
|
_DRIVER_METADATA = DriverInfo(name="Mem0", version=version("mem0ai"))
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from mem0.configs.vector_stores.vertex_ai_vector_search import (
|
|||||||
from mem0.vector_stores.base import VectorStoreBase
|
from mem0.vector_stores.base import VectorStoreBase
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def test_embed_with_huggingface_base_url():
|
|||||||
embedder = HuggingFaceEmbedding(config)
|
embedder = HuggingFaceEmbedding(config)
|
||||||
result = embedder.embed("Hello from custom endpoint")
|
result = embedder.embed("Hello from custom endpoint")
|
||||||
|
|
||||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080")
|
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key=None)
|
||||||
mock_client.embeddings.create.assert_called_once_with(
|
mock_client.embeddings.create.assert_called_once_with(
|
||||||
input="Hello from custom endpoint",
|
input="Hello from custom endpoint",
|
||||||
model="my-custom-model",
|
model="my-custom-model",
|
||||||
@@ -103,6 +103,18 @@ def test_embed_with_huggingface_base_url():
|
|||||||
assert result == [0.1, 0.2, 0.3]
|
assert result == [0.1, 0.2, 0.3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_with_huggingface_base_url_forwards_api_key():
|
||||||
|
config = BaseEmbedderConfig(
|
||||||
|
huggingface_base_url="http://localhost:8080",
|
||||||
|
model="my-custom-model",
|
||||||
|
api_key="tei-token",
|
||||||
|
)
|
||||||
|
with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai:
|
||||||
|
HuggingFaceEmbedding(config)
|
||||||
|
|
||||||
|
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="tei-token")
|
||||||
|
|
||||||
|
|
||||||
def test_embed_batch_sentence_transformer(mock_sentence_transformer):
|
def test_embed_batch_sentence_transformer(mock_sentence_transformer):
|
||||||
config = BaseEmbedderConfig()
|
config = BaseEmbedderConfig()
|
||||||
embedder = HuggingFaceEmbedding(config)
|
embedder = HuggingFaceEmbedding(config)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from mem0.memory.utils import (
|
from mem0.memory.utils import (
|
||||||
parse_messages,
|
parse_messages,
|
||||||
parse_vision_messages,
|
parse_vision_messages,
|
||||||
|
process_telemetry_filters,
|
||||||
|
remove_code_blocks,
|
||||||
remove_spaces_from_entities,
|
remove_spaces_from_entities,
|
||||||
sanitize_relationship_for_cypher,
|
sanitize_relationship_for_cypher,
|
||||||
)
|
)
|
||||||
@@ -114,6 +117,17 @@ class TestParseVisionMessages:
|
|||||||
parse_vision_messages(messages, llm=mock_llm)
|
parse_vision_messages(messages, llm=mock_llm)
|
||||||
mock_llm.generate_response.assert_not_called()
|
mock_llm.generate_response.assert_not_called()
|
||||||
|
|
||||||
|
def test_download_failure_preserves_original_exception(self):
|
||||||
|
mock_llm = Mock()
|
||||||
|
mock_llm.generate_response.side_effect = ValueError("network down")
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}}
|
||||||
|
]
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
parse_vision_messages(messages, llm=mock_llm)
|
||||||
|
assert isinstance(exc_info.value.__cause__, ValueError)
|
||||||
|
assert "network down" in str(exc_info.value.__cause__)
|
||||||
|
|
||||||
|
|
||||||
class TestRemoveSpacesFromEntities:
|
class TestRemoveSpacesFromEntities:
|
||||||
"""
|
"""
|
||||||
@@ -168,3 +182,13 @@ class TestRemoveSpacesFromEntities:
|
|||||||
f = remove_spaces_from_entities([dict(base)], sanitize_relationship=False)[0]["relationship"]
|
f = remove_spaces_from_entities([dict(base)], sanitize_relationship=False)[0]["relationship"]
|
||||||
assert t == sanitize_relationship_for_cypher("a/b")
|
assert t == sanitize_relationship_for_cypher("a/b")
|
||||||
assert f == "a/b"
|
assert f == "a/b"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessTelemetryFilters:
|
||||||
|
def test_none_filters_returns_empty_list_and_dict(self):
|
||||||
|
assert process_telemetry_filters(None) == ([], {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveCodeBlocks:
|
||||||
|
def test_none_content_returns_empty_string(self):
|
||||||
|
assert remove_code_blocks(None) == ""
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import sys
|
||||||
|
from types import ModuleType
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,3 +11,33 @@ def mock_llm():
|
|||||||
mock_llm_instance = MagicMock()
|
mock_llm_instance = MagicMock()
|
||||||
mock_factory.create.return_value = mock_llm_instance
|
mock_factory.create.return_value = mock_llm_instance
|
||||||
yield mock_factory, mock_llm_instance
|
yield mock_factory, mock_llm_instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_cohere(monkeypatch):
|
||||||
|
"""Provide a fake ``cohere`` module so CohereReranker imports/constructs."""
|
||||||
|
fake_cohere = ModuleType("cohere")
|
||||||
|
fake_client = MagicMock()
|
||||||
|
fake_cohere.Client = MagicMock(return_value=fake_client)
|
||||||
|
monkeypatch.setitem(sys.modules, "cohere", fake_cohere)
|
||||||
|
|
||||||
|
import mem0.reranker.cohere_reranker as cohere_reranker
|
||||||
|
|
||||||
|
monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False)
|
||||||
|
monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False)
|
||||||
|
return cohere_reranker, fake_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_zero_entropy(monkeypatch):
|
||||||
|
"""Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports."""
|
||||||
|
fake_module = ModuleType("zeroentropy")
|
||||||
|
fake_client = MagicMock()
|
||||||
|
fake_module.ZeroEntropy = MagicMock(return_value=fake_client)
|
||||||
|
monkeypatch.setitem(sys.modules, "zeroentropy", fake_module)
|
||||||
|
|
||||||
|
import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker
|
||||||
|
|
||||||
|
monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False)
|
||||||
|
monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False)
|
||||||
|
return zero_entropy_reranker, fake_client
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Regression tests pinning that the reranker fallback path never mutates caller-owned documents."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
||||||
|
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
||||||
|
from mem0.configs.rerankers.sentence_transformer import (
|
||||||
|
SentenceTransformerRerankerConfig,
|
||||||
|
)
|
||||||
|
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
||||||
|
from mem0.reranker.huggingface_reranker import HuggingFaceReranker
|
||||||
|
from mem0.reranker.sentence_transformer_reranker import SentenceTransformerReranker
|
||||||
|
|
||||||
|
|
||||||
|
def _docs(n):
|
||||||
|
return [{"memory": f"doc{i}"} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCohereFallbackNoMutation:
|
||||||
|
def test_fallback_does_not_mutate_original_documents(self, mock_cohere):
|
||||||
|
module, fake_client = mock_cohere
|
||||||
|
fake_client.rerank.side_effect = RuntimeError("API error")
|
||||||
|
|
||||||
|
reranker = module.CohereReranker(CohereRerankerConfig(api_key="test-key"))
|
||||||
|
documents = _docs(3)
|
||||||
|
result = reranker.rerank("query", documents)
|
||||||
|
|
||||||
|
assert all("rerank_score" not in doc for doc in documents)
|
||||||
|
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeroEntropyFallbackNoMutation:
|
||||||
|
def test_fallback_does_not_mutate_original_documents(self, mock_zero_entropy):
|
||||||
|
module, fake_client = mock_zero_entropy
|
||||||
|
fake_client.models.rerank.side_effect = RuntimeError("API error")
|
||||||
|
|
||||||
|
reranker = module.ZeroEntropyReranker(ZeroEntropyRerankerConfig(api_key="test-key"))
|
||||||
|
documents = _docs(3)
|
||||||
|
result = reranker.rerank("query", documents)
|
||||||
|
|
||||||
|
assert all("rerank_score" not in doc for doc in documents)
|
||||||
|
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHuggingFaceFallbackNoMutation:
|
||||||
|
def test_fallback_does_not_mutate_original_documents(self):
|
||||||
|
with (
|
||||||
|
patch("mem0.reranker.huggingface_reranker.AutoTokenizer") as mock_tokenizer_cls,
|
||||||
|
patch("mem0.reranker.huggingface_reranker.AutoModelForSequenceClassification") as mock_model_cls,
|
||||||
|
):
|
||||||
|
mock_tokenizer = MagicMock(side_effect=RuntimeError("tokenizer error"))
|
||||||
|
mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer
|
||||||
|
mock_model_cls.from_pretrained.return_value = MagicMock()
|
||||||
|
|
||||||
|
reranker = HuggingFaceReranker(HuggingFaceRerankerConfig())
|
||||||
|
documents = _docs(3)
|
||||||
|
result = reranker.rerank("query", documents)
|
||||||
|
|
||||||
|
assert all("rerank_score" not in doc for doc in documents)
|
||||||
|
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSentenceTransformerFallbackNoMutation:
|
||||||
|
def test_fallback_does_not_mutate_original_documents(self):
|
||||||
|
with patch("mem0.reranker.sentence_transformer_reranker.CrossEncoder") as mock_cross_encoder_cls:
|
||||||
|
mock_model = MagicMock()
|
||||||
|
mock_model.predict.side_effect = RuntimeError("predict error")
|
||||||
|
mock_cross_encoder_cls.return_value = mock_model
|
||||||
|
|
||||||
|
reranker = SentenceTransformerReranker(SentenceTransformerRerankerConfig())
|
||||||
|
documents = _docs(3)
|
||||||
|
result = reranker.rerank("query", documents)
|
||||||
|
|
||||||
|
assert all("rerank_score" not in doc for doc in documents)
|
||||||
|
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||||
@@ -1,52 +1,9 @@
|
|||||||
"""Regression tests for the reranker fallback path honoring ``config.top_k``.
|
"""Regression tests pinning that the reranker fallback path still honors ``config.top_k``."""
|
||||||
|
|
||||||
When the underlying rerank call fails, the reranker falls back to returning the
|
|
||||||
documents in their original order. That fallback must still respect the
|
|
||||||
configured ``top_k`` limit, exactly like the success path does. The HuggingFace
|
|
||||||
and SentenceTransformer rerankers already behave this way; these tests pin the
|
|
||||||
same contract for the Cohere and ZeroEntropy rerankers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from types import ModuleType
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
||||||
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_cohere(monkeypatch):
|
|
||||||
"""Provide a fake ``cohere`` module so CohereReranker imports/constructs."""
|
|
||||||
fake_cohere = ModuleType("cohere")
|
|
||||||
fake_client = MagicMock()
|
|
||||||
fake_cohere.Client = MagicMock(return_value=fake_client)
|
|
||||||
monkeypatch.setitem(sys.modules, "cohere", fake_cohere)
|
|
||||||
|
|
||||||
import mem0.reranker.cohere_reranker as cohere_reranker
|
|
||||||
|
|
||||||
monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False)
|
|
||||||
monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False)
|
|
||||||
return cohere_reranker, fake_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_zero_entropy(monkeypatch):
|
|
||||||
"""Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports."""
|
|
||||||
fake_module = ModuleType("zeroentropy")
|
|
||||||
fake_client = MagicMock()
|
|
||||||
fake_module.ZeroEntropy = MagicMock(return_value=fake_client)
|
|
||||||
monkeypatch.setitem(sys.modules, "zeroentropy", fake_module)
|
|
||||||
|
|
||||||
import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker
|
|
||||||
|
|
||||||
monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False)
|
|
||||||
monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False)
|
|
||||||
return zero_entropy_reranker, fake_client
|
|
||||||
|
|
||||||
|
|
||||||
def _docs(n):
|
def _docs(n):
|
||||||
return [{"memory": f"doc{i}"} for i in range(n)]
|
return [{"memory": f"doc{i}"} for i in range(n)]
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from mem0.client.types import GetAllMemoryOptions, SearchMemoryOptions
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_memory_client():
|
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:
|
class TestUpdateExpirationDate:
|
||||||
"""Tests for update expiration_date payload handling."""
|
"""Tests for update expiration_date payload handling."""
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,12 @@ def test_base_to_provider_without_reasoning_fields_still_builds():
|
|||||||
|
|
||||||
assert isinstance(built, AnthropicConfig)
|
assert isinstance(built, AnthropicConfig)
|
||||||
assert built.model == "claude-3-5-sonnet-20240620"
|
assert built.model == "claude-3-5-sonnet-20240620"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dict_config_not_mutated_by_kwargs():
|
||||||
|
config = {"model": "gpt-4o-mini"}
|
||||||
|
|
||||||
|
with patch("mem0.utils.factory.load_class", return_value=Mock(return_value=Mock())):
|
||||||
|
LlmFactory.create("openai", config, api_key="secret")
|
||||||
|
|
||||||
|
assert config == {"model": "gpt-4o-mini"}
|
||||||
|
|||||||
Reference in New Issue
Block a user