Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782fa392a5 | ||
|
|
9d60fd9f4e |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Generate Profiles'
|
||||
description: "Start one generation: sample a few entities, or build one for a single entity."
|
||||
openapi: post /v2/profiles/jobs/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Get Generation Job'
|
||||
description: "Read the progress of a generation, and whether it finished."
|
||||
openapi: get /v2/profiles/jobs/{job_id}/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Get Profile Settings'
|
||||
description: "Retrieve the profile schema, custom instructions, and enabled flag for the current project."
|
||||
openapi: get /v2/profiles/settings/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Get Profile'
|
||||
description: "Retrieve the structured profile for a user, with a status describing whether generation has completed."
|
||||
openapi: get /v2/entities/{entity_type}/{entity_id}/profile/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Update Profile Settings'
|
||||
description: "Set the JSON Schema, custom instructions, or enabled flag that control profile generation for the project."
|
||||
openapi: post /v2/profiles/settings/
|
||||
---
|
||||
+17
-14
@@ -7,13 +7,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-09-23" description="v2.1.0">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add User Profiles to `MemoryClient` and `AsyncMemoryClient`: `get_profile()`, `generate_profile()`, `get_profile_settings()`, `update_profile_settings()`, `sample_profiles()`, and `get_profile_job()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-02" description="v2.0.20">
|
||||
|
||||
**Improvements:**
|
||||
@@ -1234,13 +1227,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-09-23" description="v3.2.0">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add User Profiles to `MemoryClient`: `getProfile()`, `generateProfile()`, `getProfileSettings()`, `updateProfileSettings()`, `sampleProfiles()`, and `getProfileJob()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-02" description="v3.1.8">
|
||||
|
||||
**Improvements:**
|
||||
@@ -3120,6 +3106,23 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
|
||||
|
||||
<Tab title="DeepSeek Harness">
|
||||
|
||||
<Update label="2026-09-11" description="deepseek-plugin v0.3.1">
|
||||
|
||||
**Fixed:**
|
||||
- Automatic recall uses the claimed human inbox message before Harness appends it to session history, so the first request in a fresh session can recall memories and later turns use the current prompt.
|
||||
- Native `dsh.bundle` activation loads Mem0 when the package is installed into a Harness profile. `pnpm pack` builds the plugin and includes its activation patch.
|
||||
- Automatic recall guidance points to the available memory search tool for deeper searches.
|
||||
|
||||
**Added:**
|
||||
- Optional `memoryScope: workspace` applies the same canonical workspace filter to automatic recall, capture, and both memory tools. Missing workspace paths skip automatic operations and reject explicit tools without falling back to shared memory.
|
||||
|
||||
**Upgrade notes:**
|
||||
- `memoryScope: user` remains the default and shares memories across workspaces. Existing memories are not migrated into workspace scopes; separate clones and worktrees have separate scopes when workspace mode is enabled.
|
||||
- Export `MEM0_USER_ID` alongside `MEM0_API_KEY`. Remove the old manual `insert: mem0` overlay; customize the bundle's existing row with an `id: mem0` override. See [DeepSeek Harness setup](/integrations/deepseek-plugin).
|
||||
- Capture extraction remains asynchronous. This patch does not change extraction policy or guarantee that every preference or numeric constraint becomes a memory.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-08" description="deepseek-plugin v0.3.0">
|
||||
|
||||
**Added:**
|
||||
|
||||
@@ -102,7 +102,7 @@ Here are the parameters available for configuring Upstash Vector:
|
||||
| `url` | URL for the Upstash Vector index | `None` |
|
||||
| `token` | Token for the Upstash Vector index | `None` |
|
||||
| `client` | An `upstash_vector.Index` instance | `None` |
|
||||
| `collection_name` | The default namespace used | `"mem0"` |
|
||||
| `collection_name` | The default namespace used | `""` |
|
||||
| `enable_embeddings` | Whether to use Upstash embeddings | `False` |
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"pages": [
|
||||
"platform/features/v2-memory-filters",
|
||||
"platform/features/entity-scoped-memory",
|
||||
"platform/features/user-profiles",
|
||||
"platform/features/graph-memory",
|
||||
"platform/features/async-client",
|
||||
"platform/features/multimodal-support",
|
||||
@@ -512,17 +511,6 @@
|
||||
"api-reference/entities/delete-user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Profiles",
|
||||
"icon": "id-card",
|
||||
"pages": [
|
||||
"api-reference/profiles/get-profile",
|
||||
"api-reference/profiles/get-profile-settings",
|
||||
"api-reference/profiles/update-profile-settings",
|
||||
"api-reference/profiles/generate-profiles",
|
||||
"api-reference/profiles/get-profile-job"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Organizations",
|
||||
"icon": "building",
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Add persistent memory to DeepSeek Harness with automatic recall, a
|
||||
|
||||
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/deepseek-plugin`. The plugin recalls relevant context before a model request, captures completed turns, and provides explicit Mem0 tools when the agent needs them.
|
||||
|
||||
<Info>Current package version: `0.3.0`.</Info>
|
||||
<Info>Current package version: `0.3.1`.</Info>
|
||||
|
||||
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
|
||||
|
||||
@@ -32,7 +32,7 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This plugin waits fo
|
||||
|
||||
Cordis removes the listeners and tools when the plugin unmounts. Memory failures are fail-open, so a Mem0 outage does not stop the agent from completing its normal work.
|
||||
|
||||
DeepSeek Harness provides subagents through separate host-composition packages. This Mem0 package does not register a named Sidekick or claim child filesystem isolation. A Harness child uses Mem0 only when its own agent preset includes the Mem0 plugin.
|
||||
DeepSeek Harness provides subagents through separate host-composition packages. This Mem0 package does not register a named Sidekick or claim child filesystem isolation. Memory availability in children depends on the Harness composition and service scope.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -40,25 +40,27 @@ DeepSeek Harness provides subagents through separate host-composition packages.
|
||||
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-deepseek-plugin">Sign up at app.mem0.ai</a>
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-deepseek-plugin">Get your API key</a> (starts with `m0-`)
|
||||
|
||||
2. The DeepSeek Harness installed.
|
||||
2. The DeepSeek Harness installed and a funded model-provider credential (`DEEPSEEK_API_KEY` for the official DeepSeek provider). This is separate from your Mem0 key.
|
||||
|
||||
3. Your API key exported in your shell:
|
||||
3. Your Mem0 key and a stable user identity exported in the shell that launches Harness:
|
||||
|
||||
<CodeGroup>
|
||||
```bash zsh
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
|
||||
echo 'export MEM0_USER_ID="your-user-id"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
```bash bash
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
|
||||
echo 'export MEM0_USER_ID="your-user-id"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Build and pack the plugin:
|
||||
1. Build and pack the plugin from the full repository (the source imports the sibling shared core):
|
||||
```sh
|
||||
cd integrations/deepseek-plugin
|
||||
pnpm install --frozen-lockfile
|
||||
@@ -70,33 +72,32 @@ source ~/.bashrc
|
||||
2. Install it into a disposable Harness profile so Harness supplies its peer dependencies:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
|
||||
plugin --profile web add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.1.tgz
|
||||
```
|
||||
|
||||
3. Copy `cordis.example.yml`, set its installed package path and your `userId`, then load it with the same profile:
|
||||
3. Launch the same profile. The package's `dsh.bundle` activates Mem0 automatically:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
web
|
||||
```
|
||||
|
||||
4. Open the web UI and ask the agent to remember something, then recall it in a later turn.
|
||||
4. Open the web UI, select a workspace, and state a synthetic preference without mentioning Mem0. Allow extraction to finish, start a fresh session, and ask for that preference without tools. Inspect the `mem0:recall` context to confirm automatic recall.
|
||||
|
||||
The `cordis.yml` entry looks like this:
|
||||
`pnpm pack` builds the package automatically and ships its activation patch. To customize it, copy `cordis.example.yml` and launch with `web --patch /absolute/path/to/cordis.yml`. The override updates the existing bundle row:
|
||||
|
||||
```yaml
|
||||
- name: "@deepseek-ai/dsh-system-prompt"
|
||||
- name: "@deepseek-ai/dsh-tools"
|
||||
- insert:
|
||||
- id: mem0
|
||||
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from MEM0_API_KEY when omitted here.
|
||||
userId: "your-user-id"
|
||||
autoRecall: true
|
||||
autoCapture: true
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
- id: mem0
|
||||
config:
|
||||
# A config override replaces the whole config, so restate userId.
|
||||
userId: !!js process.env.MEM0_USER_ID
|
||||
memoryScope: workspace
|
||||
autoRecall: true
|
||||
autoCapture: true
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform dedicated base URL
|
||||
```
|
||||
|
||||
When upgrading from a manual installation, remove the old patch that inserts a `mem0` row; the bundle now inserts it. Keep custom settings as an override by `id`.
|
||||
|
||||
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` is a Platform base-URL override, not a switch to self-hosted Mem0 OSS.
|
||||
|
||||
## Configuration
|
||||
@@ -105,12 +106,27 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Default entity that owns the memories |
|
||||
| `memoryScope` | no | `user` | `user` shares memory across workspaces; `workspace` isolates automatic and explicit memory operations |
|
||||
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
|
||||
| `autoCapture` | no | `true` | Store completed human and assistant turns |
|
||||
|
||||
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; `userId` defaults to the configured user, while `agentId` and `runId` are omitted unless supplied. Automatic recall and capture use the configured user without an agent, repository, or session filter. Automatic capture preserves full redacted user and assistant message text without a per-message character cutoff.
|
||||
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; `userId` defaults to the configured user, while `agentId` and `runId` are omitted unless supplied. Automatic capture preserves full redacted user and assistant message text without a per-message character cutoff.
|
||||
|
||||
## Workspace scope
|
||||
|
||||
By default, automatic capture and recall use the configured user across all workspaces. This is intentional compatibility behavior. To isolate workspaces, set `memoryScope: workspace` as shown above. All writes then attach an `appId` derived from the canonical absolute session workspace path; all searches require its matching `app_id`. The model cannot override that workspace filter.
|
||||
|
||||
Symlink aliases share a scope. Different directories, clones, or worktrees have different scopes; moving a directory changes its scope. Missing or invalid workspace paths skip automatic memory operations and reject explicit tools without falling back to user-wide access. Existing user-only memories are not migrated into workspace scopes.
|
||||
|
||||
User scope searches all memories belonging to the user, including workspace-tagged memories. Workspace isolation applies only when workspace scope is configured; it is application filtering, not a separate Mem0 account or credential.
|
||||
|
||||
## Extraction and recall limits
|
||||
|
||||
Capture sends the full redacted turn to Mem0 for asynchronous extraction. A queued write, or even an initially nonempty memory list, does not mean every extracted fact is ready. Allow processing to finish and inspect stored memories before diagnosing lost preferences or missing filenames and numeric constraints. Exact extraction remains model-dependent; a one-off request should not automatically become a standing preference.
|
||||
|
||||
Automatic recall is a shallow first pass: up to five results, 4,000 context characters, and a two-second wait. The explicit `search_memory` tool can use a more focused query and returns up to ten results by default.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -124,6 +140,6 @@ Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to thi
|
||||
|
||||
- **`MISSING_CREDENTIAL` for `deepseek-official`**: Configure `DEEPSEEK_API_KEY` through Harness's Models page or export it in the shell that launches Harness.
|
||||
- **`EMFILE: too many open files, watch` on macOS**: Launch Harness with `CHOKIDAR_USEPOLLING=1`.
|
||||
- **Mem0 tools do not appear**: Run Harness with `--dump-config` and confirm the final composition contains the `mem0` row and the installed `dist/index.js` path.
|
||||
- **Mem0 tools do not appear**: Run Harness with `--dump-config` for the same profile used at installation and confirm it contains a `mem0` row naming `@mem0/deepseek-plugin`. Export `MEM0_USER_ID` and `MEM0_API_KEY` before launching.
|
||||
|
||||
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
---
|
||||
title: Hermes Agent
|
||||
description: "Add long-term memory to Hermes agents with Mem0: managed Platform, a self-hosted server, or fully local OSS — with current-turn recall and background fact extraction."
|
||||
description: "Add long-term memory to Hermes agents with Mem0, on managed Mem0 Cloud or fully self-hosted (OSS), with automatic background sync and zero-latency prefetch."
|
||||
---
|
||||
|
||||
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones for the current question, without slowing down the chat.
|
||||
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones before each turn, without slowing down the chat.
|
||||
|
||||
You can run Mem0 in three ways:
|
||||
You can run Mem0 in two ways:
|
||||
|
||||
- **Platform mode** (default): managed Mem0 Cloud. Add your API key and you are ready.
|
||||
- **Self-hosted server mode**: point the plugin at a Mem0 server you run yourself (the Docker-shipped server). The plugin only talks HTTP to your server.
|
||||
- **OSS mode**: run Mem0 in-process with your own LLM, embedder, and vector store. No Mem0 server required.
|
||||
- **OSS mode**: fully self-hosted with your own LLM, embedder, and vector store. No data leaves your machine.
|
||||
|
||||
## How It Works
|
||||
|
||||
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at two points in every conversation turn.
|
||||
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three points in every conversation turn.
|
||||
|
||||
### 1. Current-turn recall (bounded wait)
|
||||
### 1. Before the agent responds (prefetch)
|
||||
|
||||
When you send a message, Hermes searches your stored memories for the current question and waits up to 3 seconds for results. If they arrive in time, they are injected into the system prompt so the model can see them. If the backend is slower, Hermes skips the injection and the model can still call `mem0_search` itself — so a slow backend never blocks a turn.
|
||||
When you send a message, Hermes checks for cached Mem0 search results from the previous turn. If they exist, those memories are injected into the system prompt so the model can see them. This is zero-latency, with no waiting on an API call.
|
||||
|
||||
### 2. Background fact extraction (sync)
|
||||
### 2. After the agent responds (sync)
|
||||
|
||||
Once the model finishes, Hermes sends the `(user message, assistant response)` pair to Mem0 in a background thread. Mem0 extracts facts automatically (for example, "user prefers Python" or "user works at Acme Corp"), so you never have to tell it what to remember. Each write is tagged with the gateway channel it came from.
|
||||
|
||||
### 3. Background prefetch for the next turn
|
||||
|
||||
At the same time, Hermes runs a background search to pre-load relevant memories for your next message. By the time you type, the results are already cached.
|
||||
|
||||
## Agent Tools
|
||||
|
||||
When Mem0 is active, the model gets four tools it can call during a conversation:
|
||||
When Mem0 is active, the model gets five tools it can call during a conversation:
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
|------|-------------|------------|
|
||||
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `false`, Platform mode only) |
|
||||
| `mem0_list` | List all stored memories, for a full overview | `page`, `page_size` (default 100, max 200) |
|
||||
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `true`, Platform mode only) |
|
||||
| `mem0_add` | Store a fact verbatim, with no LLM extraction | `content` (required) |
|
||||
| `mem0_update` | Update a memory's text by ID | `memory_id`, `text` (both required) |
|
||||
| `mem0_delete` | Delete a memory by ID | `memory_id` (required) |
|
||||
@@ -75,36 +79,6 @@ memory:
|
||||
|
||||
That's it. Mem0 runs automatically from here.
|
||||
|
||||
## Self-Hosted Server Setup
|
||||
|
||||
Run the [Mem0 server](https://github.com/mem0ai/mem0/tree/main/server) (FastAPI + pgvector) from its Docker image and point the plugin at it. Unlike OSS mode, the plugin just talks HTTP to your server.
|
||||
|
||||
### Interactive
|
||||
|
||||
```bash
|
||||
hermes memory setup
|
||||
# Select "mem0", then "Self-hosted server", and enter the server URL
|
||||
```
|
||||
|
||||
### With flags
|
||||
|
||||
```bash
|
||||
hermes memory setup mem0 --mode selfhosted \
|
||||
--host http://localhost:8888 \
|
||||
--api-key your-admin-api-key
|
||||
```
|
||||
|
||||
### With environment variables
|
||||
|
||||
```bash
|
||||
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
|
||||
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
Then start a fresh Hermes session and call `mem0_search` — it connects to your server. The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. The API key is optional only for servers running with `AUTH_DISABLED`.
|
||||
|
||||
<Note>Setting `host` routes to the self-hosted server automatically. Don't combine it with `mode: oss` — OSS takes precedence and ignores `host`.</Note>
|
||||
|
||||
## OSS (Self-Hosted) Setup
|
||||
|
||||
OSS mode runs Mem0 entirely on your own infrastructure: your LLM, your embedder, and your vector store. No data is sent to Mem0 Cloud, and no Mem0 API key is required.
|
||||
@@ -137,20 +111,15 @@ hermes memory setup mem0 --mode oss \
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--mode` | `platform`, `selfhosted`, or `oss` |
|
||||
| `--api-key` | Platform API key, or the admin key of a self-hosted server |
|
||||
| `--host` | Self-hosted server URL (with `--mode selfhosted`) |
|
||||
| `--mode` | `platform` or `oss` |
|
||||
| `--oss-llm` | LLM provider (`openai` or `ollama`, default `openai`) |
|
||||
| `--oss-llm-key` | LLM API key (for `openai`) |
|
||||
| `--oss-llm-model` | Override the LLM model |
|
||||
| `--oss-llm-url` | LLM base URL (for `ollama` or a custom endpoint) |
|
||||
| `--oss-embedder` | Embedder provider (default `openai`) |
|
||||
| `--oss-embedder-key` | Embedder API key |
|
||||
| `--oss-embedder-model` | Override the embedder model |
|
||||
| `--oss-embedder-url` | Embedder base URL (for `ollama` or a custom endpoint) |
|
||||
| `--oss-vector` | Vector store (`qdrant` or `pgvector`, default `qdrant`) |
|
||||
| `--oss-vector-path` | Local Qdrant storage path |
|
||||
| `--oss-vector-url` | Qdrant server URL |
|
||||
| `--oss-vector-host`, `--oss-vector-port` | PGVector or remote Qdrant host and port |
|
||||
| `--oss-vector-user`, `--oss-vector-password`, `--oss-vector-dbname` | PGVector connection details |
|
||||
| `--user-id` | Canonical user identifier |
|
||||
@@ -158,7 +127,7 @@ hermes memory setup mem0 --mode oss \
|
||||
|
||||
## Switching Modes
|
||||
|
||||
You can move between the three modes at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
|
||||
You can move between Platform and OSS at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
|
||||
|
||||
```bash
|
||||
# Platform to OSS
|
||||
@@ -167,9 +136,6 @@ hermes memory setup mem0 --mode oss --oss-llm-key sk-...
|
||||
# OSS to Platform
|
||||
hermes memory setup mem0 --mode platform --api-key sk-...
|
||||
|
||||
# Platform to a self-hosted server
|
||||
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888
|
||||
|
||||
# Preview without writing anything
|
||||
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
|
||||
```
|
||||
@@ -180,7 +146,7 @@ A self-hosted `~/.hermes/mem0.json` looks like this:
|
||||
{
|
||||
"mode": "oss",
|
||||
"oss": {
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini", "is_reasoning_model": true}},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
|
||||
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
|
||||
"vector_store": {"provider": "qdrant", "config": {"path": "~/.hermes/mem0_qdrant"}}
|
||||
}
|
||||
@@ -193,12 +159,11 @@ Behavioral settings live in `~/.hermes/mem0.json` and are written for you by `he
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process). Self-hosted server routing is set via `host` |
|
||||
| `host` | none | Self-hosted Mem0 server URL. When set, the plugin talks HTTP to your server instead of the cloud |
|
||||
| `api_key` | none | Mem0 Platform API key, or the admin key of a self-hosted server. Stored in `.env` as `MEM0_API_KEY` |
|
||||
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
|
||||
| `api_key` | none | Mem0 Platform API key, required in Platform mode. Stored in `.env` as `MEM0_API_KEY` |
|
||||
| `user_id` | `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
|
||||
| `agent_id` | `hermes` | Agent identifier attached to writes |
|
||||
| `rerank` | `false` | Rerank search results for relevance (Platform mode only) |
|
||||
| `rerank` | `true` | Rerank search results for relevance (Platform mode only) |
|
||||
|
||||
### Cross-channel memories
|
||||
|
||||
@@ -209,11 +174,12 @@ Hermes can run from the CLI and from gateways like Telegram, Slack, and Discord.
|
||||
|
||||
Either way, every write is tagged with `metadata.channel` (for example `telegram` or `cli`), so per-channel views are still possible at query time.
|
||||
|
||||
|
||||
## Reliability
|
||||
|
||||
- **Circuit breaker**: if Mem0 fails five times in a row, Hermes pauses calls for two minutes, then retries. The agent keeps working without memory during that window. Expected client errors, like a 404 on a missing memory id, do not count toward tripping the breaker.
|
||||
- **Non-blocking**: fact extraction runs in a background daemon thread, and current-turn recall waits at most 3 seconds, so a slow or failed call never blocks your conversation.
|
||||
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and recall threads are guarded so concurrent gateway messages cannot produce duplicate memories.
|
||||
- **Non-blocking**: every Mem0 call runs in a background daemon thread, so a slow or failed call never blocks your conversation.
|
||||
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and prefetch threads are guarded so concurrent gateway messages cannot produce duplicate memories.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -222,7 +188,6 @@ Either way, every write is tagged with `metadata.channel` (for example `telegram
|
||||
The circuit breaker tripped after five consecutive failures and resets after two minutes.
|
||||
|
||||
- **Platform mode**: check your API key and internet connection.
|
||||
- **Self-hosted server mode**: check that the server is running and reachable at the configured `host` URL.
|
||||
- **OSS mode**: make sure your vector store (Qdrant or PGVector) is running and reachable.
|
||||
|
||||
### OSS: vector store connection refused
|
||||
@@ -252,8 +217,8 @@ curl http://localhost:11434/api/tags
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Three ways to run**: managed Platform, a self-hosted server, or fully local OSS, switchable at any time.
|
||||
2. **Current-turn recall**: memories for the current question are injected within a 3-second window, with `mem0_search` as the model's own backstop.
|
||||
1. **Two ways to run**: managed Platform or fully self-hosted OSS, switchable at any time.
|
||||
2. **Zero-latency recall**: memories are prefetched in the background and cached before you type.
|
||||
3. **Automatic extraction**: Mem0 extracts and deduplicates facts from each exchange for you.
|
||||
4. **Non-blocking and fault tolerant**: background threads plus a circuit breaker keep the agent responsive even when Mem0 is unreachable.
|
||||
5. **Additive memory**: works alongside Hermes' built-in file memory (`MEMORY.md`, `USER.md`).
|
||||
|
||||
@@ -197,7 +197,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
|
||||
### Features - Essential
|
||||
- [V2 Memory Filters](https://docs.mem0.ai/platform/features/v2-memory-filters) [Platform]: Use when compound filters (AND/OR on metadata, entity, time) are needed at search.
|
||||
- [Entity-Scoped Memory](https://docs.mem0.ai/platform/features/entity-scoped-memory) [Platform]: Use when partitioning memories by user, agent, app, or run.
|
||||
- [Profiles](https://docs.mem0.ai/platform/features/user-profiles) [Platform]: Use when a structured always-current summary of a user is needed in one read, instead of searching their memories.
|
||||
- [Graph Memory](https://docs.mem0.ai/platform/features/graph-memory) [Platform]: Use when connecting facts across memories through shared entities for entity-centric or multi-hop questions.
|
||||
- [Async Client](https://docs.mem0.ai/platform/features/async-client) [Platform]: Use when the app issues many concurrent Mem0 calls and needs non-blocking I/O.
|
||||
- [Multimodal Support](https://docs.mem0.ai/platform/features/multimodal-support) [Platform]: Use when storing images or PDFs as memory input.
|
||||
@@ -363,11 +362,6 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key).
|
||||
### Entities
|
||||
- [Get Users](https://docs.mem0.ai/api-reference/entities/get-users) [Platform]: Use when listing users, agents, or apps known to a project.
|
||||
- [Delete User](https://docs.mem0.ai/api-reference/entities/delete-user) [Platform]: Use when removing an entity and all its memories.
|
||||
- [Get Profile](https://docs.mem0.ai/api-reference/profiles/get-profile) [Platform]: Use when reading a user's structured profile and branching on its generation status.
|
||||
- [Get Profile Settings](https://docs.mem0.ai/api-reference/profiles/get-profile-settings) [Platform]: Use when checking the project's profile schema, instructions, or enabled flag.
|
||||
- [Update Profile Settings](https://docs.mem0.ai/api-reference/profiles/update-profile-settings) [Platform]: Use when defining or changing the JSON Schema that shapes profiles for a project.
|
||||
- [Generate Profiles](https://docs.mem0.ai/api-reference/profiles/generate-profiles) [Platform]: Use when building profiles now: a sample of ten, or one entity.
|
||||
- [Get Generation Job](https://docs.mem0.ai/api-reference/profiles/get-profile-job) [Platform]: Use when checking how far a generation has got, and whether it finished.
|
||||
|
||||
### Organizations
|
||||
- [Create Organization](https://docs.mem0.ai/api-reference/organization/create-org) [Platform]: Use when setting up a new org.
|
||||
|
||||
+1
-507
@@ -8070,512 +8070,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/entities/{entity_type}/{entity_id}/profile/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_read",
|
||||
"summary": "Get an entity's profile",
|
||||
"description": "Return the memory profile for one user.\n\nGeneration is asynchronous, so a known entity that has no profile yet is a normal 200 carrying a `status`. A 404 means only that no such entity exists.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "entity_type",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"description": "The kind of entity that carries the profile."
|
||||
},
|
||||
{
|
||||
"name": "entity_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The entity's id, as supplied when the memory was added."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The profile envelope.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "The generated profile, shaped by the project's schema. Empty unless status is succeeded."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"succeeded",
|
||||
"pending",
|
||||
"failed",
|
||||
"not_enabled",
|
||||
"insufficient_data"
|
||||
],
|
||||
"description": "Generation state. Branch on this rather than on an empty profile."
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"entity_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"generation_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unsupported entity type."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such entity in this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/profiles/settings/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_settings_read",
|
||||
"summary": "Get profile settings",
|
||||
"description": "Return the profile settings for the project the API key is scoped to.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current settings.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether profile generation runs for this project. Project-wide."
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"estimates": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"samples": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"full_rebuild": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_settings_update",
|
||||
"summary": "Update profile settings",
|
||||
"description": "Update the project's profile settings. Only the fields present in the body are written, so one setting can change without re-sending the others.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether profile generation runs for this project. Project-wide."
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step. Send null to clear it."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Settings as stored after the update.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether profile generation runs for this project. Project-wide."
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"estimates": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"samples": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"full_rebuild": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The schema is not a valid profile schema."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/profiles/jobs/": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_create_job",
|
||||
"summary": "Generate profiles",
|
||||
"description": "Start one generation. `operation` says what to build:\n\n- `sample` — up to 10 real entities, so a schema can be judged before it is used widely. These are real profiles: they are saved to those entities and count toward usage.\n- `trigger` — one entity, named by `entity_id`.\n\nSend an `Idempotency-Key` header. Replaying the same key returns the same job instead of charging twice. Poll `status_url` from the response until the status is terminal.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "Idempotency-Key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"description": "Makes a retry safe: the same key returns the same job."
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"operation",
|
||||
"entity_type"
|
||||
],
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sample",
|
||||
"trigger"
|
||||
],
|
||||
"description": "What to generate. Optional only when `entity_id` is set, which means `trigger`."
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"entity_id": {
|
||||
"type": "string",
|
||||
"description": "One entity, for `trigger`."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"description": "How many entities to sample."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Job accepted.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"status_url": {
|
||||
"type": "string",
|
||||
"description": "Poll this. Building the path yourself breaks on a route change."
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_count_reserved": {
|
||||
"type": "integer",
|
||||
"description": "Entities reserved against usage for this job."
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"replayed": {
|
||||
"type": "boolean",
|
||||
"description": "True when an Idempotency-Key returned an existing job."
|
||||
},
|
||||
"sampled": {
|
||||
"type": "integer",
|
||||
"description": "`sample` only."
|
||||
},
|
||||
"entity_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "`sample` only: the entity ids picked. Read each with `GET /v2/entities/user/{entity_id}/profile/`."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unknown or missing `operation`, or profiles are not configured."
|
||||
},
|
||||
"402": {
|
||||
"description": "Payment required."
|
||||
},
|
||||
"409": {
|
||||
"description": "A job is already running, or the Idempotency-Key was used for a different request. Branch on `error.code`."
|
||||
},
|
||||
"429": {
|
||||
"description": "Cooldown. `retry_after_seconds` sits inside `error`."
|
||||
},
|
||||
"503": {
|
||||
"description": "`jobs_unavailable` — generation is switched off for this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/profiles/jobs/{job_id}/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_get_job",
|
||||
"summary": "Read a generation job",
|
||||
"description": "The job nests under `job`. `total` is null until `enumeration_complete`, and `completed` is `succeeded + failed + skipped`.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "job_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The job.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"QUEUED",
|
||||
"RUNNING",
|
||||
"SUCCEEDED",
|
||||
"PARTIALLY_SUCCEEDED",
|
||||
"FAILED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
},
|
||||
"enumeration_complete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"completed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"succeeded": {
|
||||
"type": "integer"
|
||||
},
|
||||
"failed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such job in this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -9494,4 +8988,4 @@
|
||||
}
|
||||
},
|
||||
"x-original-swagger-version": "2.0"
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
---
|
||||
title: Profiles
|
||||
description: "Build a structured, always-current summary of each user from their memories, shaped by a JSON Schema you define."
|
||||
---
|
||||
|
||||
# Profiles
|
||||
|
||||
Memories are individual facts. A profile is the summary of all of them for one entity: a single structured object, shaped by a JSON Schema you define, that Mem0 keeps current as new memories arrive.
|
||||
|
||||
Search answers "what did this user say about X". A profile answers "who is this user", in one read, with no query to write.
|
||||
|
||||
<Info>
|
||||
**Use profiles when…**
|
||||
- You want to personalize a first response, before the user says anything in this session.
|
||||
- You need a compact object to drop into a prompt instead of a list of memories.
|
||||
- You want the same fields for every user, so your code can rely on their shape.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
User Profiles are in **beta** and available on request. To enable them for your
|
||||
organization, contact [support@mem0.ai](mailto:support@mem0.ai).
|
||||
</Note>
|
||||
|
||||
## How it works
|
||||
|
||||
1. You define a **schema**: the fields a profile should contain, each with a description.
|
||||
2. Mem0 builds each entity's profile from their memories, and rebuilds it as new memories arrive.
|
||||
3. You read the profile whenever you need it.
|
||||
|
||||
Generation is **asynchronous**. A profile is not ready the instant an entity's first memory lands, so a read tells you where it is with a `status` rather than failing.
|
||||
|
||||
## Define the schema
|
||||
|
||||
The schema is JSON Schema. Every property needs a `description` — that is what tells the model how to fill the field, so a vague description gives a vague profile.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
client.update_profile_settings(
|
||||
enabled=True,
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"communication_style": {
|
||||
"type": "string",
|
||||
"description": "How the user prefers to be addressed: terse, detailed, formal, casual",
|
||||
},
|
||||
"expertise_areas": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Subjects the user demonstrates working knowledge of",
|
||||
},
|
||||
"current_goals": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "What the user is actively trying to accomplish",
|
||||
},
|
||||
},
|
||||
},
|
||||
custom_instructions="Prefer durable traits over one-off remarks.",
|
||||
)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import MemoryClient from "mem0ai";
|
||||
|
||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
||||
|
||||
await client.updateProfileSettings({
|
||||
enabled: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
communication_style: {
|
||||
type: "string",
|
||||
description:
|
||||
"How the user prefers to be addressed: terse, detailed, formal, casual",
|
||||
},
|
||||
expertise_areas: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Subjects the user demonstrates working knowledge of",
|
||||
},
|
||||
current_goals: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What the user is actively trying to accomplish",
|
||||
},
|
||||
},
|
||||
},
|
||||
customInstructions: "Prefer durable traits over one-off remarks.",
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
Your schema's property names reach the API exactly as you write them. The SDKs do not rewrite them, so a profile always comes back with the field names you chose.
|
||||
</Note>
|
||||
|
||||
Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone.
|
||||
|
||||
## Read a profile
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
result = client.get_profile("alice")
|
||||
|
||||
if result["status"] == "succeeded":
|
||||
print(result["profile"])
|
||||
else:
|
||||
print("not ready:", result["status"])
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const result = await client.getProfile({ entityId: "alice" });
|
||||
|
||||
if (result.status === "succeeded") {
|
||||
console.log(result.profile);
|
||||
} else {
|
||||
console.log("not ready:", result.status);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
A response looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": {
|
||||
"communication_style": "terse",
|
||||
"expertise_areas": ["distributed systems", "postgres"],
|
||||
"current_goals": ["cut p99 latency", "migrate off the legacy queue"]
|
||||
},
|
||||
"status": "succeeded",
|
||||
"entity_type": "user",
|
||||
"entity_id": "alice",
|
||||
"updated_at": "2026-02-08T10:30:00Z",
|
||||
"generation_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
`generation_count` is how many times this profile has been (re)generated — `0` before the first generation completes.
|
||||
|
||||
### Always branch on `status`
|
||||
|
||||
`profile` is empty unless `status` is `succeeded`. Check the status rather than the emptiness of the object, so a profile that is merely still building is not mistaken for a user you know nothing about.
|
||||
|
||||
| `status` | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| `succeeded` | Profile is built and current | Use it |
|
||||
| `pending` | Generation is queued or running | Read again shortly |
|
||||
| `insufficient_data` | Not enough memories to say anything yet | Fall back to defaults |
|
||||
| `not_enabled` | Profiles are off for this project | Enable them in settings |
|
||||
| `failed` | The last generation did not complete | Retry, or trigger a new one |
|
||||
|
||||
A `404` means only that no such entity exists in your project.
|
||||
|
||||
## Generate a profile on demand
|
||||
|
||||
Profiles are built once an entity has accumulated enough messages, so a brand-new user has none during their first few interactions. Trigger one directly to close that gap:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.generate_profile("alice")
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
await client.generateProfile({ entityId: "alice" });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The call returns as soon as the work is queued. Poll the read endpoint and branch on `status`.
|
||||
|
||||
## Test a schema before applying it
|
||||
|
||||
A schema that reads well can still produce disappointing profiles. Sample a few real entities and inspect the output before committing to it.
|
||||
|
||||
Sampling is asynchronous: the call returns a job as soon as it is queued. Poll `status_url` until the job is terminal, then read each sampled entity's profile:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
import time
|
||||
|
||||
job = client.sample_profiles(limit=5)
|
||||
|
||||
# Poll until the sample job reaches a terminal state (job status is UPPERCASE).
|
||||
TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"}
|
||||
deadline = time.time() + 120
|
||||
while True:
|
||||
status = client.get_profile_job(job["status_url"])["job"]
|
||||
if status["status"] in TERMINAL:
|
||||
break
|
||||
if time.time() > deadline:
|
||||
raise TimeoutError("Sample job did not finish in time")
|
||||
time.sleep(3)
|
||||
|
||||
print(status["status"], status["succeeded"], "of", status["total"])
|
||||
|
||||
# The create response lists the sampled entities; read each one's saved profile.
|
||||
for entity_id in job.get("entity_ids", []):
|
||||
print(client.get_profile(entity_id))
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const job = await client.sampleProfiles({ limit: 5 });
|
||||
|
||||
// Poll until the sample job reaches a terminal state (job status is UPPERCASE).
|
||||
const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"];
|
||||
const deadline = Date.now() + 120_000;
|
||||
let status;
|
||||
while (true) {
|
||||
status = (await client.getProfileJob(job.statusUrl)).job;
|
||||
if (TERMINAL.includes(status.status)) break;
|
||||
if (Date.now() > deadline)
|
||||
throw new Error("Sample job did not finish in time");
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
console.log(status.status, status.succeeded, "of", status.total);
|
||||
|
||||
// The create response lists the sampled entities; read each one's saved profile.
|
||||
for (const entityId of job.entityIds ?? []) {
|
||||
console.log(await client.getProfile({ entityId }));
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
These are real generations. The profiles are saved to those entities and count toward your usage, so sampling is not wasted work and not a free dry run. A sample covers up to 10 entities and cannot be repeated immediately.
|
||||
|
||||
## Apply a new schema to existing entities
|
||||
|
||||
A new schema shapes the next generation. Profiles that already exist keep their values until their entity is generated again.
|
||||
|
||||
Each entity picks the new schema up as it sends more memories, and you can generate one now with `generate_profile`.
|
||||
|
||||
<Note>
|
||||
Rebuilding every profile in a project at once is not available yet. Refresh profiles one entity at a time with `generate_profile`, or let each one update on its own as its entity sends more memories.
|
||||
</Note>
|
||||
|
||||
## When profiles update
|
||||
|
||||
You never call an "update profile" endpoint — Mem0 keeps each profile current for you. Two things drive it:
|
||||
|
||||
- **Automatically, as memories accumulate.** Mem0 refreshes an entity's profile after roughly every **10 messages** it receives, folding the new memories into the existing profile. There is no schedule to wait for and no extra call to make: the same `add` you already do keeps the profile moving.
|
||||
- **On demand.** Call `generate_profile` to build or refresh a profile immediately — useful for a brand-new entity that has not yet crossed the automatic threshold.
|
||||
|
||||
Generation is **asynchronous and incremental**. A refresh runs in the background a short while after its trigger, so a read taken immediately after an `add` may still show the previous profile (or `pending`). Branch on `status` rather than assuming the latest memory is already reflected.
|
||||
|
||||
<Note>
|
||||
Updates are **incremental**, not a full rebuild each time — Mem0 merges what it newly learns into the stored profile and keeps the fields your schema still defines. After a schema change, existing profiles pick it up as their entities send more memories, or when you call `generate_profile` — see [Apply a new schema to existing entities](#apply-a-new-schema-to-existing-entities).
|
||||
</Note>
|
||||
|
||||
## Use a profile in a prompt
|
||||
|
||||
The point of the structure is that it drops straight into a prompt:
|
||||
|
||||
```python
|
||||
result = client.get_profile(user_id)
|
||||
|
||||
if result["status"] == "succeeded":
|
||||
profile = result["profile"]
|
||||
system_prompt = f"""You are helping {user_id}.
|
||||
Communication style: {profile.get("communication_style", "unknown")}
|
||||
Areas of expertise: {", ".join(profile.get("expertise_areas", []))}
|
||||
Current goals: {", ".join(profile.get("current_goals", []))}
|
||||
|
||||
Match their style and do not explain what they already know."""
|
||||
else:
|
||||
system_prompt = "You are a helpful assistant."
|
||||
```
|
||||
|
||||
## Writing a schema that works
|
||||
|
||||
- **Describe every field.** The description is the instruction; without it the model guesses.
|
||||
- **Prefer durable traits.** "Prefers dark mode" ages well; "is annoyed today" does not.
|
||||
- **Keep it small.** Ten focused fields beat forty speculative ones, and cost less to generate.
|
||||
- **Say what the field is not.** A description that rules out the near-miss interpretation is worth more than one that only states the obvious.
|
||||
- **Sample before you commit.** It is the only way to see what your descriptions actually produce.
|
||||
|
||||
<Note>
|
||||
A schema has a size budget of roughly **10,000 tokens** of serialized JSON — the whole schema is sent to the model on every generation, so a handful of verbose fields can cost more than many terse ones. Oversized schemas are rejected on save.
|
||||
</Note>
|
||||
|
||||
## Availability
|
||||
|
||||
The feature is in beta and enabled per organization on request — see the note at the top of this page.
|
||||
|
||||
Once it is on, an entity gets a profile when two more things hold:
|
||||
|
||||
- profiles are **enabled** with a schema for the project (see [Define the schema](#define-the-schema)), and
|
||||
- the memory is scoped to an entity — a `user_id`.
|
||||
|
||||
On a project where profiles are turned off, a read returns `status: not_enabled` rather than an error, so you can call it unconditionally and branch on the status.
|
||||
|
||||
## Settings reference
|
||||
|
||||
| Argument | Type | Description |
|
||||
|---|---|---|
|
||||
| `enabled` | boolean | Whether profile generation runs for the project |
|
||||
| `schema` | object | JSON Schema describing the profile. Every property needs a `description` |
|
||||
| `custom_instructions` | string | Extra guidance applied during extraction |
|
||||
|
||||
`enabled` is project-wide. `schema` and `custom_instructions` apply to user
|
||||
profiles, so the stored settings nest them under `entities`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"entities": {
|
||||
"user": {
|
||||
"schema": { "type": "object", "properties": { "...": {} } },
|
||||
"custom_instructions": "Prefer durable traits over one-off remarks."
|
||||
}
|
||||
},
|
||||
"capabilities": { "full_rebuild": false }
|
||||
}
|
||||
```
|
||||
|
||||
That is what a read returns and what a write accepts. The SDKs take the fields
|
||||
flat and nest them for you, so a schema you write with
|
||||
`update_profile_settings` comes back unchanged from `get_profile_settings`.
|
||||
|
||||
<Note>
|
||||
Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
|
||||
</Note>
|
||||
|
||||
## FAQ
|
||||
|
||||
**Do I need to change my `add` or `search` calls to use profiles?**
|
||||
No. Profiles are built from the memories you already add. You define a schema once and read the profile when you need it — your ingestion and retrieval code is unchanged.
|
||||
|
||||
**Why is `profile` empty even though the entity has memories?**
|
||||
Generation is asynchronous and needs enough to work with. Branch on `status`: `pending` means it is still building, and `insufficient_data` means there are not yet enough memories to fill the schema. Read again shortly, or call `generate_profile` to build one now.
|
||||
|
||||
**Is sampling free?**
|
||||
No. `sample_profiles` runs real generations against real memories and **keeps** the profiles it produces, so it counts toward your usage like any other generation. It exists to check a schema on a few entities before you commit to it — not as a zero-cost dry run.
|
||||
|
||||
**Does changing the schema rewrite existing profiles?**
|
||||
No. A schema change applies to the next generation. An existing profile keeps its values until its entity is generated again, which happens as that entity sends more memories, or when you call `generate_profile` for it.
|
||||
|
||||
**What happens to a field I remove from the schema?**
|
||||
It stops being maintained. On an entity's next generation, fields your schema no longer defines are pruned from the stored profile — so keep a field in the schema for as long as you want its value kept.
|
||||
|
||||
**How current is a profile?**
|
||||
It refreshes automatically as memories accumulate (about every 10 messages for an entity), plus any on-demand `generate_profile` calls. Because refreshes run in the background, expect a short delay after the triggering `add` rather than an instant update.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Entity-Scoped Memory" icon="users" href="/platform/features/entity-scoped-memory">
|
||||
How users, agents, apps and runs partition memories.
|
||||
</Card>
|
||||
<Card title="Custom Instructions" icon="pen" href="/platform/features/custom-instructions">
|
||||
Steer what Mem0 extracts in the first place.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -1,743 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# User Profiles — live demo\n",
|
||||
"\n",
|
||||
"A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n",
|
||||
"user's memories, shaped by a JSON Schema you supply.\n",
|
||||
"\n",
|
||||
"Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n",
|
||||
"user\"*, in one read, with no query to write — and it is available on the first turn of a\n",
|
||||
"session, before the user has said anything.\n",
|
||||
"\n",
|
||||
"**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n",
|
||||
"generated from them, add 6 more turns that contradict the first set, and watch the\n",
|
||||
"profile rewrite itself. Then it shows every way the API says no.\n",
|
||||
"\n",
|
||||
"**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n",
|
||||
"\n",
|
||||
"> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n",
|
||||
"> production. The cells below read both from the environment.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# This notebook drives the SDK from this worktree, not the published mem0ai:\n",
|
||||
"# the profile fixes below are not released yet.\n",
|
||||
"%pip install -q -e ../..\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Define the schema\n",
|
||||
"\n",
|
||||
"The schema is handed to the model as a **tool definition**, and each field's\n",
|
||||
"`description` is the only instruction the model gets about what belongs there. An\n",
|
||||
"undescribed field is a field the model guesses at.\n",
|
||||
"\n",
|
||||
"Rules worth knowing:\n",
|
||||
"\n",
|
||||
"- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n",
|
||||
" it would bill you to extract nothing\n",
|
||||
"- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n",
|
||||
" they name the storage envelope, so a schema using them could not be read back\n",
|
||||
" unambiguously\n",
|
||||
"- keep it small. The whole schema is sent to the model on every generation\n",
|
||||
"\n",
|
||||
"Descriptions are **not** enforced on write in this build — a property without one is\n",
|
||||
"accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n",
|
||||
"Treat descriptions as your job, not the validator's.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"SCHEMA = {\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\": {\n",
|
||||
" \"occupation\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"description\": \"The person's current job title, in one short phrase.\",\n",
|
||||
" },\n",
|
||||
" \"location\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"description\": \"The city or region the person currently lives in.\",\n",
|
||||
" },\n",
|
||||
" \"interests\": {\n",
|
||||
" \"type\": \"array\",\n",
|
||||
" \"items\": {\"type\": \"string\"},\n",
|
||||
" \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n",
|
||||
" },\n",
|
||||
" \"dietary_restrictions\": {\n",
|
||||
" \"type\": \"array\",\n",
|
||||
" \"items\": {\"type\": \"string\"},\n",
|
||||
" \"description\": \"Foods the person avoids, and why, if they said.\",\n",
|
||||
" },\n",
|
||||
" \"communication_style\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n",
|
||||
" \"description\": \"How this person prefers to be answered.\",\n",
|
||||
" },\n",
|
||||
" \"expertise_level\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n",
|
||||
" \"description\": \"Their technical depth, judged from how they discuss their work.\",\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"settings = client.update_profile_settings(\n",
|
||||
" enabled=True,\n",
|
||||
" schema=SCHEMA,\n",
|
||||
" custom_instructions=(\n",
|
||||
" \"Prefer facts the person stated outright over anything inferred. \"\n",
|
||||
" \"Leave a field empty rather than guessing.\"\n",
|
||||
" ),\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Sorted, because JSONB storage does not preserve the key order you sent.\n",
|
||||
"# Compare a stored schema by SET, never by string or by key order.\n",
|
||||
"stored = settings[\"entities\"][\"user\"][\"schema\"]\n",
|
||||
"print(\"schema fields:\", sorted(stored[\"properties\"]))\n",
|
||||
"print(\"enabled :\", settings[\"enabled\"])\n",
|
||||
"print(\"capabilities :\", settings[\"capabilities\"])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"`enabled` is project-wide; `schema` and `custom_instructions` apply to user profiles\n",
|
||||
"and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n",
|
||||
"you write comes back unchanged from `get_profile_settings()`.\n",
|
||||
"\n",
|
||||
"Only the arguments you pass are written. To turn the feature off without touching your\n",
|
||||
"schema, send `enabled` alone.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. The turns\n",
|
||||
"\n",
|
||||
"Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n",
|
||||
"side effect of the normal pipeline.\n",
|
||||
"\n",
|
||||
"Twelve, not five, because generation fires when an entity crosses a **10-message\n",
|
||||
"boundary**. Below that it waits for a flush window measured in hours, and this notebook\n",
|
||||
"would sit there.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"TURNS = [\n",
|
||||
" (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n",
|
||||
" (\"assistant\", \"Congratulations! What's the new role?\"),\n",
|
||||
" (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n",
|
||||
" (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n",
|
||||
" (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n",
|
||||
" (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n",
|
||||
" (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n",
|
||||
" (\"assistant\", \"Understood — short answers from here.\"),\n",
|
||||
" (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n",
|
||||
" (\"assistant\", \"Bouldering or ropes?\"),\n",
|
||||
" (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n",
|
||||
" (\"assistant\", \"Noted, vegetarian only.\"),\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"response = client.add(\n",
|
||||
" [{\"role\": r, \"content\": c} for r, c in TURNS],\n",
|
||||
" user_id=USER_ID,\n",
|
||||
")\n",
|
||||
"print(json.dumps(response, indent=2)[:300])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n",
|
||||
"`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n",
|
||||
"you know the add finished. Then let the extracted memories settle.\n",
|
||||
"\n",
|
||||
"Under load this can take a minute or more, so the cell says plainly whether it ran out of\n",
|
||||
"time rather than printing `0 memories` as though that were the answer.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"event_id = response[\"event_id\"]\n",
|
||||
"deadline = time.time() + 300\n",
|
||||
"\n",
|
||||
"# 1. The add itself. Terminal status, not a sleep.\n",
|
||||
"event_status = None\n",
|
||||
"while time.time() < deadline:\n",
|
||||
" event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n",
|
||||
" if event_status in (\"SUCCEEDED\", \"FAILED\"):\n",
|
||||
" break\n",
|
||||
" print(f\" add {event_status}\")\n",
|
||||
" time.sleep(5)\n",
|
||||
"print(f\"add finished: {event_status}\")\n",
|
||||
"assert event_status != \"FAILED\", \"the add itself failed — nothing downstream can work\"\n",
|
||||
"\n",
|
||||
"# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n",
|
||||
"# Wait for the count to stop growing instead of breaking on the first result.\n",
|
||||
"memories, stable = [], 0\n",
|
||||
"while time.time() < deadline:\n",
|
||||
" page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n",
|
||||
" found = page.get(\"results\", []) if isinstance(page, dict) else page\n",
|
||||
" stable = stable + 1 if found and len(found) == len(memories) else 0\n",
|
||||
" memories = found\n",
|
||||
" if stable >= 2: # two identical polls in a row\n",
|
||||
" break\n",
|
||||
" print(f\" ... {len(memories)} so far\")\n",
|
||||
" time.sleep(5)\n",
|
||||
"\n",
|
||||
"if memories:\n",
|
||||
" print(f\"\\n{len(memories)} memories extracted:\\n\")\n",
|
||||
" for m in memories:\n",
|
||||
" print(\" \\u2022\", m.get(\"memory\"))\n",
|
||||
"else:\n",
|
||||
" # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n",
|
||||
" # and makes the profile below look like it came from nothing.\n",
|
||||
" print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n",
|
||||
" print(\"worker is down. Everything below will report insufficient_data.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Read the profile\n",
|
||||
"\n",
|
||||
"Crossing the 10-message boundary should already have queued a generation. Read first —\n",
|
||||
"and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n",
|
||||
"distinction is the whole point of the envelope.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"envelope = client.get_profile(USER_ID)\n",
|
||||
"print(json.dumps(envelope, indent=2))\n",
|
||||
"\n",
|
||||
"print(\"\\nstatus vocabulary:\")\n",
|
||||
"print(\" succeeded terminal — a generation ran AND the profile has content\")\n",
|
||||
"print(\" pending queued or running\")\n",
|
||||
"print(\" failed terminal — the last generation did not complete\")\n",
|
||||
"print(\" not_enabled feature off, or plan below Pro\")\n",
|
||||
"print(\" insufficient_data no content to show: no row yet, queued, or a\")\n",
|
||||
"print(\" generation that legitimately found nothing\")\n",
|
||||
"print()\n",
|
||||
"print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n",
|
||||
"print(\"empty extraction still increments the counter, so counting generations\")\n",
|
||||
"print(\"reports 'done' for a profile with nothing in it.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Force it, rather than waiting\n",
|
||||
"\n",
|
||||
"`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n",
|
||||
"until their tenth message. One entity, a few seconds.\n",
|
||||
"\n",
|
||||
"Every call is sent with a fresh `Idempotency-Key`, so a retry after a dropped connection\n",
|
||||
"returns the same job instead of paying twice.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n",
|
||||
" \"\"\"Poll until terminal.\n",
|
||||
"\n",
|
||||
" `since` waits for a generation_count ABOVE that value, which is how you wait\n",
|
||||
" for an UPDATE rather than accepting the profile you already had.\n",
|
||||
"\n",
|
||||
" `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n",
|
||||
" poll through it and give up on the timeout instead.\n",
|
||||
" \"\"\"\n",
|
||||
" deadline = time.time() + timeout\n",
|
||||
" body = None\n",
|
||||
" while time.time() < deadline:\n",
|
||||
" body = client.get_profile(entity_id)\n",
|
||||
" status = (body.get(\"status\") or \"\").lower()\n",
|
||||
" count = body.get(\"generation_count\") or 0\n",
|
||||
" fresh = count > since if since is not None else True\n",
|
||||
" if status == \"succeeded\" and fresh:\n",
|
||||
" return body\n",
|
||||
" if status in (\"failed\", \"not_enabled\"):\n",
|
||||
" raise RuntimeError(f\"generation stopped: {status}\")\n",
|
||||
" print(f\" ... {status} (generation_count={count})\")\n",
|
||||
" time.sleep(interval)\n",
|
||||
" raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
|
||||
"print(\"\\npolling...\")\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" body = wait_for_profile(USER_ID)\n",
|
||||
" print(\"\\n=== PROFILE ===\")\n",
|
||||
" print(json.dumps(body[\"profile\"], indent=2))\n",
|
||||
" print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n",
|
||||
"except TimeoutError as e:\n",
|
||||
" # Say so plainly and let the rest of the notebook skip, rather than raising\n",
|
||||
" # a NameError in every cell below and burying the real cause.\n",
|
||||
" body = None\n",
|
||||
" print(f\"\\nNO PROFILE: {e}\")\n",
|
||||
" print(\"Generation never finished. Usually the ingestion worker is down, or\")\n",
|
||||
" print(\"this project has no memories for the user yet.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# The model must not invent fields outside your schema — the forced tool call is\n",
|
||||
"# what makes that structural rather than a request.\n",
|
||||
"if body is None:\n",
|
||||
" print(\"skipped — no profile was generated above\")\n",
|
||||
"else:\n",
|
||||
" extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n",
|
||||
" print(\"fields outside the schema:\", extra or \"none\")\n",
|
||||
"\n",
|
||||
" # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n",
|
||||
" # so 'I found nothing' arrives as a type default: 0, \"\", [].\n",
|
||||
" filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n",
|
||||
" print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Now watch it update\n",
|
||||
"\n",
|
||||
"Six more turns that contradict and extend what we already know: a promotion, a move, a\n",
|
||||
"dropped hobby. A profile is a living document, not an append-only log — the model gets the\n",
|
||||
"memories and rewrites the whole thing.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if body is None:\n",
|
||||
" print(\"skipped — no profile was generated above\")\n",
|
||||
"else:\n",
|
||||
" before = body[\"generation_count\"]\n",
|
||||
"\n",
|
||||
" MORE_TURNS = [\n",
|
||||
" (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n",
|
||||
" (\"assistant\", \"Congratulations. Same team?\"),\n",
|
||||
" (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n",
|
||||
" (\"assistant\", \"Big move. How do you feel about it?\"),\n",
|
||||
" (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n",
|
||||
" (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n",
|
||||
" ]\n",
|
||||
"\n",
|
||||
" followup = client.add(\n",
|
||||
" [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n",
|
||||
" user_id=USER_ID,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" # Wait for the add to land before triggering: a generation queued before the new\n",
|
||||
" # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n",
|
||||
" deadline = time.time() + 300\n",
|
||||
" while time.time() < deadline:\n",
|
||||
" status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n",
|
||||
" if status in (\"SUCCEEDED\", \"FAILED\"):\n",
|
||||
" break\n",
|
||||
" time.sleep(5)\n",
|
||||
" print(\"follow-up add:\", status)\n",
|
||||
" time.sleep(15) # let extraction settle\n",
|
||||
"\n",
|
||||
" print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
|
||||
" print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n",
|
||||
" updated = wait_for_profile(USER_ID, since=before)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if body is None:\n",
|
||||
" print(\"skipped — no profile was generated above\")\n",
|
||||
"else:\n",
|
||||
" print(f\"{'field':<22} {'before':<34} after\")\n",
|
||||
" print(\"-\" * 92)\n",
|
||||
" for field in SCHEMA[\"properties\"]:\n",
|
||||
" b = json.dumps(body[\"profile\"].get(field))\n",
|
||||
" a = json.dumps(updated[\"profile\"].get(field))\n",
|
||||
" mark = \" \" if a == b else \"->\"\n",
|
||||
" print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Use it in a prompt\n",
|
||||
"\n",
|
||||
"The point of the structure is that it drops straight into a prompt — no list of memories\n",
|
||||
"to summarize, no query to write.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_system_prompt(entity_id):\n",
|
||||
" result = client.get_profile(entity_id)\n",
|
||||
" if result[\"status\"] != \"succeeded\":\n",
|
||||
" # Branch on status, never on an empty profile: a user whose profile is\n",
|
||||
" # still building is not a user you know nothing about.\n",
|
||||
" return \"You are a helpful assistant.\"\n",
|
||||
"\n",
|
||||
" p = result[\"profile\"]\n",
|
||||
" return f\"\"\"You are helping {entity_id}.\n",
|
||||
"Occupation: {p.get(\"occupation\", \"unknown\")}\n",
|
||||
"Location: {p.get(\"location\", \"unknown\")}\n",
|
||||
"Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n",
|
||||
"Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n",
|
||||
"Preferred style: {p.get(\"communication_style\", \"unknown\")}\n",
|
||||
"\n",
|
||||
"Match their style and do not explain what they already know.\"\"\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(build_system_prompt(USER_ID))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Judge a schema before committing to it\n",
|
||||
"\n",
|
||||
"`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n",
|
||||
"\n",
|
||||
"These are real generations and the results are **kept** — a dry run would cost exactly the\n",
|
||||
"same and leave those users no better off. It is not a free preview.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# 202, not 200: the sample generations are queued, not finished.\n",
|
||||
"#\n",
|
||||
"# A 409 `already_running` means a sample from an earlier run is still going.\n",
|
||||
"# That is the cooldown working, not an error — reuse that job rather than\n",
|
||||
"# failing the notebook.\n",
|
||||
"try:\n",
|
||||
" job = client.sample_profiles(limit=3)\n",
|
||||
" print(json.dumps(job, indent=2)[:400])\n",
|
||||
" print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n",
|
||||
"except Exception as e:\n",
|
||||
" detail = str(e)\n",
|
||||
" print(\"sample refused:\", detail[:200])\n",
|
||||
" running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n",
|
||||
" job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n",
|
||||
" print(\"reusing the running job:\", running)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n",
|
||||
"so format it defensively rather than assuming a number.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Apply a new schema to existing users\n",
|
||||
"\n",
|
||||
"A new schema shapes the **next** generation. Profiles that already exist keep their values\n",
|
||||
"until their user is generated again — which happens as that user sends more memories, or\n",
|
||||
"when you call `generate_profile()` for them.\n",
|
||||
"\n",
|
||||
"A field you **remove** stops being maintained: on the next generation, fields your schema\n",
|
||||
"no longer defines are pruned. Keep a field for as long as you want its value kept."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"# Failure scenarios\n",
|
||||
"\n",
|
||||
"Everything above is the path that works. These are the ways it says no, and what each one\n",
|
||||
"means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n",
|
||||
"\n",
|
||||
"> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n",
|
||||
"> ERROR level before raising, so they appear even for the failures these cells\n",
|
||||
"> deliberately catch. Read the line printed *after* each one — that is the cell's\n",
|
||||
"> own verdict. Nothing here is unhandled.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## F1. Schemas that get rejected\n",
|
||||
"\n",
|
||||
"Rejections happen on **write**, where you can see and fix them — not silently at\n",
|
||||
"generation time, where you would only notice as an empty profile weeks later.\n",
|
||||
"\n",
|
||||
"The last case matters for storage: the user schema lives in one JSONB column alongside\n",
|
||||
"the envelope that separates it, so a schema using the envelope's own reserved keys could\n",
|
||||
"not be read back unambiguously. It is refused rather than stored.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"BAD_SCHEMAS = [\n",
|
||||
" ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n",
|
||||
" ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n",
|
||||
" (\n",
|
||||
" {\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n",
|
||||
" # At the schema ROOT, which is where the envelope's own keys live.\n",
|
||||
" \"_profile_config_version\": 1,\n",
|
||||
" \"entities\": {\"user\": {}},\n",
|
||||
" },\n",
|
||||
" \"reserved settings keys at the schema root\",\n",
|
||||
" ),\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"for bad, why in BAD_SCHEMAS:\n",
|
||||
" try:\n",
|
||||
" client.update_profile_settings(schema=bad)\n",
|
||||
" print(f\"ACCEPTED (unexpected): {why}\")\n",
|
||||
" except Exception as e:\n",
|
||||
" print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n",
|
||||
"\n",
|
||||
"# NOT rejected: a property with no description. The validator allows it and the\n",
|
||||
"# model then has nothing to go on, so the field comes back empty. Descriptions are\n",
|
||||
"# your job, not the validator's.\n",
|
||||
"try:\n",
|
||||
" client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n",
|
||||
" print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n",
|
||||
"finally:\n",
|
||||
" client.update_profile_settings(schema=SCHEMA) # put the good one back\n",
|
||||
"\n",
|
||||
"restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n",
|
||||
"assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n",
|
||||
"print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## F2. A user that does not exist\n",
|
||||
"\n",
|
||||
"404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n",
|
||||
"`insufficient_data`, so an ordinary empty state never looks like an error.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from mem0.exceptions import MemoryNotFoundError\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" client.get_profile(\"user_who_never_existed\")\n",
|
||||
" print(\"ACCEPTED (unexpected)\")\n",
|
||||
"except MemoryNotFoundError as e:\n",
|
||||
" print(\"404 as intended:\", str(e)[:120])\n",
|
||||
"\n",
|
||||
"# ...versus a real user who simply has no profile row yet.\n",
|
||||
"fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n",
|
||||
"client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n",
|
||||
"time.sleep(5)\n",
|
||||
"print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## F3. Profiles turned off\n",
|
||||
"\n",
|
||||
"`enabled` is the one project-wide switch. Every generation path then refuses.\n",
|
||||
"\n",
|
||||
"Nothing is deleted. Your schema and every profile you already built are kept, so turning\n",
|
||||
"it back on resumes rather than restarts.\n",
|
||||
"\n",
|
||||
"Note what a read does **not** do — a profile that already exists keeps reporting\n",
|
||||
"`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n",
|
||||
"with no profile yet. Turning the feature off stops new work; it does not hide what has\n",
|
||||
"already been built.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"client.update_profile_settings(enabled=False)\n",
|
||||
"\n",
|
||||
"print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n",
|
||||
"print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n",
|
||||
"try:\n",
|
||||
" client.generate_profile(USER_ID)\n",
|
||||
" print(\"trigger: ACCEPTED (unexpected)\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(\"trigger:\", str(e)[:160])\n",
|
||||
"\n",
|
||||
"back = client.update_profile_settings(enabled=True) # put it back\n",
|
||||
"print(\"\\nrestored:\", back[\"enabled\"])\n",
|
||||
"print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Cleanup\n",
|
||||
"\n",
|
||||
"Removes the demo users. The profile row cascades with the entity.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "for entity_id in (USER_ID, fresh):\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\n\n# Restore the project's profile settings to the start-of-run snapshot, so a shared\n# env is left exactly as we found it. Passing the original values (including None)\n# clears anything this notebook set — the SDK distinguishes an explicit None from an\n# omitted argument.\n_user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\nclient.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n)\nprint(\"profile settings restored to the pre-notebook snapshot\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Cheat sheet\n",
|
||||
"\n",
|
||||
"| Want | Call | Cost |\n",
|
||||
"| --- | --- | --- |\n",
|
||||
"| configure | `update_profile_settings(...)` | free |\n",
|
||||
"| read | `get_profile(user_id)` | free |\n",
|
||||
"| one user now | `generate_profile(user_id)` | 1 LLM call |\n",
|
||||
"| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n",
|
||||
"| poll a job | `get_profile_job(status_url)` | free |\n",
|
||||
"\n",
|
||||
"**Settings apply to user profiles.** The stored shape is:\n",
|
||||
"\n",
|
||||
"```json\n",
|
||||
"{\"enabled\": true,\n",
|
||||
" \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n",
|
||||
" \"capabilities\": {\"full_rebuild\": false}}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"The SDK takes these flat and nests them for you. Only the fields you pass are written;\n",
|
||||
"`enabled` is the one project-wide switch.\n",
|
||||
"\n",
|
||||
"**Left alone, generation fires** on a 10-message boundary, or after a flush window\n",
|
||||
"measured in hours. `generate_profile()` is how you skip the wait for one user.\n",
|
||||
"\n",
|
||||
"**Three traps:**\n",
|
||||
"\n",
|
||||
"1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n",
|
||||
" through it and give up on a timeout instead.\n",
|
||||
"2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n",
|
||||
" extraction still increments the counter.\n",
|
||||
"3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n",
|
||||
" arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.4"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export async function buildRecallContext(
|
||||
if (!unseen.length) return "";
|
||||
|
||||
const prefix =
|
||||
"<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — search mem0_memory for more if you need it.\n";
|
||||
"<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — use the available memory search tool for more if you need it.\n";
|
||||
const suffix = "\n</mem0-relevant-memories>";
|
||||
const maxChars = options.maxChars ?? DEFAULT_MAX_CONTEXT_CHARS;
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -13,7 +13,7 @@ It gives a Harness agent automatic long-term memory plus two explicit memory too
|
||||
|
||||
Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed backend: server-side extraction, semantic dedup and conflict resolution, and memories that other agents can retrieve when their user and entity filters match.
|
||||
|
||||
Current package version: `0.3.0`.
|
||||
Current package version: `0.3.1`.
|
||||
|
||||
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
|
||||
|
||||
@@ -47,21 +47,27 @@ Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic
|
||||
mkdir -p /tmp/mem0-deepseek-plugin
|
||||
pnpm pack --pack-destination /tmp/mem0-deepseek-plugin
|
||||
```
|
||||
2. Set your Mem0 key:
|
||||
2. Set your Mem0 key, a stable user identity, and a funded model API key:
|
||||
```sh
|
||||
export MEM0_API_KEY=...
|
||||
export MEM0_USER_ID=your-user-id
|
||||
export DEEPSEEK_API_KEY=...
|
||||
```
|
||||
3. Install it into a disposable Harness profile:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
|
||||
plugin --profile web add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.1.tgz
|
||||
```
|
||||
4. Copy `cordis.example.yml`, set its installed package path and your `userId`, then run Harness with the same profile:
|
||||
4. Run Harness with the same profile. The installed bundle activates Mem0 automatically:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
web
|
||||
```
|
||||
5. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
|
||||
5. Open http://127.0.0.1:3080, select a workspace, and state a synthetic preference without mentioning Mem0. Wait for asynchronous extraction to finish, then start a fresh session and ask for that preference without tools. Inspect the `mem0:recall` context to verify automatic recall.
|
||||
|
||||
Build from the full repository: the source imports the sibling shared core. `pnpm pack` runs the build and includes the activation patch. An installed tarball is self-contained. For custom settings, copy `cordis.example.yml` and pass its absolute path with `--patch`. When upgrading from the old manual setup, remove the old patch that **inserts** a `mem0` row; the bundle now inserts it.
|
||||
|
||||
On macOS, set `CHOKIDAR_USEPOLLING=1` if Harness reports `EMFILE`. The Mem0 key and model-provider key are separate credentials.
|
||||
|
||||
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` overrides the Platform base URL. It does not support the self-hosted Mem0 OSS API.
|
||||
|
||||
@@ -71,6 +77,7 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Entity that owns the memories |
|
||||
| `memoryScope` | no | `user` | `user` shares memory across workspaces; `workspace` isolates all automatic and explicit operations by the session workspace |
|
||||
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
|
||||
@@ -80,10 +87,20 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|
||||
Automatic capture and recall use the configured `userId` across sessions. Automatic writes do not attach a repository ID or `runId`.
|
||||
|
||||
This cross-workspace sharing is intentional compatibility behavior. Set `memoryScope: workspace` in the profile patch to isolate memory. The plugin then adds an `appId` derived from the canonical absolute session workspace path to every write and the matching `app_id` to every search. Symlink aliases share a scope; different directories (including separate clones or worktrees) do not. Moving a workspace changes its scope. Tools cannot override it. Missing or invalid workspace paths skip automatic memory operations and reject explicit tools, without falling back to user-wide access.
|
||||
|
||||
Workspace scope does not migrate existing user-only memories. User scope still searches all memories for that user, including workspace-tagged memories; isolation applies when the plugin is configured with workspace scope. These filters are application-level separation, not separate Mem0 credentials.
|
||||
|
||||
Both `search_memory` and `add_memory` accept optional `agentId` and `runId`. On search, these narrow the returned memories; on add, they attach those identities to the stored memory. Pass a known `runId` to search memories explicitly saved with that session ID. This does not include automatically captured user-only memories or identify the session making the request.
|
||||
|
||||
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
|
||||
|
||||
## Extraction and recall limits
|
||||
|
||||
The plugin sends full redacted completed turns to Mem0; Mem0 extracts the memories asynchronously. A queued write is not proof that every extracted fact is already searchable. Even an initial nonempty memory list can be incomplete. Check the stored memories after processing before diagnosing extraction loss.
|
||||
|
||||
Automatic recall is a bounded first pass (up to five results, 4,000 context characters, and a two-second wait). `search_memory` uses a focused query and returns up to ten results by default. Exact preference extraction remains model-dependent; include filenames and numeric constraints in release tests, and distinguish standing preferences from one-off requests.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0's backend can attribute usage to this integration. For it to surface by name (rather than bucketing into `OTHERS`), `DEEPSEEK_HARNESS` must be present in the backend's `KNOWN_EVENT_SOURCES` allowlist, a one-line platform change matching the existing `ZAPIER` / `STRANDS` sources.
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
# Example DeepSeek Harness config that loads deepseek-plugin alongside the built-in
|
||||
# tools plugin. Load with:
|
||||
#
|
||||
# DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
# web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
#
|
||||
- insert:
|
||||
- id: mem0
|
||||
# Install the packed plugin into this disposable profile first; loading
|
||||
# raw dist bypasses Harness's peer dependency installation.
|
||||
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from the MEM0_API_KEY env var when omitted here.
|
||||
userId: "your-user-id"
|
||||
# autoRecall: true # optional; enabled by default
|
||||
# autoCapture: true # optional; enabled by default
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
# Optional override for the mem0 row installed by dsh.bundle.
|
||||
# Copy this file, then use: dsh web --patch /absolute/path/to/cordis.yml
|
||||
- id: mem0
|
||||
config:
|
||||
# Replacing a row's config requires restating its userId.
|
||||
# apiKey is read from MEM0_API_KEY when omitted here.
|
||||
userId: !!js process.env.MEM0_USER_ID
|
||||
memoryScope: workspace # optional; default is user (shared across workspaces)
|
||||
# autoRecall: true
|
||||
# autoCapture: true
|
||||
# host: "https://your-onprem.mem0.ai" # Mem0 Platform dedicated deployment
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- insert:
|
||||
- id: mem0
|
||||
name: "@mem0/deepseek-plugin"
|
||||
config:
|
||||
# Credentials stay in the process environment, not the bundle.
|
||||
userId: !!js process.env.MEM0_USER_ID
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/deepseek-plugin",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
@@ -29,13 +29,21 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"cordis.patch.yml",
|
||||
"cordis.example.yml",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"prepack": "pnpm build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||
import { MemoryClient } from "mem0ai";
|
||||
import { formatMemoryList, formatAddResult } from "./formatting.ts";
|
||||
import { truncateOutput } from "./output.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
import { resolveSearchFilters, resolveAddParams, resolveWorkspaceId } from "./scoping.ts";
|
||||
import { captureEvent, errorKind } from "./telemetry.ts";
|
||||
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
|
||||
|
||||
@@ -44,6 +44,7 @@ interface HarnessMessage {
|
||||
interface SessionState {
|
||||
lifecycle: ReturnType<typeof createMemoryLifecycle>;
|
||||
messages: HarnessMessage[];
|
||||
currentPrompt?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
@@ -51,6 +52,8 @@ export interface Config {
|
||||
apiKey?: string;
|
||||
/** Default entity that owns the memories (Mem0 user scope). */
|
||||
userId: string;
|
||||
/** Share user memory across workspaces (default), or isolate by session workspace. */
|
||||
memoryScope?: "user" | "workspace";
|
||||
/** Explicitly allow model-selected cross-user access. Defaults to false. */
|
||||
allowUserOverride?: boolean;
|
||||
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
|
||||
@@ -98,6 +101,19 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (!userId || /^\*+$/.test(userId)) {
|
||||
throw new Error("deepseek-plugin: config.userId is required");
|
||||
}
|
||||
if (config.memoryScope !== undefined && !["user", "workspace"].includes(config.memoryScope)) {
|
||||
throw new Error("deepseek-plugin: config.memoryScope must be user or workspace");
|
||||
}
|
||||
const workspaceId = (session?: { header: { cwd?: string } }) =>
|
||||
config.memoryScope === "workspace" ? resolveWorkspaceId(session?.header.cwd) : undefined;
|
||||
const searchFilters = (params: Parameters<typeof resolveSearchFilters>[0], session?: { header: { cwd?: string } }) => {
|
||||
const appId = workspaceId(session);
|
||||
return { ...resolveSearchFilters(params, userId), ...(appId ? { app_id: appId } : {}) };
|
||||
};
|
||||
const addParams = (params: Parameters<typeof resolveAddParams>[0], session?: { header: { cwd?: string } }) => {
|
||||
const appId = workspaceId(session);
|
||||
return { ...resolveAddParams(params, userId), ...(appId ? { appId } : {}) };
|
||||
};
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey,
|
||||
@@ -123,13 +139,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}, client);
|
||||
|
||||
if (config.autoRecall !== false) {
|
||||
// Harness assembles the prompt before appending claimed messages to history.
|
||||
ctx.on("agent/inbox/claimed", ({ agent, message }) => {
|
||||
if (message.source.kind !== "user") return;
|
||||
const state = stateFor(agent.session);
|
||||
state.currentPrompt = state.lifecycle.prepareConversation([message]).at(-1)?.content;
|
||||
});
|
||||
|
||||
ctx.on("system-prompt/assemble", async (_input, context, next): Promise<PromptAssembly> => {
|
||||
const assembly = await next();
|
||||
const { agent, signal } = context;
|
||||
if (!agent || signal?.aborted) return assembly;
|
||||
|
||||
const state = stateFor(agent.session);
|
||||
const prompt = state.lifecycle.prepareConversation(
|
||||
const prompt = state.currentPrompt ?? state.lifecycle.prepareConversation(
|
||||
agent.session
|
||||
.deriveMessages()
|
||||
.filter((message) => message.role === "user" && message.source?.kind === "user"),
|
||||
@@ -140,7 +163,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.search(query, {
|
||||
filters: resolveSearchFilters({}, userId),
|
||||
filters: searchFilters({}, agent.session),
|
||||
topK: AUTO_RECALL_LIMIT,
|
||||
});
|
||||
captureEvent("deepseek.recall.auto", {
|
||||
@@ -179,8 +202,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const conversation = state.lifecycle.prepareConversation(state.messages);
|
||||
state.messages = [];
|
||||
if (event.data.reason.kind !== "completed" || conversation.length === 0) return;
|
||||
void client
|
||||
.add(conversation, { userId, source: SOURCE })
|
||||
void Promise.resolve()
|
||||
.then(() => client.add(conversation, { ...addParams({}, session), source: SOURCE }))
|
||||
.then(() => captureEvent("deepseek.capture.auto", {
|
||||
success: true,
|
||||
message_count: conversation.length,
|
||||
@@ -209,12 +232,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ query, limit, userId: u, agentId, runId }) {
|
||||
async execute({ query, limit, userId: u, agentId, runId }, exec) {
|
||||
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
|
||||
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
|
||||
}
|
||||
const safeQuery = toolLifecycle.prepareUserText(query);
|
||||
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
|
||||
const filters = searchFilters({ userId: u, agentId, runId }, exec.agent?.session);
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const started = Date.now();
|
||||
try {
|
||||
@@ -262,17 +285,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ text, userId: u, agentId, runId }) {
|
||||
async execute({ text, userId: u, agentId, runId }, exec) {
|
||||
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
|
||||
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
|
||||
}
|
||||
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
|
||||
const params = addParams({ userId: u, agentId, runId }, exec.agent?.session);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.add(
|
||||
[{ role: "user", content: toolLifecycle.prepareUserText(text) }],
|
||||
{
|
||||
...addParams,
|
||||
...params,
|
||||
source: SOURCE,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
export {
|
||||
entityAddParams as resolveAddParams,
|
||||
entitySearchFilters as resolveSearchFilters,
|
||||
} from "../../agent-plugin-core/typescript/src/identity.ts";
|
||||
export type { EntityParams } from "../../agent-plugin-core/typescript/src/identity.ts";
|
||||
|
||||
export function resolveWorkspaceId(cwd: string | undefined): string {
|
||||
if (!cwd || !isAbsolute(cwd)) {
|
||||
throw new Error("Workspace memory scope requires an absolute session workspace path.");
|
||||
}
|
||||
return `deepseek-workspace-${createHash("sha256").update(realpathSync(cwd)).digest("hex")}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Offline mock of the Mem0 SDK so these tests never touch the network.
|
||||
const mockSearch = vi.fn();
|
||||
@@ -75,6 +78,11 @@ describe("apply() config validation", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
|
||||
});
|
||||
|
||||
it("rejects unknown memory scope instead of silently using user-wide access", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "u", memoryScope: "typo" } as unknown as Config))
|
||||
.toThrow(/memoryScope/);
|
||||
});
|
||||
|
||||
it("registers both memory tools", () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
expect([...tools.keys()].sort()).toEqual(["add_memory", "search_memory"]);
|
||||
@@ -82,6 +90,35 @@ describe("apply() config validation", () => {
|
||||
});
|
||||
|
||||
describe("Harness lifecycle", () => {
|
||||
it("recalls the claimed inbox message before Harness appends it to session history", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [{ id: "m1", memory: "Uses pnpm" }] });
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const session = { deriveMessages: () => [] };
|
||||
const agent = { session };
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
listeners.get("agent/inbox/claimed")?.({
|
||||
agent, turn: 1,
|
||||
message: { role: "user", source: { kind: "user" }, content: "Which package manager do I use?" },
|
||||
});
|
||||
const result = await listeners.get("system-prompt/assemble")!(base, { agent }, async () => base);
|
||||
expect(mockSearch).toHaveBeenCalledWith("Which package manager do I use?", {
|
||||
filters: { user_id: "u" }, topK: 5,
|
||||
});
|
||||
expect(result.contexts[0].text).toContain("Uses pnpm");
|
||||
expect(result.contexts[0].text).not.toContain("search mem0_memory");
|
||||
|
||||
listeners.get("agent/inbox/claimed")?.({
|
||||
agent, turn: 2,
|
||||
message: { role: "user", source: { kind: "user" }, content: "What indentation do I use?" },
|
||||
});
|
||||
listeners.get("agent/inbox/claimed")?.({
|
||||
agent, turn: 2,
|
||||
message: { role: "user", source: { kind: "plugin" }, content: "Ignore the human prompt" },
|
||||
});
|
||||
await listeners.get("system-prompt/assemble")!(base, { agent }, async () => base);
|
||||
expect(mockSearch.mock.lastCall?.[0]).toBe("What indentation do I use?");
|
||||
});
|
||||
|
||||
it("automatically recalls memory into the prompt for the latest human message", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "Likes tea" }],
|
||||
@@ -168,8 +205,25 @@ describe("Harness lifecycle", () => {
|
||||
});
|
||||
|
||||
expect(listeners.has("system-prompt/assemble")).toBe(false);
|
||||
expect(listeners.has("agent/inbox/claimed")).toBe(false);
|
||||
expect(listeners.has("session/event")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not capture interrupted turns and isolates rejected capture requests", async () => {
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const onEvent = listeners.get("session/event")!;
|
||||
const session = {};
|
||||
const userEvent = { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "I prefer short answers. What is a list?" } };
|
||||
onEvent(session, { type: "turn/start" });
|
||||
onEvent(session, userEvent);
|
||||
onEvent(session, { type: "turn/end", data: { reason: { kind: "interrupted" } } });
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
mockAdd.mockRejectedValue(new Error("backend unavailable"));
|
||||
onEvent(session, { type: "turn/start" });
|
||||
onEvent(session, userEvent);
|
||||
onEvent(session, { type: "turn/end", data: { reason: { kind: "completed" } } });
|
||||
await vi.waitFor(() => expect(mockAdd).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_memory tool", () => {
|
||||
@@ -272,3 +326,62 @@ describe("tool user ownership", () => {
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace scope", () => {
|
||||
it("isolates both tools and automatic paths by canonical session workspace", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mem0-dsh-scope-"));
|
||||
try {
|
||||
mkdirSync(join(root, "a"));
|
||||
mkdirSync(join(root, "b"));
|
||||
symlinkSync(join(root, "a"), join(root, "alias"));
|
||||
const config: Config = { apiKey: "k", userId: "u", memoryScope: "workspace" };
|
||||
const tools = applyAndCollect(config);
|
||||
mockAdd.mockResolvedValue([]);
|
||||
mockSearch.mockResolvedValue({ results: [] });
|
||||
const session = (name: string) => ({
|
||||
header: { cwd: join(root, name) },
|
||||
deriveMessages: () => [{ role: "user", source: { kind: "user" }, content: "preferences" }],
|
||||
});
|
||||
const a = session("a");
|
||||
await tools.get("add_memory")!.execute({ text: "Uses pnpm" }, { agent: { session: a } });
|
||||
const appId = mockAdd.mock.lastCall?.[1].appId;
|
||||
expect(appId).toMatch(/^deepseek-workspace-[a-f0-9]{64}$/);
|
||||
await tools.get("search_memory")!.execute({ query: "preferences" }, { agent: { session: session("alias") } });
|
||||
expect(mockSearch.mock.lastCall?.[1].filters).toEqual({ user_id: "u", app_id: appId });
|
||||
await tools.get("search_memory")!.execute({ query: "preferences" }, { agent: { session: session("b") } });
|
||||
expect(mockSearch.mock.lastCall?.[1].filters.app_id).not.toBe(appId);
|
||||
|
||||
const listeners = applyAndCollectListeners(config);
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
await listeners.get("system-prompt/assemble")!(base, { agent: { session: a } }, async () => base);
|
||||
expect(mockSearch.mock.lastCall?.[1].filters).toEqual({ user_id: "u", app_id: appId });
|
||||
const onEvent = listeners.get("session/event")!;
|
||||
mockAdd.mockClear();
|
||||
onEvent(a, { type: "turn/start" });
|
||||
onEvent(a, { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "I prefer short answers. What is a list?" } });
|
||||
onEvent(a, { type: "turn/end", data: { reason: { kind: "completed" } } });
|
||||
await vi.waitFor(() => expect(mockAdd.mock.lastCall?.[1]).toMatchObject({ userId: "u", appId }));
|
||||
expect(mockAdd).toHaveBeenCalledOnce();
|
||||
expect(mockAdd.mock.lastCall?.[0]).toEqual([
|
||||
{ role: "user", content: "I prefer short answers. What is a list?" },
|
||||
]);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("fails closed without a workspace while automatic paths leave the agent running", async () => {
|
||||
const config: Config = { apiKey: "k", userId: "u", memoryScope: "workspace" };
|
||||
const tools = applyAndCollect(config);
|
||||
await expect(tools.get("add_memory")!.execute({ text: "x" }, {})).rejects.toThrow(/workspace/);
|
||||
await expect(tools.get("search_memory")!.execute({ query: "x" }, {})).rejects.toThrow(/workspace/);
|
||||
const listeners = applyAndCollectListeners(config);
|
||||
const session = { header: {}, deriveMessages: () => [{ role: "user", source: { kind: "user" }, content: "hello" }] };
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
expect(await listeners.get("system-prompt/assemble")!(base, { agent: { session } }, async () => base)).toBe(base);
|
||||
const onEvent = listeners.get("session/event")!;
|
||||
onEvent(session, { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "hello" } });
|
||||
expect(() => onEvent(session, { type: "turn/end", data: { reason: { kind: "completed" } } })).not.toThrow();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("installable Harness bundle", () => {
|
||||
it("ships a native activation patch and builds before packing", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const manifest = JSON.parse(readFileSync(new URL("package.json", root), "utf8"));
|
||||
expect(manifest.scripts.prepack).toBe("pnpm build");
|
||||
expect(manifest.dsh.bundle.patch).toBe("./cordis.patch.yml");
|
||||
expect(manifest.files).toContain("cordis.patch.yml");
|
||||
expect(existsSync(new URL(manifest.dsh.bundle.patch, root))).toBe(true);
|
||||
expect(readFileSync(new URL(manifest.dsh.bundle.patch, root), "utf8")).toContain("@mem0/deepseek-plugin");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.2.0",
|
||||
"version": "3.1.8",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -23,15 +23,6 @@ export type {
|
||||
FeedbackPayload,
|
||||
CreateMemoryExportPayload,
|
||||
GetMemoryExportPayload,
|
||||
ProfileEntityType,
|
||||
ProfileStatus,
|
||||
ProfileResponse,
|
||||
ProfileJobResponse,
|
||||
ProfileJobStatus,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
EntityProfileSettings,
|
||||
ProfileSampleResult,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
|
||||
+3
-207
@@ -1,5 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
AllUsers,
|
||||
PaginatedMemories,
|
||||
@@ -21,12 +20,6 @@ import {
|
||||
FeedbackPayload,
|
||||
CreateMemoryExportPayload,
|
||||
GetMemoryExportPayload,
|
||||
ProfileEntityType,
|
||||
ProfileResponse,
|
||||
ProfileJobResponse,
|
||||
ProfileJobStatus,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
} from "./mem0.types";
|
||||
import {
|
||||
captureClientEvent,
|
||||
@@ -99,9 +92,6 @@ interface ClientIdentity {
|
||||
}
|
||||
|
||||
// Shares one ping per (host, api key) across clients; FIFO-capped.
|
||||
// One collection for every generation; the operation is a body field.
|
||||
const PROFILE_JOBS_PATH = "/v2/profiles/jobs/";
|
||||
|
||||
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
||||
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
||||
|
||||
@@ -254,8 +244,7 @@ export default class MemoryClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch with no key conversion, for payloads carrying user-controlled property names. */
|
||||
async _fetchRawJson(url: string, options: any): Promise<any> {
|
||||
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -268,11 +257,8 @@ export default class MemoryClient {
|
||||
const errorData = await response.text();
|
||||
throw createExceptionFromResponse(response.status, errorData);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
|
||||
return snakeToCamelKeys(await this._fetchRawJson(url, options));
|
||||
const jsonResponse = await response.json();
|
||||
return snakeToCamelKeys(jsonResponse);
|
||||
}
|
||||
|
||||
_preparePayload(
|
||||
@@ -769,196 +755,6 @@ export default class MemoryClient {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the memory profile for a single user.
|
||||
*
|
||||
* Branch on `status`, not on an empty `profile`: generation is asynchronous,
|
||||
* so a known user without a profile yet is a normal response.
|
||||
*/
|
||||
async getProfile(data: { entityId: string }): Promise<ProfileResponse> {
|
||||
this._captureEvent("get_profile", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v2/entities/user/${encodeURIComponent(data.entityId)}/profile/`,
|
||||
{
|
||||
headers: this.headers,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or refresh the profile for one user, now.
|
||||
*
|
||||
* Profiles are otherwise built once a user crosses an internal message
|
||||
* threshold, so a new user has none for its first few memories. Returns as
|
||||
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
|
||||
*
|
||||
* Pass `idempotencyKey` and reuse it to retry a lost request without starting
|
||||
* (and being billed for) a second job.
|
||||
*/
|
||||
async generateProfile(data: {
|
||||
entityId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("generate_profile", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Idempotency-Key": data.idempotencyKey ?? uuidv4(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
entity_id: data.entityId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get the profile settings for the current project. */
|
||||
async getProfileSettings(): Promise<ProfileSettingsResponse> {
|
||||
this._captureEvent("get_profile_settings", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return this._settingsWithVerbatimSchema(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope keys are ours; the schema's property names are the customer's.
|
||||
*
|
||||
* Each entity type carries its own schema, so every one has to be restored
|
||||
* from the raw body — otherwise camel-casing rewrites the customer's field
|
||||
* names and a profile comes back under keys they never chose.
|
||||
*/
|
||||
private _settingsWithVerbatimSchema(raw: any): ProfileSettingsResponse {
|
||||
const settings = snakeToCamelKeys(raw) as ProfileSettingsResponse;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return settings;
|
||||
}
|
||||
|
||||
if ("schema" in raw) {
|
||||
settings.schema = raw.schema;
|
||||
}
|
||||
|
||||
const rawEntities = raw.entities;
|
||||
if (rawEntities && typeof rawEntities === "object") {
|
||||
for (const [entityType, entitySettings] of Object.entries(rawEntities)) {
|
||||
if (
|
||||
entitySettings &&
|
||||
typeof entitySettings === "object" &&
|
||||
"schema" in entitySettings &&
|
||||
settings.entities?.[entityType as ProfileEntityType]
|
||||
) {
|
||||
settings.entities[entityType as ProfileEntityType]!.schema = (
|
||||
entitySettings as Record<string, any>
|
||||
).schema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile settings. Only the fields you pass are written.
|
||||
*
|
||||
* `schema` and `customInstructions` are per user and are nested under
|
||||
* `entities` for the API; only `enabled` is project-wide. Sending them flat
|
||||
* is rejected with `Unsupported settings`.
|
||||
*/
|
||||
async updateProfileSettings(
|
||||
settings: ProfileSettings,
|
||||
): Promise<ProfileSettingsResponse> {
|
||||
const payloadKeys = Object.keys(settings || {});
|
||||
this._captureEvent("update_profile_settings", [payloadKeys]);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const { schema, customInstructions, enabled } = settings || {};
|
||||
|
||||
const body: Record<string, any> = {};
|
||||
if (enabled !== undefined) {
|
||||
body.enabled = enabled;
|
||||
}
|
||||
|
||||
const entitySettings: Record<string, any> = {};
|
||||
// The schema's property names are the customer's and must reach the API verbatim.
|
||||
if (schema !== undefined) {
|
||||
entitySettings.schema = schema;
|
||||
}
|
||||
if (customInstructions !== undefined) {
|
||||
entitySettings.custom_instructions = customInstructions;
|
||||
}
|
||||
if (Object.keys(entitySettings).length > 0) {
|
||||
body.entities = { user: entitySettings };
|
||||
}
|
||||
|
||||
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return this._settingsWithVerbatimSchema(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate profiles for a few real users, to check a schema.
|
||||
*
|
||||
* Real generations against real memories, and the results are kept: the
|
||||
* profiles are written to those users and count toward usage.
|
||||
*/
|
||||
async sampleProfiles(data?: {
|
||||
limit?: number;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("sample_profiles", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Idempotency-Key": data?.idempotencyKey ?? uuidv4(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "sample",
|
||||
// Required: the API refuses a job that does not name an entity kind.
|
||||
entity_type: "user",
|
||||
...this._prepareParams({ limit: data?.limit }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one generation job. Accepts the `statusUrl` from a create call, or a
|
||||
* bare job id. Prefer `statusUrl` so a route change needs no client update.
|
||||
*/
|
||||
async getProfileJob(jobIdOrStatusUrl: string): Promise<ProfileJobStatus> {
|
||||
await this._awaitIdentity();
|
||||
|
||||
const path = jobIdOrStatusUrl.startsWith("/")
|
||||
? jobIdOrStatusUrl
|
||||
: `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`;
|
||||
return this._fetchWithErrorHandling(`${this.host}${path}`, {
|
||||
method: "GET",
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async createMemoryExport(
|
||||
data: CreateMemoryExportPayload,
|
||||
): Promise<{ message: string; id: string }> {
|
||||
|
||||
@@ -236,105 +236,3 @@ export interface GetMemoryExportPayload {
|
||||
filters?: Record<string, any>;
|
||||
memoryExportId?: string;
|
||||
}
|
||||
|
||||
// ─── Profile Types ──────────────────────────────────────────
|
||||
|
||||
/** The entity kind that carries a profile. */
|
||||
export type ProfileEntityType = "user";
|
||||
|
||||
/**
|
||||
* `succeeded` is the only state in which `profile` is guaranteed to hold content.
|
||||
*
|
||||
* These are values, not keys, so the client does not camel-case them: the wire
|
||||
* spelling is what a comparison has to match.
|
||||
*/
|
||||
export type ProfileStatus =
|
||||
| "succeeded"
|
||||
| "pending"
|
||||
| "failed"
|
||||
| "not_enabled"
|
||||
| "insufficient_data";
|
||||
|
||||
export interface ProfileResponse {
|
||||
/** Shaped by the project's schema; keys are not camel-cased. */
|
||||
profile: Record<string, any>;
|
||||
status: ProfileStatus;
|
||||
entityType: ProfileEntityType;
|
||||
entityId: string;
|
||||
updatedAt: string | null;
|
||||
generationCount: number;
|
||||
/** The last generation's failure reason, when `status` is `failed`. */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Every accepted generation. `statusUrl` is the server's own poll path. */
|
||||
export interface ProfileJobResponse {
|
||||
jobId: string;
|
||||
status: string;
|
||||
statusUrl: string;
|
||||
operation: string;
|
||||
entityType: ProfileEntityType;
|
||||
/** Entities reserved against usage for this job. */
|
||||
entityCountReserved?: number;
|
||||
eventId?: string;
|
||||
replayed?: boolean;
|
||||
/** Sample runs only: how many entities were picked. */
|
||||
sampled?: number;
|
||||
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
|
||||
entityIds?: string[];
|
||||
}
|
||||
|
||||
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
|
||||
export interface ProfileSettings {
|
||||
/** Turn profile generation on or off. Project-wide. */
|
||||
enabled?: boolean;
|
||||
/** JSON Schema for the profile. Every property needs a `description`. */
|
||||
schema?: Record<string, any> | null;
|
||||
customInstructions?: string | null;
|
||||
}
|
||||
|
||||
/** One entity type's stored configuration. */
|
||||
export interface EntityProfileSettings {
|
||||
/** The customer's JSON Schema, with its property names verbatim. */
|
||||
schema?: Record<string, any> | null;
|
||||
customInstructions?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings as stored. `schema` and `customInstructions` are per entity
|
||||
* type and nest under `entities`; only `enabled` is project-wide.
|
||||
*/
|
||||
export interface ProfileSettingsResponse {
|
||||
enabled?: boolean;
|
||||
entities?: Partial<Record<ProfileEntityType, EntityProfileSettings>>;
|
||||
capabilities?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProfileSampleResult {
|
||||
entityType: ProfileEntityType;
|
||||
entityId: string;
|
||||
profileId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
|
||||
export interface ProfileJobStatus {
|
||||
job: {
|
||||
id: string;
|
||||
operation: string;
|
||||
entityType: ProfileEntityType;
|
||||
status: string;
|
||||
/** Null until `enumerationComplete`. */
|
||||
total: number | null;
|
||||
enumerationComplete: boolean;
|
||||
/** succeeded + failed + skipped. */
|
||||
completed: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
results?: Array<ProfileSampleResult>;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — profiles.
|
||||
* Verifies request construction and the verbatim round-trip of user-controlled
|
||||
* profile/schema keys, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { ProfileStatus } from "../mem0.types";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - getProfile()", () => {
|
||||
test("reads the v2 entity route and keeps profile keys verbatim", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/alice/profile/", {
|
||||
status: 200,
|
||||
body: {
|
||||
// Customer schema keys: camel-casing these would break the schema they wrote.
|
||||
profile: {
|
||||
favorite_topics: ["hiking"],
|
||||
work_style: { preferred_hours: "mornings" },
|
||||
},
|
||||
status: "succeeded",
|
||||
entity_type: "user",
|
||||
entity_id: "alice",
|
||||
updated_at: "2026-02-08T00:00:00Z",
|
||||
generation_count: 3,
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.getProfile({ entityId: "alice" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/entities/user/alice/profile/");
|
||||
expect(call).toBeDefined();
|
||||
|
||||
expect(result.profile).toEqual({
|
||||
favorite_topics: ["hiking"],
|
||||
work_style: { preferred_hours: "mornings" },
|
||||
});
|
||||
expect(result.entityType).toBe("user");
|
||||
expect(result.entityId).toBe("alice");
|
||||
expect(result.generationCount).toBe(3);
|
||||
expect(result.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
test("status keeps its wire spelling", async () => {
|
||||
// A status is a VALUE, not a key, so the client does not camel-case it.
|
||||
// Declaring the union as `insufficientData` made tsc reject the comparison
|
||||
// that works and accept one that can never be true.
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/bob/profile/", {
|
||||
status: 200,
|
||||
body: {
|
||||
profile: {},
|
||||
status: "insufficient_data",
|
||||
entity_type: "user",
|
||||
entity_id: "bob",
|
||||
},
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.getProfile({ entityId: "bob" });
|
||||
|
||||
expect(result.status).toBe("insufficient_data");
|
||||
// Assignable without a cast: the declared union must contain the wire value.
|
||||
const status: ProfileStatus = result.status;
|
||||
expect(status).not.toBe("insufficientData");
|
||||
});
|
||||
|
||||
test("scopes to user and encodes the entity id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/", {
|
||||
status: 200,
|
||||
body: { profile: {}, status: "pending", entity_type: "user" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getProfile({ entityId: "a/b" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/entities/user/a%2Fb/profile/");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - generateProfile()", () => {
|
||||
test("sends operation trigger with the entity to the jobs collection", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "00000000-0000-7000-8000-000000000001",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
entity_count_reserved: 1,
|
||||
event_id: "00000000-0000-7000-8000-000000000002",
|
||||
replayed: false,
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.generateProfile({ entityId: "alice" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.operation).toBe("trigger");
|
||||
expect(body.entity_type).toBe("user");
|
||||
expect(body.entity_id).toBe("alice");
|
||||
// A generated Idempotency-Key is always sent so the create can be retried safely.
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Idempotency-Key"]).toBeTruthy();
|
||||
expect(result.jobId).toBe("00000000-0000-7000-8000-000000000001");
|
||||
expect(result.statusUrl).toBe(
|
||||
"/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
|
||||
);
|
||||
expect(result.replayed).toBe(false);
|
||||
});
|
||||
|
||||
test("reuses a caller-supplied idempotency key", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "j1",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/j1/",
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.generateProfile({
|
||||
entityId: "alice",
|
||||
idempotencyKey: "retry-key-123",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Idempotency-Key"]).toBe("retry-key-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - profile settings", () => {
|
||||
test("sends schema property names verbatim and returns them unchanged", async () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
favorite_topics: {
|
||||
type: "array",
|
||||
description: "Topics the user returns to",
|
||||
items: { type: "string" },
|
||||
},
|
||||
workStyle: {
|
||||
type: "string",
|
||||
description: "How the user prefers to work",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: {
|
||||
enabled: true,
|
||||
entities: {
|
||||
user: {
|
||||
schema,
|
||||
custom_instructions: "Focus on durable preferences",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.updateProfileSettings({
|
||||
enabled: true,
|
||||
schema,
|
||||
customInstructions: "Focus on durable preferences",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
const body = getFetchBody(call!);
|
||||
|
||||
// Mixed casing goes out exactly as written, nested under the entity type.
|
||||
expect(body.entities.user.schema).toEqual(schema);
|
||||
expect(body.entities.user.custom_instructions).toBe(
|
||||
"Focus on durable preferences",
|
||||
);
|
||||
expect(body.enabled).toBe(true);
|
||||
// A flat schema is rejected by the API with "Unsupported settings".
|
||||
expect("schema" in body).toBe(false);
|
||||
|
||||
// And the customer's property names survive the round trip.
|
||||
expect(result.entities?.user?.schema).toEqual(schema);
|
||||
expect(result.entities?.user?.customInstructions).toBe(
|
||||
"Focus on durable preferences",
|
||||
);
|
||||
});
|
||||
|
||||
test("scopes entity-level settings under user", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: { enabled: true },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.updateProfileSettings({
|
||||
schema: { type: "object", properties: {} },
|
||||
});
|
||||
|
||||
const body = getFetchBody(
|
||||
findFetchCall(mock, "/v2/profiles/settings/", "POST")!,
|
||||
);
|
||||
expect(Object.keys(body.entities)).toEqual(["user"]);
|
||||
});
|
||||
|
||||
test("omits fields the caller did not set", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: {
|
||||
enabled: false,
|
||||
entities: { user: { schema: null, custom_instructions: null } },
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.updateProfileSettings({ enabled: false });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.enabled).toBe(false);
|
||||
// Nothing entity-scoped was passed, so no entities key is sent at all.
|
||||
expect("entities" in body).toBe(false);
|
||||
expect("schema" in body).toBe(false);
|
||||
expect("custom_instructions" in body).toBe(false);
|
||||
});
|
||||
|
||||
test("getProfileSettings reads the v2 route", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: {
|
||||
enabled: true,
|
||||
entities: {
|
||||
user: {
|
||||
schema: { properties: { favorite_topics: { type: "array" } } },
|
||||
custom_instructions: null,
|
||||
},
|
||||
},
|
||||
capabilities: { full_rebuild: false },
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.getProfileSettings();
|
||||
|
||||
expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined();
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.entities?.user?.schema).toEqual({
|
||||
properties: { favorite_topics: { type: "array" } },
|
||||
});
|
||||
expect(result.capabilities?.fullRebuild).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - sampleProfiles()", () => {
|
||||
test("sampleProfiles omits limit when unset", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "job_1",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/job_1/",
|
||||
operation: "sample",
|
||||
entity_type: "user",
|
||||
sampled: 5,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.sampleProfiles();
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
// Every job names an entity kind: the API refuses one that does not.
|
||||
expect(getFetchBody(call!)).toEqual({
|
||||
operation: "sample",
|
||||
entity_type: "user",
|
||||
});
|
||||
});
|
||||
|
||||
test("sampleProfiles passes an explicit limit", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "job_2",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/job_2/",
|
||||
operation: "sample",
|
||||
entity_type: "user",
|
||||
sampled: 3,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.sampleProfiles({ limit: 3 });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
expect(getFetchBody(call!).operation).toBe("sample");
|
||||
expect(getFetchBody(call!).limit).toBe(3);
|
||||
expect(getFetchBody(call!).entity_type).toBe("user");
|
||||
expect(result.sampled).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,6 @@ const OPAQUE_VALUE_KEYS = new Set([
|
||||
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
|
||||
"customCategories",
|
||||
"custom_categories",
|
||||
// A profile's keys come from the customer's own JSON Schema. The schema itself
|
||||
// is handled in `updateProfileSettings`, so that `schema` is not opaque globally.
|
||||
"profile",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+2
-382
@@ -1,7 +1,6 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -40,43 +39,6 @@ setup_config()
|
||||
# Entity parameters that must be passed via filters, not top-level
|
||||
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
|
||||
|
||||
# One collection for every generation; the operation is a body field.
|
||||
PROFILE_JOBS_PATH = "/v2/profiles/jobs/"
|
||||
PROFILE_SETTINGS_PATH = "/v2/profiles/settings/"
|
||||
|
||||
# Distinguishes an omitted argument from an explicit ``None`` that clears a field.
|
||||
_UNSET: Any = object()
|
||||
|
||||
|
||||
def _profile_settings_payload(
|
||||
enabled: Optional[bool],
|
||||
schema: Any = _UNSET,
|
||||
custom_instructions: Any = _UNSET,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the settings body the API accepts.
|
||||
|
||||
``schema`` and ``custom_instructions`` are per user and nest under
|
||||
``entities``; only ``enabled`` is project-wide. This mirrors what
|
||||
``get_profile_settings`` returns, so the two round-trip.
|
||||
|
||||
Sending them flat is rejected with ``Unsupported settings``, so this shape is
|
||||
not cosmetic. ``_UNSET`` leaves a field unchanged; an explicit ``None`` clears it.
|
||||
"""
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
if enabled is not None:
|
||||
payload["enabled"] = enabled
|
||||
|
||||
entity_settings: Dict[str, Any] = {}
|
||||
if schema is not _UNSET:
|
||||
entity_settings["schema"] = schema
|
||||
if custom_instructions is not _UNSET:
|
||||
entity_settings["custom_instructions"] = custom_instructions
|
||||
|
||||
if entity_settings:
|
||||
payload["entities"] = {"user": entity_settings}
|
||||
return payload
|
||||
|
||||
|
||||
def _validate_and_trim_search_query(query: str) -> str:
|
||||
if not isinstance(query, str):
|
||||
@@ -409,9 +371,7 @@ class MemoryClient:
|
||||
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
|
||||
|
||||
if not payload:
|
||||
raise ValueError(
|
||||
"At least one of text, metadata, timestamp, or expiration_date must be provided for update."
|
||||
)
|
||||
raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.")
|
||||
|
||||
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
params = self._prepare_params()
|
||||
@@ -722,174 +682,6 @@ class MemoryClient:
|
||||
capture_client_event("client.get_summary", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def get_profile(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""Get the memory profile for a single user.
|
||||
|
||||
Branch on ``status``, not on an empty ``profile``: generation is
|
||||
asynchronous, so a known user without a profile yet is a normal response.
|
||||
|
||||
Args:
|
||||
entity_id: The user's id, as you supplied it on ``add`` (e.g. "alice").
|
||||
|
||||
Returns:
|
||||
Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
|
||||
``updated_at`` and ``generation_count``. ``status`` is one of
|
||||
"succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = self.client.get(f"/v2/entities/user/{_encode_path_segment(entity_id)}/profile/")
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get_profile", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Generate or refresh the profile for a single user, now.
|
||||
|
||||
Profiles are otherwise built once a user crosses an internal message
|
||||
threshold, so a new user has none for its first few memories. Returns as
|
||||
soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
|
||||
|
||||
Args:
|
||||
entity_id: The user's id, as you supplied it on ``add``.
|
||||
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||
the same value to safely retry a lost request without starting
|
||||
(and being billed for) a second job. A fresh key is generated when
|
||||
omitted.
|
||||
|
||||
Returns:
|
||||
Dict containing ``job_id``, ``status``, ``status_url``, ``operation``,
|
||||
``entity_type``, ``entity_count_reserved``, ``event_id`` and
|
||||
``replayed``. Poll :meth:`get_profile` and branch on ``status``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If profiles are not enabled and configured for the
|
||||
project.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
||||
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.generate_profile", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def get_profile_settings(self) -> Dict[str, Any]:
|
||||
"""Get the profile settings for the current project.
|
||||
|
||||
Returns:
|
||||
Dict with ``enabled`` and ``capabilities`` at the top level, and
|
||||
``entities`` holding each entity type's ``schema`` and
|
||||
``custom_instructions``.
|
||||
"""
|
||||
|
||||
response = self.client.get(PROFILE_SETTINGS_PATH)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get_profile_settings", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def update_profile_settings(
|
||||
self,
|
||||
enabled: Optional[bool] = None,
|
||||
schema: Any = _UNSET,
|
||||
custom_instructions: Any = _UNSET,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the profile settings for the current project.
|
||||
|
||||
Only the arguments you pass are written.
|
||||
|
||||
Args:
|
||||
enabled: Turn profile generation on or off. Project-wide.
|
||||
schema: JSON Schema for the profile. Every property needs a
|
||||
``description``. Pass ``None`` to clear it; omit to leave it
|
||||
unchanged. Applies to user profiles.
|
||||
custom_instructions: Extra guidance for the extraction step. Pass
|
||||
``None`` to clear it; omit to leave it unchanged. Applies to user
|
||||
profiles.
|
||||
|
||||
Returns:
|
||||
Dict with the settings as stored after the update, in the same
|
||||
shape :meth:`get_profile_settings` returns.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the schema is not a valid profile schema.
|
||||
"""
|
||||
|
||||
payload = _profile_settings_payload(enabled, schema, custom_instructions)
|
||||
response = self.client.post(PROFILE_SETTINGS_PATH, json=payload)
|
||||
response.raise_for_status()
|
||||
capture_client_event(
|
||||
"client.update_profile_settings",
|
||||
self,
|
||||
{"keys": list(payload.keys()), "sync_type": "sync"},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def sample_profiles(self, limit: Optional[int] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Generate profiles for a few real users, to check a schema.
|
||||
|
||||
Real generations against real memories, and the results are kept. The
|
||||
profiles are written to those users and count toward usage.
|
||||
|
||||
Args:
|
||||
limit: How many users to sample, 1-10. Defaults to the server value.
|
||||
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||
the same value to safely retry without starting a second sample
|
||||
run. A fresh key is generated when omitted.
|
||||
|
||||
Returns:
|
||||
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
||||
and the ``entity_ids`` that were picked. Poll
|
||||
:meth:`get_profile_job` with ``status_url`` until the status is terminal.
|
||||
|
||||
Raises:
|
||||
ValidationError: If profiles are not enabled and configured.
|
||||
RateLimitError: If a sample run was already started very recently.
|
||||
"""
|
||||
|
||||
payload = self._prepare_params({"limit": limit})
|
||||
payload["operation"] = "sample"
|
||||
payload["entity_type"] = "user"
|
||||
response = self.client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.sample_profiles", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def get_profile_job(self, job_id_or_status_url: str) -> Dict[str, Any]:
|
||||
"""Read one generation job.
|
||||
|
||||
Accepts the ``status_url`` from a create call, or a bare job id. Prefer
|
||||
passing ``status_url`` so a route change needs no client update.
|
||||
|
||||
Returns:
|
||||
Dict whose ``job`` key holds the job: ``status``, ``total``,
|
||||
``completed``, ``succeeded``, ``failed`` and ``skipped``. ``total`` is
|
||||
null until ``enumeration_complete``.
|
||||
"""
|
||||
|
||||
path = job_id_or_status_url
|
||||
if not path.startswith("/"):
|
||||
path = f"{PROFILE_JOBS_PATH}{path}/"
|
||||
response = self.client.get(path)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Get instructions or categories for the current project.
|
||||
@@ -1499,9 +1291,7 @@ class AsyncMemoryClient:
|
||||
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
|
||||
|
||||
if not payload:
|
||||
raise ValueError(
|
||||
"At least one of text, metadata, timestamp, or expiration_date must be provided for update."
|
||||
)
|
||||
raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.")
|
||||
|
||||
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
params = self._prepare_params()
|
||||
@@ -1798,176 +1588,6 @@ class AsyncMemoryClient:
|
||||
capture_client_event("client.get_summary", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def get_profile(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""Get the memory profile for a single user.
|
||||
|
||||
Branch on ``status``, not on an empty ``profile``: generation is
|
||||
asynchronous, so a known user without a profile yet is a normal response.
|
||||
|
||||
Args:
|
||||
entity_id: The user's id, as you supplied it on ``add`` (e.g. "alice").
|
||||
|
||||
Returns:
|
||||
Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
|
||||
``updated_at`` and ``generation_count``. ``status`` is one of
|
||||
"succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = await self.async_client.get(f"/v2/entities/user/{_encode_path_segment(entity_id)}/profile/")
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get_profile", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Generate or refresh the profile for a single user, now.
|
||||
|
||||
Profiles are otherwise built once a user crosses an internal message
|
||||
threshold, so a new user has none for its first few memories. Returns as
|
||||
soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
|
||||
|
||||
Args:
|
||||
entity_id: The user's id, as you supplied it on ``add``.
|
||||
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||
the same value to safely retry a lost request without starting
|
||||
(and being billed for) a second job. A fresh key is generated when
|
||||
omitted.
|
||||
|
||||
Returns:
|
||||
Dict containing ``job_id``, ``status``, ``status_url``, ``operation``,
|
||||
``entity_type``, ``entity_count_reserved``, ``event_id`` and
|
||||
``replayed``. Poll :meth:`get_profile` and branch on ``status``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If profiles are not enabled and configured for the
|
||||
project.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = await self.async_client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
||||
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.generate_profile", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def get_profile_settings(self) -> Dict[str, Any]:
|
||||
"""Get the profile settings for the current project.
|
||||
|
||||
Returns:
|
||||
Dict with ``enabled`` and ``capabilities`` at the top level, and
|
||||
``entities`` holding each entity type's ``schema`` and
|
||||
``custom_instructions``.
|
||||
"""
|
||||
|
||||
response = await self.async_client.get(PROFILE_SETTINGS_PATH)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get_profile_settings", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def update_profile_settings(
|
||||
self,
|
||||
enabled: Optional[bool] = None,
|
||||
schema: Any = _UNSET,
|
||||
custom_instructions: Any = _UNSET,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the profile settings for the current project.
|
||||
|
||||
Only the arguments you pass are written.
|
||||
|
||||
Args:
|
||||
enabled: Turn profile generation on or off. Project-wide.
|
||||
schema: JSON Schema for the profile. Every property needs a
|
||||
``description``. Pass ``None`` to clear it; omit to leave it
|
||||
unchanged. Applies to user profiles.
|
||||
custom_instructions: Extra guidance for the extraction step. Pass
|
||||
``None`` to clear it; omit to leave it unchanged. Applies to user
|
||||
profiles.
|
||||
|
||||
Returns:
|
||||
Dict with the settings as stored after the update, in the same
|
||||
shape :meth:`get_profile_settings` returns.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the schema is not a valid profile schema.
|
||||
"""
|
||||
|
||||
payload = _profile_settings_payload(enabled, schema, custom_instructions)
|
||||
response = await self.async_client.post(PROFILE_SETTINGS_PATH, json=payload)
|
||||
response.raise_for_status()
|
||||
capture_client_event(
|
||||
"client.update_profile_settings",
|
||||
self,
|
||||
{"keys": list(payload.keys()), "sync_type": "async"},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def sample_profiles(
|
||||
self, limit: Optional[int] = None, idempotency_key: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate profiles for a few real users, to check a schema.
|
||||
|
||||
Real generations against real memories, and the results are kept. The
|
||||
profiles are written to those users and count toward usage.
|
||||
|
||||
Args:
|
||||
limit: How many users to sample, 1-10. Defaults to the server value.
|
||||
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||
the same value to safely retry without starting a second sample
|
||||
run. A fresh key is generated when omitted.
|
||||
|
||||
Returns:
|
||||
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
||||
and the ``entity_ids`` that were picked. Poll
|
||||
:meth:`get_profile_job` with ``status_url`` until the status is terminal.
|
||||
|
||||
Raises:
|
||||
ValidationError: If profiles are not enabled and configured.
|
||||
RateLimitError: If a sample run was already started very recently.
|
||||
"""
|
||||
|
||||
payload = self._prepare_params({"limit": limit})
|
||||
payload["operation"] = "sample"
|
||||
payload["entity_type"] = "user"
|
||||
response = await self.async_client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.sample_profiles", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def get_profile_job(self, job_id_or_status_url: str) -> Dict[str, Any]:
|
||||
"""Read one generation job.
|
||||
|
||||
Accepts the ``status_url`` from a create call, or a bare job id. Prefer
|
||||
passing ``status_url`` so a route change needs no client update.
|
||||
|
||||
Returns:
|
||||
Dict whose ``job`` key holds the job: ``status``, ``total``,
|
||||
``completed``, ``succeeded``, ``failed`` and ``skipped``. ``total`` is
|
||||
null until ``enumeration_complete``.
|
||||
"""
|
||||
|
||||
path = job_id_or_status_url
|
||||
if not path.startswith("/"):
|
||||
path = f"{PROFILE_JOBS_PATH}{path}/"
|
||||
response = await self.async_client.get(path)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Get instructions or categories for the current project.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "2.1.0"
|
||||
version = "2.0.20"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "support@mem0.ai" }
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
"""Tests for the MemoryClient profile methods.
|
||||
|
||||
These assert request construction — path, verb, body — rather than echoing a
|
||||
mocked response back. The profile payload itself is the customer's own JSON
|
||||
Schema shape, so the tests also pin that the SDK passes it through untouched.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_client():
|
||||
"""A MemoryClient whose transport is mocked."""
|
||||
with patch("mem0.client.main.httpx.Client") as mock_httpx:
|
||||
mock_http_client = MagicMock()
|
||||
mock_http_client.get.return_value = MagicMock(
|
||||
json=lambda: {"org_id": "org1", "project_id": "proj1", "user_email": "test@test.com"},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
mock_httpx.return_value = mock_http_client
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
from mem0.client.main import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="test-api-key")
|
||||
# The constructor pings through this same mock; drop that call.
|
||||
mock_http_client.get.reset_mock()
|
||||
yield client
|
||||
|
||||
|
||||
def _assert_job_call(post_mock, expected_json):
|
||||
"""One jobs collection, operation in the body, idempotency key per attempt."""
|
||||
post_mock.assert_called_once()
|
||||
args, kwargs = post_mock.call_args
|
||||
assert args[0] == "/v2/profiles/jobs/"
|
||||
assert kwargs["json"] == expected_json
|
||||
assert kwargs["headers"]["Idempotency-Key"]
|
||||
|
||||
|
||||
def _mock_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
class TestGetProfile:
|
||||
def test_reads_the_v2_entity_route(self, mock_memory_client):
|
||||
mock_memory_client.client.get.return_value = _mock_response(
|
||||
{"profile": {}, "status": "pending", "entity_type": "user", "entity_id": "alice"}
|
||||
)
|
||||
|
||||
mock_memory_client.get_profile("alice")
|
||||
|
||||
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
||||
|
||||
def test_encodes_path_segments(self, mock_memory_client):
|
||||
"""An id with a slash must not open a new path segment."""
|
||||
mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
|
||||
|
||||
mock_memory_client.get_profile("tenant/alice")
|
||||
|
||||
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/tenant%2Falice/profile/")
|
||||
|
||||
def test_returns_the_envelope_verbatim(self, mock_memory_client):
|
||||
"""The customer's schema keys reach the caller exactly as stored."""
|
||||
payload = {
|
||||
"profile": {"favorite_topics": ["hiking"], "work_style": {"preferred_hours": "mornings"}},
|
||||
"status": "succeeded",
|
||||
"entity_type": "user",
|
||||
"entity_id": "alice",
|
||||
"updated_at": "2026-02-08T00:00:00Z",
|
||||
"generation_count": 3,
|
||||
}
|
||||
mock_memory_client.client.get.return_value = _mock_response(payload)
|
||||
|
||||
assert mock_memory_client.get_profile("alice") == payload
|
||||
|
||||
|
||||
class TestGenerateProfile:
|
||||
def test_posts_entity_type_and_id(self, mock_memory_client):
|
||||
mock_memory_client.client.post.return_value = _mock_response(
|
||||
{"job_id": "j1", "status": "QUEUED", "status_url": "/v2/profiles/jobs/j1/"}
|
||||
)
|
||||
|
||||
mock_memory_client.generate_profile("alice")
|
||||
|
||||
_assert_job_call(
|
||||
mock_memory_client.client.post,
|
||||
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
||||
)
|
||||
|
||||
def test_reuses_caller_idempotency_key(self, mock_memory_client):
|
||||
"""A caller-supplied key lets a retry hit the same job instead of billing twice."""
|
||||
mock_memory_client.client.post.return_value = _mock_response({"job_id": "j1", "status": "QUEUED"})
|
||||
|
||||
mock_memory_client.generate_profile("alice", idempotency_key="retry-key-123")
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["headers"]["Idempotency-Key"] == "retry-key-123"
|
||||
|
||||
|
||||
class TestProfileSettings:
|
||||
def test_get_reads_v2(self, mock_memory_client):
|
||||
mock_memory_client.client.get.return_value = _mock_response(
|
||||
{"enabled": True, "entities": {"user": {"schema": None, "custom_instructions": None}}}
|
||||
)
|
||||
|
||||
mock_memory_client.get_profile_settings()
|
||||
|
||||
mock_memory_client.client.get.assert_called_once_with("/v2/profiles/settings/")
|
||||
|
||||
def test_update_sends_only_supplied_fields(self, mock_memory_client):
|
||||
"""A partial update must not blank the fields it never mentions."""
|
||||
mock_memory_client.client.post.return_value = _mock_response({"enabled": False})
|
||||
|
||||
mock_memory_client.update_profile_settings(enabled=False)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v2/profiles/settings/",
|
||||
json={"enabled": False},
|
||||
)
|
||||
|
||||
def test_update_nests_schema_under_entities(self, mock_memory_client):
|
||||
"""The API takes only ``enabled`` and ``entities`` at the top level.
|
||||
|
||||
A flat body is rejected with ``Unsupported settings``, so this nesting is
|
||||
what makes the call work at all.
|
||||
"""
|
||||
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
|
||||
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
|
||||
|
||||
mock_memory_client.update_profile_settings(enabled=True, schema=schema)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert set(kwargs["json"]) == {"enabled", "entities"}
|
||||
assert "schema" not in kwargs["json"]
|
||||
|
||||
def test_update_targets_the_user_entity_type(self, mock_memory_client):
|
||||
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
|
||||
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
|
||||
|
||||
mock_memory_client.update_profile_settings(schema=schema)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"] == {"entities": {"user": {"schema": schema}}}
|
||||
|
||||
def test_update_passes_schema_verbatim(self, mock_memory_client):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"favorite_topics": {
|
||||
"type": "array",
|
||||
"description": "Topics the user returns to",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
}
|
||||
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
|
||||
|
||||
mock_memory_client.update_profile_settings(enabled=True, schema=schema, custom_instructions="Keep it durable")
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v2/profiles/settings/",
|
||||
json={
|
||||
"enabled": True,
|
||||
"entities": {"user": {"schema": schema, "custom_instructions": "Keep it durable"}},
|
||||
},
|
||||
)
|
||||
|
||||
def test_update_clears_fields_with_explicit_none(self, mock_memory_client):
|
||||
"""Explicit ``None`` clears a field; the sentinel default leaves it untouched."""
|
||||
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
|
||||
|
||||
mock_memory_client.update_profile_settings(schema=None, custom_instructions=None)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"] == {"entities": {"user": {"schema": None, "custom_instructions": None}}}
|
||||
|
||||
|
||||
class TestSampleProfiles:
|
||||
def test_sample_without_limit(self, mock_memory_client):
|
||||
mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "entity_ids": []})
|
||||
|
||||
mock_memory_client.sample_profiles()
|
||||
|
||||
_assert_job_call(mock_memory_client.client.post, {"operation": "sample", "entity_type": "user"})
|
||||
|
||||
def test_sample_with_limit(self, mock_memory_client):
|
||||
mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "entity_ids": []})
|
||||
|
||||
mock_memory_client.sample_profiles(limit=3)
|
||||
|
||||
_assert_job_call(
|
||||
mock_memory_client.client.post,
|
||||
{"operation": "sample", "limit": 3, "entity_type": "user"},
|
||||
)
|
||||
|
||||
|
||||
class TestAsyncClientParity:
|
||||
"""The async client must speak the same wire protocol as the sync one."""
|
||||
|
||||
@pytest.fixture
|
||||
def async_client(self):
|
||||
# AsyncMemoryClient validates the key synchronously, through requests.
|
||||
validation = MagicMock()
|
||||
validation.json.return_value = {
|
||||
"org_id": "org1",
|
||||
"project_id": "proj1",
|
||||
"user_email": "test@test.com",
|
||||
}
|
||||
validation.raise_for_status.return_value = None
|
||||
|
||||
with patch("mem0.client.main.httpx.AsyncClient") as mock_httpx:
|
||||
mock_httpx.return_value = MagicMock()
|
||||
with patch("mem0.client.main.requests.get", return_value=validation):
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
from mem0.client.main import AsyncMemoryClient
|
||||
|
||||
yield AsyncMemoryClient(api_key="test-api-key")
|
||||
|
||||
def test_get_profile(self, async_client):
|
||||
async_client.async_client.get = AsyncMock(return_value=_mock_response({"profile": {}, "status": "pending"}))
|
||||
|
||||
asyncio.run(async_client.get_profile("alice"))
|
||||
|
||||
async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
||||
|
||||
def test_generate_profile(self, async_client):
|
||||
async_client.async_client.post = AsyncMock(return_value=_mock_response({"job_id": "j1", "status": "QUEUED"}))
|
||||
|
||||
asyncio.run(async_client.generate_profile("alice"))
|
||||
|
||||
_assert_job_call(
|
||||
async_client.async_client.post,
|
||||
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
||||
)
|
||||
|
||||
def test_update_settings_partial(self, async_client):
|
||||
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
|
||||
|
||||
asyncio.run(async_client.update_profile_settings(enabled=True))
|
||||
|
||||
async_client.async_client.post.assert_called_once_with(
|
||||
"/v2/profiles/settings/",
|
||||
json={"enabled": True},
|
||||
)
|
||||
|
||||
def test_update_settings_nests_schema(self, async_client):
|
||||
"""The async client builds the same body as the sync one."""
|
||||
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
|
||||
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
|
||||
|
||||
asyncio.run(async_client.update_profile_settings(enabled=True, schema=schema))
|
||||
|
||||
async_client.async_client.post.assert_called_once_with(
|
||||
"/v2/profiles/settings/",
|
||||
json={"enabled": True, "entities": {"user": {"schema": schema}}},
|
||||
)
|
||||
|
||||
def test_update_settings_clears_with_none(self, async_client):
|
||||
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
|
||||
|
||||
asyncio.run(async_client.update_profile_settings(custom_instructions=None))
|
||||
|
||||
async_client.async_client.post.assert_called_once_with(
|
||||
"/v2/profiles/settings/",
|
||||
json={"entities": {"user": {"custom_instructions": None}}},
|
||||
)
|
||||
Reference in New Issue
Block a user