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..fc10ef8f4 --- /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 or agent, with a status describing whether generation has completed." +openapi: get /v2/entities/{entity_type}/{entity_id}/profile/ +--- diff --git a/docs/api-reference/profiles/regenerate-profiles.mdx b/docs/api-reference/profiles/regenerate-profiles.mdx new file mode 100644 index 000000000..694e895fb --- /dev/null +++ b/docs/api-reference/profiles/regenerate-profiles.mdx @@ -0,0 +1,5 @@ +--- +title: 'Regenerate Profiles' +description: "Rebuild the profile of every entity in the project, applying the current schema to entities that already have one." +openapi: post /v2/profiles/regenerate/ +--- diff --git a/docs/api-reference/profiles/sample-profiles.mdx b/docs/api-reference/profiles/sample-profiles.mdx new file mode 100644 index 000000000..ff231a586 --- /dev/null +++ b/docs/api-reference/profiles/sample-profiles.mdx @@ -0,0 +1,5 @@ +--- +title: 'Sample Profiles' +description: "Generate profiles for a small set of real entities to evaluate a schema before applying it across the project." +openapi: post /v2/profiles/samples/ +--- diff --git a/docs/api-reference/profiles/trigger-profile.mdx b/docs/api-reference/profiles/trigger-profile.mdx new file mode 100644 index 000000000..4dfde7709 --- /dev/null +++ b/docs/api-reference/profiles/trigger-profile.mdx @@ -0,0 +1,5 @@ +--- +title: 'Generate Profile' +description: "Generate or refresh the profile for a single user or agent immediately, instead of waiting for the message threshold." +openapi: post /v2/profiles/trigger/ +--- 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/docs.json b/docs/docs.json index c1c4887d2..ed22f8635 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -71,6 +71,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", @@ -511,6 +512,18 @@ "api-reference/entities/delete-user" ] }, + { + "group": "Profiles", + "icon": "id-card", + "pages": [ + "api-reference/profiles/get-profile", + "api-reference/profiles/trigger-profile", + "api-reference/profiles/get-profile-settings", + "api-reference/profiles/update-profile-settings", + "api-reference/profiles/sample-profiles", + "api-reference/profiles/regenerate-profiles" + ] + }, { "group": "Organizations", "icon": "building", diff --git a/docs/llms.txt b/docs/llms.txt index 65e13de8f..dfedf13b9 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -197,6 +197,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 or agent 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. @@ -362,6 +363,12 @@ 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 or agent's structured profile and branching on its generation status. +- [Generate Profile](https://docs.mem0.ai/api-reference/profiles/trigger-profile) [Platform]: Use when a profile is needed before the entity reaches the automatic message threshold. +- [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. +- [Sample Profiles](https://docs.mem0.ai/api-reference/profiles/sample-profiles) [Platform]: Use when validating a profile schema against a few real entities before applying it project-wide. +- [Regenerate Profiles](https://docs.mem0.ai/api-reference/profiles/regenerate-profiles) [Platform]: Use when a new schema must be applied to entities that already have a profile. ### 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..cef11fed4 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -8070,6 +8070,377 @@ } } } + }, + "/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 or agent.\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", + "agent" + ] + }, + "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", + "agent" + ] + }, + "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." + }, + "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." + } + } + } + } + } + } + } + }, + "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", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether profile generation runs for this project." + }, + "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." + } + } + } + } + } + }, + "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." + }, + "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." + } + } + } + } + } + }, + "400": { + "description": "The schema is not a valid profile schema." + } + } + } + }, + "/v2/profiles/trigger/": { + "post": { + "tags": [ + "profiles" + ], + "operationId": "profiles_trigger", + "summary": "Generate one entity's profile now", + "description": "Generate or refresh the profile for a single entity immediately.\n\nProfiles are otherwise built once an entity crosses an internal message threshold, so a new entity has none for its first few memories. Returns 202: poll the read endpoint and branch on `status`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "entity_id" + ], + "properties": { + "entity_type": { + "type": "string", + "enum": [ + "user", + "agent" + ], + "default": "user" + }, + "entity_id": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Generation queued.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "entity_id": { + "type": "string" + }, + "profile_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Unsupported entity type, or profiles are not enabled and configured." + }, + "404": { + "description": "No such entity in this project." + } + } + } + }, + "/v2/profiles/samples/": { + "post": { + "tags": [ + "profiles" + ], + "operationId": "profiles_samples", + "summary": "Sample profiles to check a schema", + "description": "Generate profiles for a handful of real entities so a schema can be judged before it is applied project-wide.\n\nThese are real generations and the results are kept, so the work counts toward a later regenerate.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How many entities to sample." + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Sampling queued.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "sampled": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "400": { + "description": "Profiles are not enabled and configured." + }, + "429": { + "description": "A sample run was already started for this project very recently." + } + } + } + }, + "/v2/profiles/regenerate/": { + "post": { + "tags": [ + "profiles" + ], + "operationId": "profiles_regenerate", + "summary": "Rebuild every profile in the project", + "description": "Rebuild the profile of every entity in the project. This is how a new schema reaches entities that already have a profile.\n\nReturns 202 immediately; the rebuild runs in the background.", + "responses": { + "202": { + "description": "Regenerate queued.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "existing_profile_count": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Profiles are not enabled and configured." + }, + "409": { + "description": "A regenerate is already running for this project." + }, + "429": { + "description": "A regenerate already ran for this project within the last hour." + } + } + } } }, "components": { @@ -8988,4 +9359,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..b8cb71310 --- /dev/null +++ b/docs/platform/features/user-profiles.mdx @@ -0,0 +1,272 @@ +--- +title: Profiles +description: "Build a structured, always-current summary of each user or agent from their memories, shaped by a JSON Schema you define." +icon: "id-card" +--- + +# 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. + + +Profiles cover **users and agents**. An agent profile summarizes how an agent behaves, in the same way a user profile summarizes a person. + +## 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": 4 +} +``` + +### 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. + +For an agent, pass the entity type: + + +```python Python +client.get_profile("support-bot", entity_type="agent") +``` + +```typescript TypeScript +await client.getProfile({ entityId: "support-bot", entityType: "agent" }); +``` + + +## 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: + + +```python Python +result = client.sample_profiles(limit=5) + +for row in result["results"]: + print(client.get_profile(row["entity_id"])) +``` + +```typescript TypeScript +const result = await client.sampleProfiles({ limit: 5 }); + +for (const row of result.results) { + console.log(await client.getProfile({ entityId: row.entityId })); +} +``` + + +These are real generations against real memories, and the results are kept — sampling is not wasted work. Sampling is limited to 10 entities per call and cannot be repeated immediately. + +## Apply a new schema to existing entities + +Changing the schema does not retroactively rewrite profiles that already exist. To rebuild every profile in the project: + + +```python Python +client.regenerate_profiles() +``` + +```typescript TypeScript +await client.regenerateProfiles(); +``` + + +This returns immediately and runs in the background; a large project takes a while. It is limited to one run per project per hour, so sample first and regenerate once you are satisfied. + +## 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. + +## Settings reference + +| Field | 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 | + + + Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary. + + +## Related + + + + How users, agents, apps and runs partition memories. + + + Steer what Mem0 extracts in the first place. + + diff --git a/mem0-ts/src/client/index.ts b/mem0-ts/src/client/index.ts index 24ec5b8f4..98f1849a0 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, + ProfileTriggerResponse, + ProfileSettings, + ProfileSampleResult, + ProfileSamplesResponse, + ProfileRegenerateResponse, } 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 ff259f2fe..15368cc21 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -20,6 +20,12 @@ import { FeedbackPayload, CreateMemoryExportPayload, GetMemoryExportPayload, + ProfileEntityType, + ProfileResponse, + ProfileTriggerResponse, + ProfileSettings, + ProfileSamplesResponse, + ProfileRegenerateResponse, } from "./mem0.types"; import { captureClientEvent, @@ -244,7 +250,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: { @@ -257,8 +264,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( @@ -755,6 +765,142 @@ export default class MemoryClient { return response; } + /** + * Get the memory profile for a single entity. + * + * Branch on `status`, not on an empty `profile`: generation is asynchronous, + * so a known entity without a profile yet is a normal response. + */ + async getProfile(data: { + entityId: string; + entityType?: ProfileEntityType; + }): Promise { + this._captureEvent("get_profile", []); + await this._awaitIdentity(); + + const entityType = data.entityType ?? "user"; + const response = await this._fetchWithErrorHandling( + `${this.host}/v2/entities/${encodeURIComponent(entityType)}/${encodeURIComponent(data.entityId)}/profile/`, + { + headers: this.headers, + }, + ); + return response; + } + + /** + * Generate or refresh the profile for one entity, now. + * + * Profiles are otherwise built once an entity crosses an internal message + * threshold, so a new entity has none for its first few memories. Returns as + * soon as the work is queued: poll {@link getProfile} and branch on `status`. + */ + async generateProfile(data: { + entityId: string; + entityType?: ProfileEntityType; + }): Promise { + this._captureEvent("generate_profile", []); + await this._awaitIdentity(); + + const response = await this._fetchWithErrorHandling( + `${this.host}/v2/profiles/trigger/`, + { + method: "POST", + headers: this.headers, + body: JSON.stringify({ + entity_type: data.entityType ?? "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. */ + private _settingsWithVerbatimSchema(raw: any): ProfileSettings { + const settings = snakeToCamelKeys(raw) as ProfileSettings; + if (raw && typeof raw === "object" && "schema" in raw) { + settings.schema = raw.schema; + } + return settings; + } + + /** Update profile settings. Only the fields you pass are written. */ + async updateProfileSettings( + settings: ProfileSettings, + ): Promise { + const payloadKeys = Object.keys(settings || {}); + this._captureEvent("update_profile_settings", [payloadKeys]); + await this._awaitIdentity(); + + // The schema's property names are the customer's and must reach the API verbatim. + const { schema, ...rest } = settings; + const body: Record = camelToSnakeKeys(rest); + if (schema !== undefined) { + body.schema = schema; + } + + 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 entities, to check a schema. + * + * Real generations against real memories, and the results are kept. + */ + async sampleProfiles(data?: { + limit?: number; + }): Promise { + this._captureEvent("sample_profiles", []); + await this._awaitIdentity(); + + const response = await this._fetchWithErrorHandling( + `${this.host}/v2/profiles/samples/`, + { + method: "POST", + headers: this.headers, + body: JSON.stringify(this._prepareParams({ limit: data?.limit })), + }, + ); + return response; + } + + /** + * Rebuild the profile of every entity in the project. + * + * This is how a new schema reaches entities that already have a profile. + */ + async regenerateProfiles(): Promise { + this._captureEvent("regenerate_profiles", []); + await this._awaitIdentity(); + + const response = await this._fetchWithErrorHandling( + `${this.host}/v2/profiles/regenerate/`, + { + method: "POST", + headers: this.headers, + body: JSON.stringify({}), + }, + ); + return response; + } + 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 c230441e0..cdbcd1c4d 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -236,3 +236,57 @@ export interface GetMemoryExportPayload { filters?: Record; memoryExportId?: string; } + +// ─── Profile Types ────────────────────────────────────────── + +/** Entity kinds that can carry a profile. */ +export type ProfileEntityType = "user" | "agent"; + +/** `succeeded` is the only state in which `profile` is guaranteed to hold content. */ +export type ProfileStatus = + "succeeded" | "pending" | "failed" | "notEnabled" | "insufficientData"; + +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; +} + +export interface ProfileTriggerResponse { + message: string; + entityType: ProfileEntityType; + entityId: string; + profileId: string; + status: string; +} + +export interface ProfileSettings { + enabled?: boolean; + /** JSON Schema for the profile. Every property needs a `description`. */ + schema?: Record | null; + customInstructions?: string | null; +} + +export interface ProfileSampleResult { + entityType: ProfileEntityType; + entityId: string; + profileId?: string; + [key: string]: any; +} + +export interface ProfileSamplesResponse { + message: string; + sampled: number; + results: Array; +} + +export interface ProfileRegenerateResponse { + status: string; + message: string; + projectId: string; + existingProfileCount: number; +} 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..d09aa0697 --- /dev/null +++ b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts @@ -0,0 +1,238 @@ +/** + * 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 { 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("defaults to user and encodes the entity id", async () => { + const extra = new Map(); + extra.set("/v2/entities/agent/", { + status: 200, + body: { profile: {}, status: "pending", entity_type: "agent" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getProfile({ entityId: "a/b", entityType: "agent" }); + + const call = findFetchCall(mock, "/v2/entities/agent/a%2Fb/profile/"); + expect(call).toBeDefined(); + }); +}); + +describe("MemoryClient - generateProfile()", () => { + test("posts entity_type and entity_id to the trigger route", async () => { + const extra = new Map(); + extra.set("/v2/profiles/trigger/", { + status: 202, + body: { + message: "Profile generation started.", + entity_type: "user", + entity_id: "alice", + profile_id: "p_1", + status: "PENDING", + }, + }); + 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/trigger/", "POST"); + expect(call).toBeDefined(); + const body = getFetchBody(call!); + expect(body.entity_type).toBe("user"); + expect(body.entity_id).toBe("alice"); + expect(result.profileId).toBe("p_1"); + }); +}); + +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, + 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. + expect(body.schema).toEqual(schema); + expect(body.custom_instructions).toBe("Focus on durable preferences"); + expect(body.enabled).toBe(true); + expect(result.schema).toEqual(schema); + expect(result.customInstructions).toBe("Focus on durable preferences"); + }); + + test("omits fields the caller did not set", async () => { + const extra = new Map(); + extra.set("/v2/profiles/settings/", { + status: 200, + body: { enabled: false, 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); + 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, + schema: { properties: { favorite_topics: { type: "array" } } }, + custom_instructions: null, + }, + }); + 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.schema).toEqual({ + properties: { favorite_topics: { type: "array" } }, + }); + }); +}); + +describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => { + test("sampleProfiles omits limit when unset", async () => { + const extra = new Map(); + extra.set("/v2/profiles/samples/", { + status: 202, + body: { message: "Sampling 5 users.", sampled: 5, results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.sampleProfiles(); + + const call = findFetchCall(mock, "/v2/profiles/samples/", "POST"); + expect(getFetchBody(call!)).toEqual({}); + }); + + test("sampleProfiles passes an explicit limit", async () => { + const extra = new Map(); + extra.set("/v2/profiles/samples/", { + status: 202, + body: { message: "Sampling 3 users.", 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/samples/", "POST"); + expect(getFetchBody(call!).limit).toBe(3); + expect(result.sampled).toBe(3); + }); + + test("regenerateProfiles posts to the regenerate route", async () => { + const extra = new Map(); + extra.set("/v2/profiles/regenerate/", { + status: 202, + body: { + status: "accepted", + message: "Regenerating profiles.", + project_id: "proj_abc", + existing_profile_count: 12, + }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const result = await client.regenerateProfiles(); + + expect( + findFetchCall(mock, "/v2/profiles/regenerate/", "POST"), + ).toBeDefined(); + expect(result.existingProfileCount).toBe(12); + expect(result.projectId).toBe("proj_abc"); + }); +}); 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 a52986dcd..9bce4e3d7 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -682,6 +682,155 @@ 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, entity_type: str = "user") -> Dict[str, Any]: + """Get the memory profile for a single entity. + + Branch on ``status``, not on an empty ``profile``: generation is + asynchronous, so a known entity without a profile yet is a normal response. + + Args: + entity_id: The entity's id, as you supplied it on ``add`` (e.g. "alice"). + entity_type: Either "user" or "agent". Defaults to "user". + + 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: + ValidationError: If entity_type is not a supported entity kind. + AuthenticationError: If authentication fails. + NotFoundError: If no such entity exists in the project. + """ + + response = self.client.get( + f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/profile/" + ) + response.raise_for_status() + capture_client_event("client.get_profile", self, {"entity_type": entity_type, "sync_type": "sync"}) + return response.json() + + @api_error_handler + def generate_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]: + """Generate or refresh the profile for a single entity, now. + + Profiles are otherwise built once an entity crosses an internal message + threshold, so a new entity 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 entity's id, as you supplied it on ``add``. + entity_type: Either "user" or "agent". Defaults to "user". + + Returns: + Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and + ``status``. + + Raises: + ValidationError: If entity_type is unsupported or profiles are not + enabled and configured for the project. + NotFoundError: If no such entity exists in the project. + """ + + response = self.client.post( + "/v2/profiles/trigger/", + json={"entity_type": entity_type, "entity_id": entity_id}, + ) + response.raise_for_status() + capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "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``, ``schema`` and ``custom_instructions``. + """ + + response = self.client.get("/v2/profiles/settings/") + 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: Optional[Dict[str, Any]] = None, + custom_instructions: Optional[str] = None, + ) -> 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. + schema: JSON Schema for the profile. Every property needs a + ``description``. + custom_instructions: Extra guidance for the extraction step. + + Returns: + Dict with the settings as stored after the update. + + Raises: + ValidationError: If the schema is not a valid profile schema. + """ + + payload = self._prepare_params( + {"enabled": enabled, "schema": schema, "custom_instructions": custom_instructions} + ) + response = self.client.post("/v2/profiles/settings/", 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) -> Dict[str, Any]: + """Generate profiles for a few real entities, to check a schema. + + Real generations against real memories, and the results are kept. + + Args: + limit: How many entities to sample, 1-10. Defaults to the server value. + + Returns: + Dict containing ``sampled`` and one ``results`` row per entity. + + Raises: + ValidationError: If profiles are not enabled and configured. + RateLimitError: If a sample run was already started very recently. + """ + + response = self.client.post("/v2/profiles/samples/", json=self._prepare_params({"limit": limit})) + response.raise_for_status() + capture_client_event("client.sample_profiles", self, {"sync_type": "sync"}) + return response.json() + + @api_error_handler + def regenerate_profiles(self) -> Dict[str, Any]: + """Rebuild the profile of every entity in the current project. + + This is how a new schema reaches entities that already have a profile. + Returns as soon as the work is queued. + + Returns: + Dict containing ``status``, ``message``, ``project_id`` and + ``existing_profile_count``. + + Raises: + ValidationError: If profiles are not enabled and configured. + RateLimitError: If a regenerate already ran for this project recently. + """ + + response = self.client.post("/v2/profiles/regenerate/", json={}) + response.raise_for_status() + capture_client_event("client.regenerate_profiles", self, {"sync_type": "sync"}) + 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. @@ -1588,6 +1737,155 @@ 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, entity_type: str = "user") -> Dict[str, Any]: + """Get the memory profile for a single entity. + + Branch on ``status``, not on an empty ``profile``: generation is + asynchronous, so a known entity without a profile yet is a normal response. + + Args: + entity_id: The entity's id, as you supplied it on ``add`` (e.g. "alice"). + entity_type: Either "user" or "agent". Defaults to "user". + + 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: + ValidationError: If entity_type is not a supported entity kind. + AuthenticationError: If authentication fails. + NotFoundError: If no such entity exists in the project. + """ + + response = await self.async_client.get( + f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/profile/" + ) + response.raise_for_status() + capture_client_event("client.get_profile", self, {"entity_type": entity_type, "sync_type": "async"}) + return response.json() + + @api_error_handler + async def generate_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]: + """Generate or refresh the profile for a single entity, now. + + Profiles are otherwise built once an entity crosses an internal message + threshold, so a new entity 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 entity's id, as you supplied it on ``add``. + entity_type: Either "user" or "agent". Defaults to "user". + + Returns: + Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and + ``status``. + + Raises: + ValidationError: If entity_type is unsupported or profiles are not + enabled and configured for the project. + NotFoundError: If no such entity exists in the project. + """ + + response = await self.async_client.post( + "/v2/profiles/trigger/", + json={"entity_type": entity_type, "entity_id": entity_id}, + ) + response.raise_for_status() + capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "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``, ``schema`` and ``custom_instructions``. + """ + + response = await self.async_client.get("/v2/profiles/settings/") + 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: Optional[Dict[str, Any]] = None, + custom_instructions: Optional[str] = None, + ) -> 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. + schema: JSON Schema for the profile. Every property needs a + ``description``. + custom_instructions: Extra guidance for the extraction step. + + Returns: + Dict with the settings as stored after the update. + + Raises: + ValidationError: If the schema is not a valid profile schema. + """ + + payload = self._prepare_params( + {"enabled": enabled, "schema": schema, "custom_instructions": custom_instructions} + ) + response = await self.async_client.post("/v2/profiles/settings/", 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) -> Dict[str, Any]: + """Generate profiles for a few real entities, to check a schema. + + Real generations against real memories, and the results are kept. + + Args: + limit: How many entities to sample, 1-10. Defaults to the server value. + + Returns: + Dict containing ``sampled`` and one ``results`` row per entity. + + Raises: + ValidationError: If profiles are not enabled and configured. + RateLimitError: If a sample run was already started very recently. + """ + + response = await self.async_client.post("/v2/profiles/samples/", json=self._prepare_params({"limit": limit})) + response.raise_for_status() + capture_client_event("client.sample_profiles", self, {"sync_type": "async"}) + return response.json() + + @api_error_handler + async def regenerate_profiles(self) -> Dict[str, Any]: + """Rebuild the profile of every entity in the current project. + + This is how a new schema reaches entities that already have a profile. + Returns as soon as the work is queued. + + Returns: + Dict containing ``status``, ``message``, ``project_id`` and + ``existing_profile_count``. + + Raises: + ValidationError: If profiles are not enabled and configured. + RateLimitError: If a regenerate already ran for this project recently. + """ + + response = await self.async_client.post("/v2/profiles/regenerate/", json={}) + response.raise_for_status() + capture_client_event("client.regenerate_profiles", self, {"sync_type": "async"}) + 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/tests/test_client_profiles.py b/tests/test_client_profiles.py new file mode 100644 index 000000000..352af1ffb --- /dev/null +++ b/tests/test_client_profiles.py @@ -0,0 +1,224 @@ +"""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 _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_supports_agents(self, mock_memory_client): + mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"}) + + mock_memory_client.get_profile("support-bot", entity_type="agent") + + mock_memory_client.client.get.assert_called_once_with("/v2/entities/agent/support-bot/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({"profile_id": "p_1", "status": "PENDING"}) + + mock_memory_client.generate_profile("alice") + + mock_memory_client.client.post.assert_called_once_with( + "/v2/profiles/trigger/", + json={"entity_type": "user", "entity_id": "alice"}, + ) + + def test_agent_entity_type(self, mock_memory_client): + mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_2", "status": "PENDING"}) + + mock_memory_client.generate_profile("support-bot", entity_type="agent") + + mock_memory_client.client.post.assert_called_once_with( + "/v2/profiles/trigger/", + json={"entity_type": "agent", "entity_id": "support-bot"}, + ) + + +class TestProfileSettings: + def test_get_reads_v2(self, mock_memory_client): + mock_memory_client.client.get.return_value = _mock_response( + {"enabled": True, "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_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, "schema": schema}) + + 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, "schema": schema, "custom_instructions": "Keep it durable"}, + ) + + +class TestSampleAndRegenerate: + def test_sample_without_limit(self, mock_memory_client): + mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "results": []}) + + mock_memory_client.sample_profiles() + + mock_memory_client.client.post.assert_called_once_with("/v2/profiles/samples/", json={}) + + def test_sample_with_limit(self, mock_memory_client): + mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "results": []}) + + mock_memory_client.sample_profiles(limit=3) + + mock_memory_client.client.post.assert_called_once_with("/v2/profiles/samples/", json={"limit": 3}) + + def test_regenerate(self, mock_memory_client): + mock_memory_client.client.post.return_value = _mock_response( + {"status": "accepted", "project_id": "proj_abc", "existing_profile_count": 12} + ) + + mock_memory_client.regenerate_profiles() + + mock_memory_client.client.post.assert_called_once_with("/v2/profiles/regenerate/", json={}) + + +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({"profile_id": "p_1"})) + + asyncio.run(async_client.generate_profile("alice", entity_type="agent")) + + async_client.async_client.post.assert_called_once_with( + "/v2/profiles/trigger/", + json={"entity_type": "agent", "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_regenerate(self, async_client): + async_client.async_client.post = AsyncMock(return_value=_mock_response({"status": "accepted"})) + + asyncio.run(async_client.regenerate_profiles()) + + async_client.async_client.post.assert_called_once_with("/v2/profiles/regenerate/", json={})