From ea9bbcabed98418a1800c104aade8975e6460a04 Mon Sep 17 00:00:00 2001 From: Karthik Date: Thu, 24 Sep 2026 00:11:22 +0530 Subject: [PATCH] =?UTF-8?q?feat(profiles):=20User=20Profiles=20v1=20?= =?UTF-8?q?=E2=80=94=20SDK=20methods=20+=20docs=20(#7340)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pratik <10096516+pratikgajjar@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .../profiles/generate-profiles.mdx | 5 + .../profiles/get-profile-job.mdx | 5 + .../profiles/get-profile-settings.mdx | 5 + docs/api-reference/profiles/get-profile.mdx | 5 + .../profiles/update-profile-settings.mdx | 5 + docs/changelog/sdk.mdx | 14 + docs/docs.json | 12 + docs/llms.txt | 6 + docs/openapi.json | 476 ++++++++++- docs/platform/features/user-profiles.mdx | 359 ++++++++ examples/notebooks/user-profiles.ipynb | 764 ++++++++++++++++++ mem0-ts/package.json | 2 +- mem0-ts/src/client/index.ts | 8 + mem0-ts/src/client/mem0.ts | 206 ++++- mem0-ts/src/client/mem0.types.ts | 95 +++ .../tests/memoryClient.profiles.test.ts | 341 ++++++++ mem0-ts/src/client/utils.ts | 3 + mem0/client/main.py | 384 ++++++++- pyproject.toml | 2 +- tests/test_client_profiles.py | 272 +++++++ 20 files changed, 2961 insertions(+), 8 deletions(-) create mode 100644 docs/api-reference/profiles/generate-profiles.mdx create mode 100644 docs/api-reference/profiles/get-profile-job.mdx create mode 100644 docs/api-reference/profiles/get-profile-settings.mdx create mode 100644 docs/api-reference/profiles/get-profile.mdx create mode 100644 docs/api-reference/profiles/update-profile-settings.mdx create mode 100644 docs/platform/features/user-profiles.mdx create mode 100644 examples/notebooks/user-profiles.ipynb create mode 100644 mem0-ts/src/client/tests/memoryClient.profiles.test.ts create mode 100644 tests/test_client_profiles.py diff --git a/docs/api-reference/profiles/generate-profiles.mdx b/docs/api-reference/profiles/generate-profiles.mdx new file mode 100644 index 000000000..e5de5a12d --- /dev/null +++ b/docs/api-reference/profiles/generate-profiles.mdx @@ -0,0 +1,5 @@ +--- +title: 'Generate Profiles' +description: "Start one generation: sample a few entities, or build one for a single entity." +openapi: post /v2/profiles/jobs/ +--- diff --git a/docs/api-reference/profiles/get-profile-job.mdx b/docs/api-reference/profiles/get-profile-job.mdx new file mode 100644 index 000000000..c24e86794 --- /dev/null +++ b/docs/api-reference/profiles/get-profile-job.mdx @@ -0,0 +1,5 @@ +--- +title: 'Get Generation Job' +description: "Read the progress of a generation, and whether it finished." +openapi: get /v2/profiles/jobs/{job_id}/ +--- diff --git a/docs/api-reference/profiles/get-profile-settings.mdx b/docs/api-reference/profiles/get-profile-settings.mdx new file mode 100644 index 000000000..82eb1b37b --- /dev/null +++ b/docs/api-reference/profiles/get-profile-settings.mdx @@ -0,0 +1,5 @@ +--- +title: 'Get Profile Settings' +description: "Retrieve the profile schema, custom instructions, and enabled flag for the current project." +openapi: get /v2/profiles/settings/ +--- diff --git a/docs/api-reference/profiles/get-profile.mdx b/docs/api-reference/profiles/get-profile.mdx new file mode 100644 index 000000000..604535a3c --- /dev/null +++ b/docs/api-reference/profiles/get-profile.mdx @@ -0,0 +1,5 @@ +--- +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/ +--- diff --git a/docs/api-reference/profiles/update-profile-settings.mdx b/docs/api-reference/profiles/update-profile-settings.mdx new file mode 100644 index 000000000..e33dc9198 --- /dev/null +++ b/docs/api-reference/profiles/update-profile-settings.mdx @@ -0,0 +1,5 @@ +--- +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/ +--- diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index 008e58b9f..bf53c5d31 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -7,6 +7,13 @@ mode: "wide" + + +**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. Every job POST carries an `Idempotency-Key`; to retry a lost request without starting a second job, pass the same `idempotency_key` on each attempt ([#7340](https://github.com/mem0ai/mem0/pull/7340)) + + + **Improvements:** @@ -1235,6 +1242,13 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to- + + +**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. Every job POST carries an `Idempotency-Key`; to retry a lost request without starting a second job, pass the same `idempotencyKey` on each attempt ([#7340](https://github.com/mem0ai/mem0/pull/7340)) + + + **Improvements:** diff --git a/docs/docs.json b/docs/docs.json index 7aecc007c..969ebad35 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,6 +72,7 @@ "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", @@ -513,6 +514,17 @@ "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", diff --git a/docs/llms.txt b/docs/llms.txt index ce60e29f5..2a939cbe3 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -198,6 +198,7 @@ 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. @@ -364,6 +365,11 @@ 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. diff --git a/docs/openapi.json b/docs/openapi.json index af215596f..f27449514 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -8070,6 +8070,480 @@ } } } + }, + "/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": "Settings for user profiles, under `user`.", + "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." + } + } + } + } + }, + "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": "Settings for user profiles, under `user`.", + "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": "Settings for user profiles, under `user`.", + "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." + } + } + } + } + }, + "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": { @@ -8988,4 +9462,4 @@ } }, "x-original-swagger-version": "2.0" -} \ No newline at end of file +} diff --git a/docs/platform/features/user-profiles.mdx b/docs/platform/features/user-profiles.mdx new file mode 100644 index 000000000..1e99f59ea --- /dev/null +++ b/docs/platform/features/user-profiles.mdx @@ -0,0 +1,359 @@ +--- +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. + + + **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. + + + + User Profiles are in **beta** and available on request. To enable them for your + organization, contact [support@mem0.ai](mailto:support@mem0.ai). + + +## 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. + + +```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.", +}); +``` + + + + 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. + + +Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone. + +## Read a profile + + +```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); +} +``` + + +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: + + +```python Python +client.generate_profile("alice") +``` + +```typescript TypeScript +await client.generateProfile({ entityId: "alice" }); +``` + + +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: + + +```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 })); +} +``` + + +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`. + + +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. + + +## 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. + + + 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). + + +## 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. + + + 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. + + +## 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`. + + + Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary. + + +## 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 + + + + How users, agents, apps and runs partition memories. + + + Steer what Mem0 extracts in the first place. + + diff --git a/examples/notebooks/user-profiles.ipynb b/examples/notebooks/user-profiles.ipynb new file mode 100644 index 000000000..72ef7580d --- /dev/null +++ b/examples/notebooks/user-profiles.ipynb @@ -0,0 +1,764 @@ +{ + "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": "markdown", + "metadata": {}, + "source": [ + "> **Use a disposable project.** This notebook overwrites the project's profile settings\n", + "> (enabled, schema, custom instructions). The last cell restores the values saved at the\n", + "> start, but only if you reach it: if a cell fails midway, the project keeps the demo\n", + "> schema until you run the cleanup cell or reset it yourself. Do not point it at a\n", + "> project other people or production traffic depend on.\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", + "# Stop here unless the add SUCCEEDED. A failed or unfinished add would otherwise let the\n", + "# generation below bill for a profile built without these memories.\n", + "if event_status != \"SUCCEEDED\":\n", + " raise RuntimeError(f\"add did not succeed (status={event_status}); not generating a profile\")\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", + "Each call sends a new `Idempotency-Key` unless you pass one, and a new key starts a new job.\n", + "To retry a dropped request safely, generate the key yourself and pass the same\n", + "`idempotency_key` on every attempt: the server then returns the original job instead of\n", + "billing a second one.\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", + " status = None\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", + " # Generating after a FAILED or unfinished add bills for a profile built from the OLD\n", + " # memories only, so stop instead.\n", + " if status != \"SUCCEEDED\":\n", + " raise RuntimeError(f\"follow-up add did not succeed (status={status}); not regenerating\")\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": "# Restore the project's profile settings to the start-of-run snapshot in `finally`, so a\n# failed delete still leaves a shared project as we found it. Passing the original values\n# (including None) clears anything this notebook set: the SDK treats an explicit None as\n# \"clear\" and an omitted argument as \"unchanged\".\ntry:\n # `fresh` only exists if the error-handling section ran.\n for entity_id in (USER_ID, globals().get(\"fresh\")):\n if entity_id is None:\n continue\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\nfinally:\n _user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\n client.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n )\n print(\"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 +} \ No newline at end of file diff --git a/mem0-ts/package.json b/mem0-ts/package.json index b0490dd2e..3bb996079 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -1,6 +1,6 @@ { "name": "mem0ai", - "version": "3.2.0", + "version": "3.3.0", "description": "The Memory Layer For Your AI Apps", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/mem0-ts/src/client/index.ts b/mem0-ts/src/client/index.ts index 24ec5b8f4..138b29dc7 100644 --- a/mem0-ts/src/client/index.ts +++ b/mem0-ts/src/client/index.ts @@ -23,6 +23,14 @@ export type { FeedbackPayload, CreateMemoryExportPayload, GetMemoryExportPayload, + ProfileEntityType, + ProfileStatus, + ProfileResponse, + ProfileJobResponse, + ProfileJobStatus, + ProfileSettings, + ProfileSettingsResponse, + EntityProfileSettings, } from "./mem0.types"; // Re-export enums as values (not type-only) diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 33e03f0c2..0f8725164 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { v7 as uuidv7 } from "uuid"; import { AllUsers, PaginatedMemories, @@ -20,6 +21,12 @@ import { FeedbackPayload, CreateMemoryExportPayload, GetMemoryExportPayload, + ProfileEntityType, + ProfileResponse, + ProfileJobResponse, + ProfileJobStatus, + ProfileSettings, + ProfileSettingsResponse, } from "./mem0.types"; import { captureClientEvent, @@ -92,6 +99,9 @@ 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>(); @@ -305,7 +315,8 @@ export default class MemoryClient { }); } - async _fetchWithErrorHandling(url: string, options: any): Promise { + /** Fetch with no key conversion, for payloads carrying user-controlled property names. */ + async _fetchRawJson(url: string, options: any): Promise { const response = await fetch(url, { ...options, headers: { @@ -318,8 +329,11 @@ export default class MemoryClient { const errorData = await response.text(); throw createExceptionFromResponse(response.status, errorData); } - const jsonResponse = await response.json(); - return snakeToCamelKeys(jsonResponse); + return response.json(); + } + + async _fetchWithErrorHandling(url: string, options: any): Promise { + return snakeToCamelKeys(await this._fetchRawJson(url, options)); } _preparePayload( @@ -816,6 +830,192 @@ 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 { + 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 { + 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 ?? uuidv7(), + }, + 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 { + 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; + } + + 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 + ).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 { + const payloadKeys = Object.keys(settings || {}); + this._captureEvent("update_profile_settings", [payloadKeys]); + await this._awaitIdentity(); + + const { schema, customInstructions, enabled } = settings || {}; + + const body: Record = {}; + if (enabled !== undefined) { + body.enabled = enabled; + } + + const entitySettings: Record = {}; + // 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 { + 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 ?? uuidv7(), + }, + 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 { + 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 }> { diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index cbe541321..606cf0daf 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -239,3 +239,98 @@ export interface GetMemoryExportPayload { filters?: Record; 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; + 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; + /** Null when the job has no associated event. */ + eventId?: string | null; + 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 | 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 | 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>; + capabilities?: Record; + [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; + [key: string]: any; + }; +} diff --git a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts new file mode 100644 index 000000000..27069a570 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts @@ -0,0 +1,341 @@ +/** + * 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(); + 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(); + 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(); + 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(); + 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; + 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(); + 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; + 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(); + 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. + // Exact, not a subset match: the customer's keys must go out unchanged. + expect(body.entities).toEqual({ + user: { schema, custom_instructions: "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(); + 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 as object)).toEqual(["user"]); + }); + + test("omits fields the caller did not set", async () => { + const extra = new Map(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/mem0-ts/src/client/utils.ts b/mem0-ts/src/client/utils.ts index aca9ffbcd..8630ad470 100644 --- a/mem0-ts/src/client/utils.ts +++ b/mem0-ts/src/client/utils.ts @@ -34,6 +34,9 @@ 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", ]); /** diff --git a/mem0/client/main.py b/mem0/client/main.py index 3a82d9e5a..d65d4f0b1 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -1,6 +1,7 @@ import hashlib import logging import os +import uuid import warnings from typing import Any, Dict, List, Optional from urllib.parse import quote @@ -39,6 +40,43 @@ 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): @@ -452,7 +490,9 @@ 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() @@ -763,6 +803,174 @@ 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. @@ -1361,7 +1569,9 @@ 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() @@ -1658,6 +1868,176 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 73c5cdf71..ddb3d7153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0ai" -version = "2.1.0" +version = "2.2.0" description = "Long-term memory for AI Agents" authors = [ { name = "Mem0", email = "support@mem0.ai" } diff --git a/tests/test_client_profiles.py b/tests/test_client_profiles.py new file mode 100644 index 000000000..c2ea0eff3 --- /dev/null +++ b/tests/test_client_profiles.py @@ -0,0 +1,272 @@ +"""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}}}, + )