Compare commits

...

15 Commits

Author SHA1 Message Date
Pratik 1f68430471 test(profiles): use placeholder ids in the job response fixture
The fixture carried a real job_id and event_id from a live generation. The
shape is what the test needs, not the values, and this repo is public.
2026-09-23 10:49:16 -07:00
Pratik 1121efe365 docs(profiles): match the job create response in openapi
The 202 body documented usage_units and a results array. The API returns
entity_count_reserved, event_id and, for sample jobs, entity_ids; results
only appears on GET /v2/profiles/jobs/{id}/. The SDK types already use
entityCountReserved and entityIds, so the spec was the one out of step.

The docs sample example indexes entity_ids with .get(), matching the
notebook and the TS example's ?? [].
2026-09-23 10:29:15 -07:00
karthik be1ded5793 fix(profiles): address SDK/docs review for user profiles v1
Addresses @kartik-mem0's review on mem0#7340, verified against the live
staging profiles API on a neuron:

- generate_profile / sample_profiles accept a caller-supplied idempotency_key,
  so retrying a lost request reuses the job instead of creating a second
  billable one (Python sync+async and TS)
- TS uses the uuid dependency instead of the global crypto.randomUUID(), which
  throws on the supported Node 18 target
- update_profile_settings distinguishes an omitted argument from an explicit
  None, so schema / custom_instructions can be cleared (Python sentinel)
- export ProfileJobResponse and ProfileJobStatus; drop the deprecated
  ProfileTriggerResponse / ProfileSamplesResponse aliases and the unpopulated
  results field; correct usageUnits -> entityCountReserved; add error to
  ProfileResponse
- openapi: nest schema / custom_instructions under entities in the settings
  request and response, add capabilities, and mark entity_type required on the
  job body
- docs sample example polls to a terminal job status with a timeout, then reads
  the create response's entity_ids (status.results raised KeyError)
- notebook: include PARTIALLY_SUCCEEDED in terminal states, raise on timeout,
  and snapshot/restore project settings so a shared env is left as found
- tests: real job_id create shape, idempotency-key reuse, and clear-with-None

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 22:56:48 +05:30
karthik f29820886a docs(profiles): make user-profiles guide and notebook user-only
Remove leftover agent and regenerate references so the docs match the shipped
v1 SDK: drop the entity_type settings argument and the agent entities example
from the guide, and remove the regenerate demo section and cheat-sheet row from
the notebook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 19:53:41 +05:30
karthik d91d793c52 chore(profiles): bump SDKs and add changelog for user profiles v1
Python mem0ai 2.0.20 -> 2.1.0, TypeScript mem0ai 3.1.8 -> 3.2.0 (minor: new
user-profiles methods). Add matching sdk.mdx entries under the Python and
TypeScript tabs to satisfy the changelog CI gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 19:53:34 +05:30
karthik 48ae36ad94 feat(profiles): user-only SDK for v1 (drop regenerate + agent)
Reduce the User Profiles SDK to the shipped v1 scope: user profiles only,
no full rebuild.

- Remove regenerate_profiles / regenerateProfiles (sync + async, Python + TS)
  and the ProfileRegenerateResponse type. Full rebuild is gated off server-side
  (409 not_yet_available).
- Drop the entity_type / entityType parameter from get_profile,
  generate_profile, sample_profiles, and update_profile_settings; every path
  and payload is scoped to "user". Narrow ProfileEntityType to "user".
- Update tests to the user-only surface.

Endpoint paths (/v2/profiles/jobs/, /v2/profiles/settings/,
/v2/entities/user/{id}/profile/) and the per-job Idempotency-Key are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 19:50:48 +05:30
Pratik 949b026991 fix(profiles): correct the settings shape, job entity_type and status type
Four bugs that made the profile SDK unusable against the live API, each
found by running the demo notebook end to end rather than by reading.

1. `update_profile_settings()` sent a flat body:

       {"enabled": ..., "schema": ..., "custom_instructions": ...}

   The API takes only `enabled` and `entities` at the top level and
   answers 400 "Unsupported settings: custom_instructions, schema." to
   anything else, so every call passing a schema failed.
   `get_profile_settings()` already returned the nested shape, so the read
   and the write disagreed and the method could not round-trip its own
   settings. Both SDKs now nest `schema` and `custom_instructions` under
   `entities.<entity_type>` while keeping the flat call signature;
   `entity_type` is a new optional argument defaulting to "user".

2. `sample_profiles()` and `regenerate_profiles()` never sent
   `entity_type`. Every profile job must name an entity kind, so both
   failed with "entity_type must be one of: user, agent."

3. The TypeScript read path rewrote the customer's schema property names.
   Once the schema moved under `entities`, `snakeToCamelKeys` camel-cased
   the keys inside it, because only a top-level schema was restored
   verbatim. A field named `favorite_topics` came back as `favoriteTopics`.

4. `ProfileStatus` declared `notEnabled` and `insufficientData`, but a
   status is a value, not a key, so it is never camel-cased. tsc rejected
   `status === "insufficient_data"`, which is true at runtime, and accepted
   `status === "insufficientData"`, which can never fire. Branching on
   status is the documented way to use a profile, so the type steered
   every TypeScript caller into a dead branch.

Response-shape corrections found alongside them: sample returns
`entity_ids` on create and a richer `results` array on the job, so the TS
`results` field on the create response is marked deprecated and never set;
regenerate answers 409, not the 501 the docstrings claimed.

Verified against a live environment, from both SDKs: a settings write
followed by a read returns the schema property for property, a partial
update no longer blanks it, sample returns 202 with the entities it
picked, regenerate reaches the server and answers its real
not_yet_available, and a user with no profile returns "insufficient_data".

Adds a user-profiles demo notebook covering the whole loop: schema,
ingestion, generation, a before/after diff of a profile rewriting itself,
schema sampling, and a failure-scenario section for each way the API says
no. It polls the add event to a terminal status instead of sleeping, waits
for the extracted memory count to settle rather than trusting the first
page, and reports plainly when generation cannot finish instead of
presenting an empty profile as a result. Executed end to end: 0 failing
cells.

- python: 20 passed (tests/test_client_profiles.py)
- typescript: 203 passed (src/client/tests/)
2026-09-18 17:10:31 -07:00
Pratik 414aef6f1a docs(profiles): drop the sidebar icon and the plan table
Review feedback from Rudraj on the docs preview.

The sidebar icon made Profiles the only entry in platform/features with
one; every sibling page has no icon, so it read as a rendering accident.

The plan table stated Pro/Enterprise availability, which is not how the
feature is reaching customers: it is enabled per organization on request
while in beta. A table naming tiers invites a self-serve upgrade that
does not turn it on.

Keeps the two conditions that are not about pricing — schema configured
and an entity-scoped memory — and the note that a disabled project reads
back not_enabled rather than erroring.
2026-09-18 17:10:03 -07:00
karthik 9192b9b565 docs(profiles): make profiles docs user-only and match the shipped API
Hide agent profiles for this release (backend exists but is gated off) and
sync the guide, api-reference, openapi, and llms.txt to the User Profiles
feature head.

Agent removal:
- Drop "users and agents" framing, the entity_type="agent" get_profile
  example, and the "profiles for agents" FAQ from the guide.
- Reduce entity_type to user-only in the read path/param/response and the
  jobs request body in openapi.json; drop "or agent" from the api-ref and
  llms.txt descriptions.

Code sync (verified against the feature head):
- Read example + prose now include generation_count alongside profile,
  status, entity_type, entity_id, updated_at.
- Sample flow corrected: sample_profiles returns a job; poll get_profile_job
  and read each result's entity, instead of iterating a non-existent
  results field on the create response.
- Bulk regenerate is gated off for v1 (FULL_REBUILD_ENABLED=False): remove
  it from the guide, openapi operation enum, and the 501 note; keep only
  sample and trigger. Idempotency-Key marked required to match the backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-18 03:49:32 +05:30
karthik 4b9a27aac1 fix(profiles): format TS SDK profile client with prettier
The ts-sdk CI Lint step (npx prettier --check .) failed on the three
profile client files, which also failed the aggregate CI Gate. Reformat
them with prettier 3.8.4 (the pinned devDependency); whitespace only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-18 03:18:19 +05:30
Pratik 34894e2cea fix(profiles): follow the jobs API
Four POST routes became one collection with the operation in the body, so the
three route pages collapse into one. samples became sample. Creates send an
Idempotency-Key and return a status_url to poll, which the client follows
rather than building the path.

Full rebuild is closed: regenerate_profiles answers 501 not_yet_available and
the docs say so instead of teaching a daily cadence that cannot run.
2026-09-16 17:02:10 -07:00
karthik c4eea87526 Merge branch 'main' into user/karthik/user-profiles-docs 2026-09-15 22:12:30 +05:30
karthik b852f9240c docs(profiles): correct response example and regenerate cadence from live API
Live e2e against the feature neuron: the profile envelope has no generation_count, and the regenerate cooldown is once per day (86400s), not hourly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-15 21:46:53 +05:30
karthik ac2c5cbfbf docs(profiles): add plan availability, cadence, and FAQ to the profiles guide
Model the profiles feature guide on the Dream guide: add a Plan availability table with eligibility criteria, a 'When profiles update' cadence section, a schema size-budget note, and an FAQ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-15 21:38:00 +05:30
Pratik 623e33f2db feat(profiles): SDK methods and docs for entity profiles
Adds the profile surface to both SDKs and documents it.

Python (MemoryClient + AsyncMemoryClient) and TypeScript gain:
get/generate profile, get/update settings, sample, regenerate.

A profile's keys come from the customer's own JSON Schema, so the TS
client keeps `profile` opaque and passes `schema` through untouched in
both directions. Camel-casing them would return field names that do not
match the schema the customer wrote.

Reads use the v2 envelope, where a known entity with no profile yet is a
200 carrying a status rather than a 404, so an empty state is
distinguishable from an error.

Verified against a live environment running this API: settings
round-trip, a partial update leaves the schema intact, generation
produces a profile under the configured schema, and sample, regenerate,
cooldown and the 400/404 paths all answer as documented.
2026-09-10 16:04:09 -07:00
20 changed files with 2984 additions and 8 deletions
@@ -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/
---
@@ -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}/
---
@@ -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/
---
@@ -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/
---
@@ -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/
---
+14
View File
@@ -7,6 +7,13 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-09-23" description="v2.1.0">
**New Features:**
- **Client:** Add User Profiles to `MemoryClient` and `AsyncMemoryClient`: `get_profile()`, `generate_profile()`, `get_profile_settings()`, `update_profile_settings()`, `sample_profiles()`, and `get_profile_job()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
</Update>
<Update label="2026-09-02" description="v2.0.20">
**Improvements:**
@@ -1227,6 +1234,13 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-09-23" description="v3.2.0">
**New Features:**
- **Client:** Add User Profiles to `MemoryClient`: `getProfile()`, `generateProfile()`, `getProfileSettings()`, `updateProfileSettings()`, `sampleProfiles()`, and `getProfileJob()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
</Update>
<Update label="2026-09-02" description="v3.1.8">
**Improvements:**
+12
View File
@@ -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,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",
+6
View File
@@ -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 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,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.
+507 -1
View File
@@ -8070,6 +8070,512 @@
}
}
}
},
"/v2/entities/{entity_type}/{entity_id}/profile/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_read",
"summary": "Get an entity's profile",
"description": "Return the memory profile for one user.\n\nGeneration is asynchronous, so a known entity that has no profile yet is a normal 200 carrying a `status`. A 404 means only that no such entity exists.",
"parameters": [
{
"name": "entity_type",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"user"
]
},
"description": "The kind of entity that carries the profile."
},
{
"name": "entity_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The entity's id, as supplied when the memory was added."
}
],
"responses": {
"200": {
"description": "The profile envelope.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"profile": {
"type": "object",
"additionalProperties": true,
"description": "The generated profile, shaped by the project's schema. Empty unless status is succeeded."
},
"status": {
"type": "string",
"enum": [
"succeeded",
"pending",
"failed",
"not_enabled",
"insufficient_data"
],
"description": "Generation state. Branch on this rather than on an empty profile."
},
"entity_type": {
"type": "string",
"enum": [
"user"
]
},
"entity_id": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"generation_count": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "Unsupported entity type."
},
"404": {
"description": "No such entity in this project."
}
}
}
},
"/v2/profiles/settings/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_settings_read",
"summary": "Get profile settings",
"description": "Return the profile settings for the project the API key is scoped to.",
"responses": {
"200": {
"description": "Current settings.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Per-entity-type settings. Only `user` is available in this release.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
},
"agent": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
}
}
},
"capabilities": {
"type": "object",
"properties": {
"jobs": {
"type": "boolean"
},
"estimates": {
"type": "boolean"
},
"samples": {
"type": "boolean"
},
"full_rebuild": {
"type": "boolean",
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
}
}
}
}
}
}
}
}
}
},
"post": {
"tags": [
"profiles"
],
"operationId": "profiles_settings_update",
"summary": "Update profile settings",
"description": "Update the project's profile settings. Only the fields present in the body are written, so one setting can change without re-sending the others.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Per-entity-type settings. Only `user` is available in this release.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step. Send null to clear it."
}
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Settings as stored after the update.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Per-entity-type settings. Only `user` is available in this release.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
},
"agent": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
}
}
},
"capabilities": {
"type": "object",
"properties": {
"jobs": {
"type": "boolean"
},
"estimates": {
"type": "boolean"
},
"samples": {
"type": "boolean"
},
"full_rebuild": {
"type": "boolean",
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
}
}
}
}
}
}
}
},
"400": {
"description": "The schema is not a valid profile schema."
}
}
}
},
"/v2/profiles/jobs/": {
"post": {
"tags": [
"profiles"
],
"operationId": "profiles_create_job",
"summary": "Generate profiles",
"description": "Start one generation. `operation` says what to build:\n\n- `sample` — up to 10 real entities, so a schema can be judged before it is used widely. These are real profiles: they are saved to those entities and count toward usage.\n- `trigger` — one entity, named by `entity_id`.\n\nSend an `Idempotency-Key` header. Replaying the same key returns the same job instead of charging twice. Poll `status_url` from the response until the status is terminal.",
"parameters": [
{
"in": "header",
"name": "Idempotency-Key",
"required": true,
"schema": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"description": "Makes a retry safe: the same key returns the same job."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"operation",
"entity_type"
],
"properties": {
"operation": {
"type": "string",
"enum": [
"sample",
"trigger"
],
"description": "What to generate. Optional only when `entity_id` is set, which means `trigger`."
},
"entity_type": {
"type": "string",
"enum": [
"user"
]
},
"entity_id": {
"type": "string",
"description": "One entity, for `trigger`."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"description": "How many entities to sample."
}
}
}
}
}
},
"responses": {
"202": {
"description": "Job accepted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"job_id": {
"type": "string"
},
"status": {
"type": "string"
},
"status_url": {
"type": "string",
"description": "Poll this. Building the path yourself breaks on a route change."
},
"operation": {
"type": "string"
},
"entity_type": {
"type": "string"
},
"entity_count_reserved": {
"type": "integer",
"description": "Entities reserved against usage for this job."
},
"event_id": {
"type": "string",
"nullable": true
},
"replayed": {
"type": "boolean",
"description": "True when an Idempotency-Key returned an existing job."
},
"sampled": {
"type": "integer",
"description": "`sample` only."
},
"entity_ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "`sample` only: the entity ids picked. Read each with `GET /v2/entities/user/{entity_id}/profile/`."
}
}
}
}
}
},
"400": {
"description": "Unknown or missing `operation`, or profiles are not configured."
},
"402": {
"description": "Payment required."
},
"409": {
"description": "A job is already running, or the Idempotency-Key was used for a different request. Branch on `error.code`."
},
"429": {
"description": "Cooldown. `retry_after_seconds` sits inside `error`."
},
"503": {
"description": "`jobs_unavailable` — generation is switched off for this project."
}
}
}
},
"/v2/profiles/jobs/{job_id}/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_get_job",
"summary": "Read a generation job",
"description": "The job nests under `job`. `total` is null until `enumeration_complete`, and `completed` is `succeeded + failed + skipped`.",
"parameters": [
{
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The job.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"job": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"operation": {
"type": "string"
},
"entity_type": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"QUEUED",
"RUNNING",
"SUCCEEDED",
"PARTIALLY_SUCCEEDED",
"FAILED",
"CANCELLED"
]
},
"total": {
"type": "integer",
"nullable": true
},
"enumeration_complete": {
"type": "boolean"
},
"completed": {
"type": "integer"
},
"succeeded": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"skipped": {
"type": "integer"
}
}
}
}
}
}
}
},
"404": {
"description": "No such job in this project."
}
}
}
}
},
"components": {
@@ -8988,4 +9494,4 @@
}
},
"x-original-swagger-version": "2.0"
}
}
+359
View File
@@ -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.
<Info>
**Use profiles when…**
- You want to personalize a first response, before the user says anything in this session.
- You need a compact object to drop into a prompt instead of a list of memories.
- You want the same fields for every user, so your code can rely on their shape.
</Info>
<Note>
User Profiles are in **beta** and available on request. To enable them for your
organization, contact [support@mem0.ai](mailto:support@mem0.ai).
</Note>
## How it works
1. You define a **schema**: the fields a profile should contain, each with a description.
2. Mem0 builds each entity's profile from their memories, and rebuilds it as new memories arrive.
3. You read the profile whenever you need it.
Generation is **asynchronous**. A profile is not ready the instant an entity's first memory lands, so a read tells you where it is with a `status` rather than failing.
## Define the schema
The schema is JSON Schema. Every property needs a `description` — that is what tells the model how to fill the field, so a vague description gives a vague profile.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient()
client.update_profile_settings(
enabled=True,
schema={
"type": "object",
"properties": {
"communication_style": {
"type": "string",
"description": "How the user prefers to be addressed: terse, detailed, formal, casual",
},
"expertise_areas": {
"type": "array",
"items": {"type": "string"},
"description": "Subjects the user demonstrates working knowledge of",
},
"current_goals": {
"type": "array",
"items": {"type": "string"},
"description": "What the user is actively trying to accomplish",
},
},
},
custom_instructions="Prefer durable traits over one-off remarks.",
)
```
```typescript TypeScript
import MemoryClient from "mem0ai";
const client = new MemoryClient({ apiKey: "your-api-key" });
await client.updateProfileSettings({
enabled: true,
schema: {
type: "object",
properties: {
communication_style: {
type: "string",
description:
"How the user prefers to be addressed: terse, detailed, formal, casual",
},
expertise_areas: {
type: "array",
items: { type: "string" },
description: "Subjects the user demonstrates working knowledge of",
},
current_goals: {
type: "array",
items: { type: "string" },
description: "What the user is actively trying to accomplish",
},
},
},
customInstructions: "Prefer durable traits over one-off remarks.",
});
```
</CodeGroup>
<Note>
Your schema's property names reach the API exactly as you write them. The SDKs do not rewrite them, so a profile always comes back with the field names you chose.
</Note>
Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone.
## Read a profile
<CodeGroup>
```python Python
result = client.get_profile("alice")
if result["status"] == "succeeded":
print(result["profile"])
else:
print("not ready:", result["status"])
```
```typescript TypeScript
const result = await client.getProfile({ entityId: "alice" });
if (result.status === "succeeded") {
console.log(result.profile);
} else {
console.log("not ready:", result.status);
}
```
</CodeGroup>
A response looks like this:
```json
{
"profile": {
"communication_style": "terse",
"expertise_areas": ["distributed systems", "postgres"],
"current_goals": ["cut p99 latency", "migrate off the legacy queue"]
},
"status": "succeeded",
"entity_type": "user",
"entity_id": "alice",
"updated_at": "2026-02-08T10:30:00Z",
"generation_count": 3
}
```
`generation_count` is how many times this profile has been (re)generated — `0` before the first generation completes.
### Always branch on `status`
`profile` is empty unless `status` is `succeeded`. Check the status rather than the emptiness of the object, so a profile that is merely still building is not mistaken for a user you know nothing about.
| `status` | Meaning | What to do |
|---|---|---|
| `succeeded` | Profile is built and current | Use it |
| `pending` | Generation is queued or running | Read again shortly |
| `insufficient_data` | Not enough memories to say anything yet | Fall back to defaults |
| `not_enabled` | Profiles are off for this project | Enable them in settings |
| `failed` | The last generation did not complete | Retry, or trigger a new one |
A `404` means only that no such entity exists in your project.
## Generate a profile on demand
Profiles are built once an entity has accumulated enough messages, so a brand-new user has none during their first few interactions. Trigger one directly to close that gap:
<CodeGroup>
```python Python
client.generate_profile("alice")
```
```typescript TypeScript
await client.generateProfile({ entityId: "alice" });
```
</CodeGroup>
The call returns as soon as the work is queued. Poll the read endpoint and branch on `status`.
## Test a schema before applying it
A schema that reads well can still produce disappointing profiles. Sample a few real entities and inspect the output before committing to it.
Sampling is asynchronous: the call returns a job as soon as it is queued. Poll `status_url` until the job is terminal, then read each sampled entity's profile:
<CodeGroup>
```python Python
import time
job = client.sample_profiles(limit=5)
# Poll until the sample job reaches a terminal state (job status is UPPERCASE).
TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"}
deadline = time.time() + 120
while True:
status = client.get_profile_job(job["status_url"])["job"]
if status["status"] in TERMINAL:
break
if time.time() > deadline:
raise TimeoutError("Sample job did not finish in time")
time.sleep(3)
print(status["status"], status["succeeded"], "of", status["total"])
# The create response lists the sampled entities; read each one's saved profile.
for entity_id in job.get("entity_ids", []):
print(client.get_profile(entity_id))
```
```typescript TypeScript
const job = await client.sampleProfiles({ limit: 5 });
// Poll until the sample job reaches a terminal state (job status is UPPERCASE).
const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"];
const deadline = Date.now() + 120_000;
let status;
while (true) {
status = (await client.getProfileJob(job.statusUrl)).job;
if (TERMINAL.includes(status.status)) break;
if (Date.now() > deadline)
throw new Error("Sample job did not finish in time");
await new Promise((resolve) => setTimeout(resolve, 3000));
}
console.log(status.status, status.succeeded, "of", status.total);
// The create response lists the sampled entities; read each one's saved profile.
for (const entityId of job.entityIds ?? []) {
console.log(await client.getProfile({ entityId }));
}
```
</CodeGroup>
These are real generations. The profiles are saved to those entities and count toward your usage, so sampling is not wasted work and not a free dry run. A sample covers up to 10 entities and cannot be repeated immediately.
## Apply a new schema to existing entities
A new schema shapes the next generation. Profiles that already exist keep their values until their entity is generated again.
Each entity picks the new schema up as it sends more memories, and you can generate one now with `generate_profile`.
<Note>
Rebuilding every profile in a project at once is not available yet. Refresh profiles one entity at a time with `generate_profile`, or let each one update on its own as its entity sends more memories.
</Note>
## When profiles update
You never call an "update profile" endpoint — Mem0 keeps each profile current for you. Two things drive it:
- **Automatically, as memories accumulate.** Mem0 refreshes an entity's profile after roughly every **10 messages** it receives, folding the new memories into the existing profile. There is no schedule to wait for and no extra call to make: the same `add` you already do keeps the profile moving.
- **On demand.** Call `generate_profile` to build or refresh a profile immediately — useful for a brand-new entity that has not yet crossed the automatic threshold.
Generation is **asynchronous and incremental**. A refresh runs in the background a short while after its trigger, so a read taken immediately after an `add` may still show the previous profile (or `pending`). Branch on `status` rather than assuming the latest memory is already reflected.
<Note>
Updates are **incremental**, not a full rebuild each time — Mem0 merges what it newly learns into the stored profile and keeps the fields your schema still defines. After a schema change, existing profiles pick it up as their entities send more memories, or when you call `generate_profile` — see [Apply a new schema to existing entities](#apply-a-new-schema-to-existing-entities).
</Note>
## Use a profile in a prompt
The point of the structure is that it drops straight into a prompt:
```python
result = client.get_profile(user_id)
if result["status"] == "succeeded":
profile = result["profile"]
system_prompt = f"""You are helping {user_id}.
Communication style: {profile.get("communication_style", "unknown")}
Areas of expertise: {", ".join(profile.get("expertise_areas", []))}
Current goals: {", ".join(profile.get("current_goals", []))}
Match their style and do not explain what they already know."""
else:
system_prompt = "You are a helpful assistant."
```
## Writing a schema that works
- **Describe every field.** The description is the instruction; without it the model guesses.
- **Prefer durable traits.** "Prefers dark mode" ages well; "is annoyed today" does not.
- **Keep it small.** Ten focused fields beat forty speculative ones, and cost less to generate.
- **Say what the field is not.** A description that rules out the near-miss interpretation is worth more than one that only states the obvious.
- **Sample before you commit.** It is the only way to see what your descriptions actually produce.
<Note>
A schema has a size budget of roughly **10,000 tokens** of serialized JSON — the whole schema is sent to the model on every generation, so a handful of verbose fields can cost more than many terse ones. Oversized schemas are rejected on save.
</Note>
## Availability
The feature is in beta and enabled per organization on request — see the note at the top of this page.
Once it is on, an entity gets a profile when two more things hold:
- profiles are **enabled** with a schema for the project (see [Define the schema](#define-the-schema)), and
- the memory is scoped to an entity — a `user_id`.
On a project where profiles are turned off, a read returns `status: not_enabled` rather than an error, so you can call it unconditionally and branch on the status.
## Settings reference
| Argument | Type | Description |
|---|---|---|
| `enabled` | boolean | Whether profile generation runs for the project |
| `schema` | object | JSON Schema describing the profile. Every property needs a `description` |
| `custom_instructions` | string | Extra guidance applied during extraction |
`enabled` is project-wide. `schema` and `custom_instructions` apply to user
profiles, so the stored settings nest them under `entities`:
```json
{
"enabled": true,
"entities": {
"user": {
"schema": { "type": "object", "properties": { "...": {} } },
"custom_instructions": "Prefer durable traits over one-off remarks."
}
},
"capabilities": { "full_rebuild": false }
}
```
That is what a read returns and what a write accepts. The SDKs take the fields
flat and nest them for you, so a schema you write with
`update_profile_settings` comes back unchanged from `get_profile_settings`.
<Note>
Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
</Note>
## FAQ
**Do I need to change my `add` or `search` calls to use profiles?**
No. Profiles are built from the memories you already add. You define a schema once and read the profile when you need it — your ingestion and retrieval code is unchanged.
**Why is `profile` empty even though the entity has memories?**
Generation is asynchronous and needs enough to work with. Branch on `status`: `pending` means it is still building, and `insufficient_data` means there are not yet enough memories to fill the schema. Read again shortly, or call `generate_profile` to build one now.
**Is sampling free?**
No. `sample_profiles` runs real generations against real memories and **keeps** the profiles it produces, so it counts toward your usage like any other generation. It exists to check a schema on a few entities before you commit to it — not as a zero-cost dry run.
**Does changing the schema rewrite existing profiles?**
No. A schema change applies to the next generation. An existing profile keeps its values until its entity is generated again, which happens as that entity sends more memories, or when you call `generate_profile` for it.
**What happens to a field I remove from the schema?**
It stops being maintained. On an entity's next generation, fields your schema no longer defines are pruned from the stored profile — so keep a field in the schema for as long as you want its value kept.
**How current is a profile?**
It refreshes automatically as memories accumulate (about every 10 messages for an entity), plus any on-demand `generate_profile` calls. Because refreshes run in the background, expect a short delay after the triggering `add` rather than an instant update.
## Related
<CardGroup cols={2}>
<Card title="Entity-Scoped Memory" icon="users" href="/platform/features/entity-scoped-memory">
How users, agents, apps and runs partition memories.
</Card>
<Card title="Custom Instructions" icon="pen" href="/platform/features/custom-instructions">
Steer what Mem0 extracts in the first place.
</Card>
</CardGroup>
+743
View File
@@ -0,0 +1,743 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# User Profiles — live demo\n",
"\n",
"A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n",
"user's memories, shaped by a JSON Schema you supply.\n",
"\n",
"Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n",
"user\"*, in one read, with no query to write — and it is available on the first turn of a\n",
"session, before the user has said anything.\n",
"\n",
"**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n",
"generated from them, add 6 more turns that contradict the first set, and watch the\n",
"profile rewrite itself. Then it shows every way the API says no.\n",
"\n",
"**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n",
"\n",
"> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n",
"> production. The cells below read both from the environment.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# This notebook drives the SDK from this worktree, not the published mem0ai:\n",
"# the profile fixes below are not released yet.\n",
"%pip install -q -e ../..\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Define the schema\n",
"\n",
"The schema is handed to the model as a **tool definition**, and each field's\n",
"`description` is the only instruction the model gets about what belongs there. An\n",
"undescribed field is a field the model guesses at.\n",
"\n",
"Rules worth knowing:\n",
"\n",
"- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n",
" it would bill you to extract nothing\n",
"- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n",
" they name the storage envelope, so a schema using them could not be read back\n",
" unambiguously\n",
"- keep it small. The whole schema is sent to the model on every generation\n",
"\n",
"Descriptions are **not** enforced on write in this build — a property without one is\n",
"accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n",
"Treat descriptions as your job, not the validator's.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"SCHEMA = {\n",
" \"type\": \"object\",\n",
" \"properties\": {\n",
" \"occupation\": {\n",
" \"type\": \"string\",\n",
" \"description\": \"The person's current job title, in one short phrase.\",\n",
" },\n",
" \"location\": {\n",
" \"type\": \"string\",\n",
" \"description\": \"The city or region the person currently lives in.\",\n",
" },\n",
" \"interests\": {\n",
" \"type\": \"array\",\n",
" \"items\": {\"type\": \"string\"},\n",
" \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n",
" },\n",
" \"dietary_restrictions\": {\n",
" \"type\": \"array\",\n",
" \"items\": {\"type\": \"string\"},\n",
" \"description\": \"Foods the person avoids, and why, if they said.\",\n",
" },\n",
" \"communication_style\": {\n",
" \"type\": \"string\",\n",
" \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n",
" \"description\": \"How this person prefers to be answered.\",\n",
" },\n",
" \"expertise_level\": {\n",
" \"type\": \"string\",\n",
" \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n",
" \"description\": \"Their technical depth, judged from how they discuss their work.\",\n",
" },\n",
" },\n",
"}\n",
"\n",
"settings = client.update_profile_settings(\n",
" enabled=True,\n",
" schema=SCHEMA,\n",
" custom_instructions=(\n",
" \"Prefer facts the person stated outright over anything inferred. \"\n",
" \"Leave a field empty rather than guessing.\"\n",
" ),\n",
")\n",
"\n",
"# Sorted, because JSONB storage does not preserve the key order you sent.\n",
"# Compare a stored schema by SET, never by string or by key order.\n",
"stored = settings[\"entities\"][\"user\"][\"schema\"]\n",
"print(\"schema fields:\", sorted(stored[\"properties\"]))\n",
"print(\"enabled :\", settings[\"enabled\"])\n",
"print(\"capabilities :\", settings[\"capabilities\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"`enabled` is project-wide; `schema` and `custom_instructions` apply to user profiles\n",
"and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n",
"you write comes back unchanged from `get_profile_settings()`.\n",
"\n",
"Only the arguments you pass are written. To turn the feature off without touching your\n",
"schema, send `enabled` alone.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. The turns\n",
"\n",
"Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n",
"side effect of the normal pipeline.\n",
"\n",
"Twelve, not five, because generation fires when an entity crosses a **10-message\n",
"boundary**. Below that it waits for a flush window measured in hours, and this notebook\n",
"would sit there.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"TURNS = [\n",
" (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n",
" (\"assistant\", \"Congratulations! What's the new role?\"),\n",
" (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n",
" (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n",
" (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n",
" (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n",
" (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n",
" (\"assistant\", \"Understood — short answers from here.\"),\n",
" (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n",
" (\"assistant\", \"Bouldering or ropes?\"),\n",
" (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n",
" (\"assistant\", \"Noted, vegetarian only.\"),\n",
"]\n",
"\n",
"response = client.add(\n",
" [{\"role\": r, \"content\": c} for r, c in TURNS],\n",
" user_id=USER_ID,\n",
")\n",
"print(json.dumps(response, indent=2)[:300])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n",
"`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n",
"you know the add finished. Then let the extracted memories settle.\n",
"\n",
"Under load this can take a minute or more, so the cell says plainly whether it ran out of\n",
"time rather than printing `0 memories` as though that were the answer.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"event_id = response[\"event_id\"]\n",
"deadline = time.time() + 300\n",
"\n",
"# 1. The add itself. Terminal status, not a sleep.\n",
"event_status = None\n",
"while time.time() < deadline:\n",
" event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n",
" if event_status in (\"SUCCEEDED\", \"FAILED\"):\n",
" break\n",
" print(f\" add {event_status}\")\n",
" time.sleep(5)\n",
"print(f\"add finished: {event_status}\")\n",
"assert event_status != \"FAILED\", \"the add itself failed — nothing downstream can work\"\n",
"\n",
"# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n",
"# Wait for the count to stop growing instead of breaking on the first result.\n",
"memories, stable = [], 0\n",
"while time.time() < deadline:\n",
" page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n",
" found = page.get(\"results\", []) if isinstance(page, dict) else page\n",
" stable = stable + 1 if found and len(found) == len(memories) else 0\n",
" memories = found\n",
" if stable >= 2: # two identical polls in a row\n",
" break\n",
" print(f\" ... {len(memories)} so far\")\n",
" time.sleep(5)\n",
"\n",
"if memories:\n",
" print(f\"\\n{len(memories)} memories extracted:\\n\")\n",
" for m in memories:\n",
" print(\" \\u2022\", m.get(\"memory\"))\n",
"else:\n",
" # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n",
" # and makes the profile below look like it came from nothing.\n",
" print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n",
" print(\"worker is down. Everything below will report insufficient_data.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Read the profile\n",
"\n",
"Crossing the 10-message boundary should already have queued a generation. Read first —\n",
"and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n",
"distinction is the whole point of the envelope.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"envelope = client.get_profile(USER_ID)\n",
"print(json.dumps(envelope, indent=2))\n",
"\n",
"print(\"\\nstatus vocabulary:\")\n",
"print(\" succeeded terminal — a generation ran AND the profile has content\")\n",
"print(\" pending queued or running\")\n",
"print(\" failed terminal — the last generation did not complete\")\n",
"print(\" not_enabled feature off, or plan below Pro\")\n",
"print(\" insufficient_data no content to show: no row yet, queued, or a\")\n",
"print(\" generation that legitimately found nothing\")\n",
"print()\n",
"print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n",
"print(\"empty extraction still increments the counter, so counting generations\")\n",
"print(\"reports 'done' for a profile with nothing in it.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Force it, rather than waiting\n",
"\n",
"`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n",
"until their tenth message. One entity, a few seconds.\n",
"\n",
"Every call is sent with a fresh `Idempotency-Key`, so a retry after a dropped connection\n",
"returns the same job instead of paying twice.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n",
"\n",
"\n",
"def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n",
" \"\"\"Poll until terminal.\n",
"\n",
" `since` waits for a generation_count ABOVE that value, which is how you wait\n",
" for an UPDATE rather than accepting the profile you already had.\n",
"\n",
" `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n",
" poll through it and give up on the timeout instead.\n",
" \"\"\"\n",
" deadline = time.time() + timeout\n",
" body = None\n",
" while time.time() < deadline:\n",
" body = client.get_profile(entity_id)\n",
" status = (body.get(\"status\") or \"\").lower()\n",
" count = body.get(\"generation_count\") or 0\n",
" fresh = count > since if since is not None else True\n",
" if status == \"succeeded\" and fresh:\n",
" return body\n",
" if status in (\"failed\", \"not_enabled\"):\n",
" raise RuntimeError(f\"generation stopped: {status}\")\n",
" print(f\" ... {status} (generation_count={count})\")\n",
" time.sleep(interval)\n",
" raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n",
"\n",
"\n",
"print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
"print(\"\\npolling...\")\n",
"\n",
"try:\n",
" body = wait_for_profile(USER_ID)\n",
" print(\"\\n=== PROFILE ===\")\n",
" print(json.dumps(body[\"profile\"], indent=2))\n",
" print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n",
"except TimeoutError as e:\n",
" # Say so plainly and let the rest of the notebook skip, rather than raising\n",
" # a NameError in every cell below and burying the real cause.\n",
" body = None\n",
" print(f\"\\nNO PROFILE: {e}\")\n",
" print(\"Generation never finished. Usually the ingestion worker is down, or\")\n",
" print(\"this project has no memories for the user yet.\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# The model must not invent fields outside your schema — the forced tool call is\n",
"# what makes that structural rather than a request.\n",
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n",
" print(\"fields outside the schema:\", extra or \"none\")\n",
"\n",
" # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n",
" # so 'I found nothing' arrives as a type default: 0, \"\", [].\n",
" filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n",
" print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Now watch it update\n",
"\n",
"Six more turns that contradict and extend what we already know: a promotion, a move, a\n",
"dropped hobby. A profile is a living document, not an append-only log — the model gets the\n",
"memories and rewrites the whole thing.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" before = body[\"generation_count\"]\n",
"\n",
" MORE_TURNS = [\n",
" (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n",
" (\"assistant\", \"Congratulations. Same team?\"),\n",
" (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n",
" (\"assistant\", \"Big move. How do you feel about it?\"),\n",
" (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n",
" (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n",
" ]\n",
"\n",
" followup = client.add(\n",
" [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n",
" user_id=USER_ID,\n",
" )\n",
"\n",
" # Wait for the add to land before triggering: a generation queued before the new\n",
" # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n",
" deadline = time.time() + 300\n",
" while time.time() < deadline:\n",
" status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n",
" if status in (\"SUCCEEDED\", \"FAILED\"):\n",
" break\n",
" time.sleep(5)\n",
" print(\"follow-up add:\", status)\n",
" time.sleep(15) # let extraction settle\n",
"\n",
" print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
" print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n",
" updated = wait_for_profile(USER_ID, since=before)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" print(f\"{'field':<22} {'before':<34} after\")\n",
" print(\"-\" * 92)\n",
" for field in SCHEMA[\"properties\"]:\n",
" b = json.dumps(body[\"profile\"].get(field))\n",
" a = json.dumps(updated[\"profile\"].get(field))\n",
" mark = \" \" if a == b else \"->\"\n",
" print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Use it in a prompt\n",
"\n",
"The point of the structure is that it drops straight into a prompt — no list of memories\n",
"to summarize, no query to write.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def build_system_prompt(entity_id):\n",
" result = client.get_profile(entity_id)\n",
" if result[\"status\"] != \"succeeded\":\n",
" # Branch on status, never on an empty profile: a user whose profile is\n",
" # still building is not a user you know nothing about.\n",
" return \"You are a helpful assistant.\"\n",
"\n",
" p = result[\"profile\"]\n",
" return f\"\"\"You are helping {entity_id}.\n",
"Occupation: {p.get(\"occupation\", \"unknown\")}\n",
"Location: {p.get(\"location\", \"unknown\")}\n",
"Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n",
"Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n",
"Preferred style: {p.get(\"communication_style\", \"unknown\")}\n",
"\n",
"Match their style and do not explain what they already know.\"\"\"\n",
"\n",
"\n",
"print(build_system_prompt(USER_ID))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Judge a schema before committing to it\n",
"\n",
"`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n",
"\n",
"These are real generations and the results are **kept** — a dry run would cost exactly the\n",
"same and leave those users no better off. It is not a free preview.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 202, not 200: the sample generations are queued, not finished.\n",
"#\n",
"# A 409 `already_running` means a sample from an earlier run is still going.\n",
"# That is the cooldown working, not an error — reuse that job rather than\n",
"# failing the notebook.\n",
"try:\n",
" job = client.sample_profiles(limit=3)\n",
" print(json.dumps(job, indent=2)[:400])\n",
" print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n",
"except Exception as e:\n",
" detail = str(e)\n",
" print(\"sample refused:\", detail[:200])\n",
" running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n",
" job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n",
" print(\"reusing the running job:\", running)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n",
"so format it defensively rather than assuming a number.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Apply a new schema to existing users\n",
"\n",
"A new schema shapes the **next** generation. Profiles that already exist keep their values\n",
"until their user is generated again — which happens as that user sends more memories, or\n",
"when you call `generate_profile()` for them.\n",
"\n",
"A field you **remove** stops being maintained: on the next generation, fields your schema\n",
"no longer defines are pruned. Keep a field for as long as you want its value kept."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"# Failure scenarios\n",
"\n",
"Everything above is the path that works. These are the ways it says no, and what each one\n",
"means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n",
"\n",
"> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n",
"> ERROR level before raising, so they appear even for the failures these cells\n",
"> deliberately catch. Read the line printed *after* each one — that is the cell's\n",
"> own verdict. Nothing here is unhandled.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F1. Schemas that get rejected\n",
"\n",
"Rejections happen on **write**, where you can see and fix them — not silently at\n",
"generation time, where you would only notice as an empty profile weeks later.\n",
"\n",
"The last case matters for storage: the user schema lives in one JSONB column alongside\n",
"the envelope that separates it, so a schema using the envelope's own reserved keys could\n",
"not be read back unambiguously. It is refused rather than stored.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"BAD_SCHEMAS = [\n",
" ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n",
" ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n",
" (\n",
" {\n",
" \"type\": \"object\",\n",
" \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n",
" # At the schema ROOT, which is where the envelope's own keys live.\n",
" \"_profile_config_version\": 1,\n",
" \"entities\": {\"user\": {}},\n",
" },\n",
" \"reserved settings keys at the schema root\",\n",
" ),\n",
"]\n",
"\n",
"for bad, why in BAD_SCHEMAS:\n",
" try:\n",
" client.update_profile_settings(schema=bad)\n",
" print(f\"ACCEPTED (unexpected): {why}\")\n",
" except Exception as e:\n",
" print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n",
"\n",
"# NOT rejected: a property with no description. The validator allows it and the\n",
"# model then has nothing to go on, so the field comes back empty. Descriptions are\n",
"# your job, not the validator's.\n",
"try:\n",
" client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n",
" print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n",
"finally:\n",
" client.update_profile_settings(schema=SCHEMA) # put the good one back\n",
"\n",
"restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n",
"assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n",
"print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F2. A user that does not exist\n",
"\n",
"404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n",
"`insufficient_data`, so an ordinary empty state never looks like an error.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from mem0.exceptions import MemoryNotFoundError\n",
"\n",
"try:\n",
" client.get_profile(\"user_who_never_existed\")\n",
" print(\"ACCEPTED (unexpected)\")\n",
"except MemoryNotFoundError as e:\n",
" print(\"404 as intended:\", str(e)[:120])\n",
"\n",
"# ...versus a real user who simply has no profile row yet.\n",
"fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n",
"client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n",
"time.sleep(5)\n",
"print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F3. Profiles turned off\n",
"\n",
"`enabled` is the one project-wide switch. Every generation path then refuses.\n",
"\n",
"Nothing is deleted. Your schema and every profile you already built are kept, so turning\n",
"it back on resumes rather than restarts.\n",
"\n",
"Note what a read does **not** do — a profile that already exists keeps reporting\n",
"`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n",
"with no profile yet. Turning the feature off stops new work; it does not hide what has\n",
"already been built.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"client.update_profile_settings(enabled=False)\n",
"\n",
"print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n",
"print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n",
"try:\n",
" client.generate_profile(USER_ID)\n",
" print(\"trigger: ACCEPTED (unexpected)\")\n",
"except Exception as e:\n",
" print(\"trigger:\", str(e)[:160])\n",
"\n",
"back = client.update_profile_settings(enabled=True) # put it back\n",
"print(\"\\nrestored:\", back[\"enabled\"])\n",
"print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Cleanup\n",
"\n",
"Removes the demo users. The profile row cascades with the entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "for entity_id in (USER_ID, fresh):\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\n\n# Restore the project's profile settings to the start-of-run snapshot, so a shared\n# env is left exactly as we found it. Passing the original values (including None)\n# clears anything this notebook set — the SDK distinguishes an explicit None from an\n# omitted argument.\n_user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\nclient.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n)\nprint(\"profile settings restored to the pre-notebook snapshot\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Cheat sheet\n",
"\n",
"| Want | Call | Cost |\n",
"| --- | --- | --- |\n",
"| configure | `update_profile_settings(...)` | free |\n",
"| read | `get_profile(user_id)` | free |\n",
"| one user now | `generate_profile(user_id)` | 1 LLM call |\n",
"| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n",
"| poll a job | `get_profile_job(status_url)` | free |\n",
"\n",
"**Settings apply to user profiles.** The stored shape is:\n",
"\n",
"```json\n",
"{\"enabled\": true,\n",
" \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n",
" \"capabilities\": {\"full_rebuild\": false}}\n",
"```\n",
"\n",
"The SDK takes these flat and nests them for you. Only the fields you pass are written;\n",
"`enabled` is the one project-wide switch.\n",
"\n",
"**Left alone, generation fires** on a 10-message boundary, or after a flush window\n",
"measured in hours. `generate_profile()` is how you skip the wait for one user.\n",
"\n",
"**Three traps:**\n",
"\n",
"1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n",
" through it and give up on a timeout instead.\n",
"2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n",
" extraction still increments the counter.\n",
"3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n",
" arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.1.8",
"version": "3.2.0",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+9
View File
@@ -23,6 +23,15 @@ export type {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileStatus,
ProfileResponse,
ProfileJobResponse,
ProfileJobStatus,
ProfileSettings,
ProfileSettingsResponse,
EntityProfileSettings,
ProfileSampleResult,
} from "./mem0.types";
// Re-export enums as values (not type-only)
+207 -3
View File
@@ -1,4 +1,5 @@
import axios from "axios";
import { v4 as uuidv4 } 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<string, Promise<ClientIdentity>>();
@@ -244,7 +254,8 @@ export default class MemoryClient {
});
}
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
/** Fetch with no key conversion, for payloads carrying user-controlled property names. */
async _fetchRawJson(url: string, options: any): Promise<any> {
const response = await fetch(url, {
...options,
headers: {
@@ -257,8 +268,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<any> {
return snakeToCamelKeys(await this._fetchRawJson(url, options));
}
_preparePayload(
@@ -755,6 +769,196 @@ export default class MemoryClient {
return response;
}
/**
* Get the memory profile for a single user.
*
* Branch on `status`, not on an empty `profile`: generation is asynchronous,
* so a known user without a profile yet is a normal response.
*/
async getProfile(data: { entityId: string }): Promise<ProfileResponse> {
this._captureEvent("get_profile", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/entities/user/${encodeURIComponent(data.entityId)}/profile/`,
{
headers: this.headers,
},
);
return response;
}
/**
* Generate or refresh the profile for one user, now.
*
* Profiles are otherwise built once a user crosses an internal message
* threshold, so a new user has none for its first few memories. Returns as
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
*
* Pass `idempotencyKey` and reuse it to retry a lost request without starting
* (and being billed for) a second job.
*/
async generateProfile(data: {
entityId: string;
idempotencyKey?: string;
}): Promise<ProfileJobResponse> {
this._captureEvent("generate_profile", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: {
...this.headers,
"Idempotency-Key": data.idempotencyKey ?? uuidv4(),
},
body: JSON.stringify({
operation: "trigger",
entity_type: "user",
entity_id: data.entityId,
}),
},
);
return response;
}
/** Get the profile settings for the current project. */
async getProfileSettings(): Promise<ProfileSettingsResponse> {
this._captureEvent("get_profile_settings", []);
await this._awaitIdentity();
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
headers: this.headers,
});
return this._settingsWithVerbatimSchema(raw);
}
/**
* The envelope keys are ours; the schema's property names are the customer's.
*
* Each entity type carries its own schema, so every one has to be restored
* from the raw body — otherwise camel-casing rewrites the customer's field
* names and a profile comes back under keys they never chose.
*/
private _settingsWithVerbatimSchema(raw: any): ProfileSettingsResponse {
const settings = snakeToCamelKeys(raw) as ProfileSettingsResponse;
if (!raw || typeof raw !== "object") {
return settings;
}
if ("schema" in raw) {
settings.schema = raw.schema;
}
const rawEntities = raw.entities;
if (rawEntities && typeof rawEntities === "object") {
for (const [entityType, entitySettings] of Object.entries(rawEntities)) {
if (
entitySettings &&
typeof entitySettings === "object" &&
"schema" in entitySettings &&
settings.entities?.[entityType as ProfileEntityType]
) {
settings.entities[entityType as ProfileEntityType]!.schema = (
entitySettings as Record<string, any>
).schema;
}
}
}
return settings;
}
/**
* Update profile settings. Only the fields you pass are written.
*
* `schema` and `customInstructions` are per user and are nested under
* `entities` for the API; only `enabled` is project-wide. Sending them flat
* is rejected with `Unsupported settings`.
*/
async updateProfileSettings(
settings: ProfileSettings,
): Promise<ProfileSettingsResponse> {
const payloadKeys = Object.keys(settings || {});
this._captureEvent("update_profile_settings", [payloadKeys]);
await this._awaitIdentity();
const { schema, customInstructions, enabled } = settings || {};
const body: Record<string, any> = {};
if (enabled !== undefined) {
body.enabled = enabled;
}
const entitySettings: Record<string, any> = {};
// The schema's property names are the customer's and must reach the API verbatim.
if (schema !== undefined) {
entitySettings.schema = schema;
}
if (customInstructions !== undefined) {
entitySettings.custom_instructions = customInstructions;
}
if (Object.keys(entitySettings).length > 0) {
body.entities = { user: entitySettings };
}
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
return this._settingsWithVerbatimSchema(raw);
}
/**
* Generate profiles for a few real users, to check a schema.
*
* Real generations against real memories, and the results are kept: the
* profiles are written to those users and count toward usage.
*/
async sampleProfiles(data?: {
limit?: number;
idempotencyKey?: string;
}): Promise<ProfileJobResponse> {
this._captureEvent("sample_profiles", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: {
...this.headers,
"Idempotency-Key": data?.idempotencyKey ?? uuidv4(),
},
body: JSON.stringify({
operation: "sample",
// Required: the API refuses a job that does not name an entity kind.
entity_type: "user",
...this._prepareParams({ limit: data?.limit }),
}),
},
);
return response;
}
/**
* Read one generation job. Accepts the `statusUrl` from a create call, or a
* bare job id. Prefer `statusUrl` so a route change needs no client update.
*/
async getProfileJob(jobIdOrStatusUrl: string): Promise<ProfileJobStatus> {
await this._awaitIdentity();
const path = jobIdOrStatusUrl.startsWith("/")
? jobIdOrStatusUrl
: `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`;
return this._fetchWithErrorHandling(`${this.host}${path}`, {
method: "GET",
headers: this.headers,
});
}
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
+102
View File
@@ -236,3 +236,105 @@ export interface GetMemoryExportPayload {
filters?: Record<string, any>;
memoryExportId?: string;
}
// ─── Profile Types ──────────────────────────────────────────
/** The entity kind that carries a profile. */
export type ProfileEntityType = "user";
/**
* `succeeded` is the only state in which `profile` is guaranteed to hold content.
*
* These are values, not keys, so the client does not camel-case them: the wire
* spelling is what a comparison has to match.
*/
export type ProfileStatus =
| "succeeded"
| "pending"
| "failed"
| "not_enabled"
| "insufficient_data";
export interface ProfileResponse {
/** Shaped by the project's schema; keys are not camel-cased. */
profile: Record<string, any>;
status: ProfileStatus;
entityType: ProfileEntityType;
entityId: string;
updatedAt: string | null;
generationCount: number;
/** The last generation's failure reason, when `status` is `failed`. */
error?: string | null;
}
/** Every accepted generation. `statusUrl` is the server's own poll path. */
export interface ProfileJobResponse {
jobId: string;
status: string;
statusUrl: string;
operation: string;
entityType: ProfileEntityType;
/** Entities reserved against usage for this job. */
entityCountReserved?: number;
eventId?: string;
replayed?: boolean;
/** Sample runs only: how many entities were picked. */
sampled?: number;
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
entityIds?: string[];
}
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
export interface ProfileSettings {
/** Turn profile generation on or off. Project-wide. */
enabled?: boolean;
/** JSON Schema for the profile. Every property needs a `description`. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
}
/** One entity type's stored configuration. */
export interface EntityProfileSettings {
/** The customer's JSON Schema, with its property names verbatim. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
[key: string]: any;
}
/**
* The settings as stored. `schema` and `customInstructions` are per entity
* type and nest under `entities`; only `enabled` is project-wide.
*/
export interface ProfileSettingsResponse {
enabled?: boolean;
entities?: Partial<Record<ProfileEntityType, EntityProfileSettings>>;
capabilities?: Record<string, any>;
[key: string]: any;
}
export interface ProfileSampleResult {
entityType: ProfileEntityType;
entityId: string;
profileId?: string;
[key: string]: any;
}
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
export interface ProfileJobStatus {
job: {
id: string;
operation: string;
entityType: ProfileEntityType;
status: string;
/** Null until `enumerationComplete`. */
total: number | null;
enumerationComplete: boolean;
/** succeeded + failed + skipped. */
completed: number;
succeeded: number;
failed: number;
skipped: number;
results?: Array<ProfileSampleResult>;
[key: string]: any;
};
}
@@ -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<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/alice/profile/", {
status: 200,
body: {
// Customer schema keys: camel-casing these would break the schema they wrote.
profile: {
favorite_topics: ["hiking"],
work_style: { preferred_hours: "mornings" },
},
status: "succeeded",
entity_type: "user",
entity_id: "alice",
updated_at: "2026-02-08T00:00:00Z",
generation_count: 3,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/entities/user/alice/profile/");
expect(call).toBeDefined();
expect(result.profile).toEqual({
favorite_topics: ["hiking"],
work_style: { preferred_hours: "mornings" },
});
expect(result.entityType).toBe("user");
expect(result.entityId).toBe("alice");
expect(result.generationCount).toBe(3);
expect(result.status).toBe("succeeded");
});
test("status keeps its wire spelling", async () => {
// A status is a VALUE, not a key, so the client does not camel-case it.
// Declaring the union as `insufficientData` made tsc reject the comparison
// that works and accept one that can never be true.
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/bob/profile/", {
status: 200,
body: {
profile: {},
status: "insufficient_data",
entity_type: "user",
entity_id: "bob",
},
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfile({ entityId: "bob" });
expect(result.status).toBe("insufficient_data");
// Assignable without a cast: the declared union must contain the wire value.
const status: ProfileStatus = result.status;
expect(status).not.toBe("insufficientData");
});
test("scopes to user and encodes the entity id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/", {
status: 200,
body: { profile: {}, status: "pending", entity_type: "user" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getProfile({ entityId: "a/b" });
const call = findFetchCall(mock, "/v2/entities/user/a%2Fb/profile/");
expect(call).toBeDefined();
});
});
describe("MemoryClient - generateProfile()", () => {
test("sends operation trigger with the entity to the jobs collection", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "00000000-0000-7000-8000-000000000001",
status: "QUEUED",
status_url: "/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
operation: "trigger",
entity_type: "user",
entity_count_reserved: 1,
event_id: "00000000-0000-7000-8000-000000000002",
replayed: false,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.generateProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
expect(body.operation).toBe("trigger");
expect(body.entity_type).toBe("user");
expect(body.entity_id).toBe("alice");
// A generated Idempotency-Key is always sent so the create can be retried safely.
const headers = call![1].headers as Record<string, string>;
expect(headers["Idempotency-Key"]).toBeTruthy();
expect(result.jobId).toBe("00000000-0000-7000-8000-000000000001");
expect(result.statusUrl).toBe(
"/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
);
expect(result.replayed).toBe(false);
});
test("reuses a caller-supplied idempotency key", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "j1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/j1/",
operation: "trigger",
entity_type: "user",
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.generateProfile({
entityId: "alice",
idempotencyKey: "retry-key-123",
});
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
const headers = call![1].headers as Record<string, string>;
expect(headers["Idempotency-Key"]).toBe("retry-key-123");
});
});
describe("MemoryClient - profile settings", () => {
test("sends schema property names verbatim and returns them unchanged", async () => {
const schema = {
type: "object",
properties: {
favorite_topics: {
type: "array",
description: "Topics the user returns to",
items: { type: "string" },
},
workStyle: {
type: "string",
description: "How the user prefers to work",
},
},
};
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: true,
entities: {
user: {
schema,
custom_instructions: "Focus on durable preferences",
},
},
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.updateProfileSettings({
enabled: true,
schema,
customInstructions: "Focus on durable preferences",
});
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
// Mixed casing goes out exactly as written, nested under the entity type.
expect(body.entities.user.schema).toEqual(schema);
expect(body.entities.user.custom_instructions).toBe(
"Focus on durable preferences",
);
expect(body.enabled).toBe(true);
// A flat schema is rejected by the API with "Unsupported settings".
expect("schema" in body).toBe(false);
// And the customer's property names survive the round trip.
expect(result.entities?.user?.schema).toEqual(schema);
expect(result.entities?.user?.customInstructions).toBe(
"Focus on durable preferences",
);
});
test("scopes entity-level settings under user", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: { enabled: true },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.updateProfileSettings({
schema: { type: "object", properties: {} },
});
const body = getFetchBody(
findFetchCall(mock, "/v2/profiles/settings/", "POST")!,
);
expect(Object.keys(body.entities)).toEqual(["user"]);
});
test("omits fields the caller did not set", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: false,
entities: { user: { schema: null, custom_instructions: null } },
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.updateProfileSettings({ enabled: false });
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
const body = getFetchBody(call!);
expect(body.enabled).toBe(false);
// Nothing entity-scoped was passed, so no entities key is sent at all.
expect("entities" in body).toBe(false);
expect("schema" in body).toBe(false);
expect("custom_instructions" in body).toBe(false);
});
test("getProfileSettings reads the v2 route", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: true,
entities: {
user: {
schema: { properties: { favorite_topics: { type: "array" } } },
custom_instructions: null,
},
},
capabilities: { full_rebuild: false },
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfileSettings();
expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined();
expect(result.enabled).toBe(true);
expect(result.entities?.user?.schema).toEqual({
properties: { favorite_topics: { type: "array" } },
});
expect(result.capabilities?.fullRebuild).toBe(false);
});
});
describe("MemoryClient - sampleProfiles()", () => {
test("sampleProfiles omits limit when unset", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "job_1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_1/",
operation: "sample",
entity_type: "user",
sampled: 5,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.sampleProfiles();
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
// Every job names an entity kind: the API refuses one that does not.
expect(getFetchBody(call!)).toEqual({
operation: "sample",
entity_type: "user",
});
});
test("sampleProfiles passes an explicit limit", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "job_2",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_2/",
operation: "sample",
entity_type: "user",
sampled: 3,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.sampleProfiles({ limit: 3 });
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(getFetchBody(call!).operation).toBe("sample");
expect(getFetchBody(call!).limit).toBe(3);
expect(getFetchBody(call!).entity_type).toBe("user");
expect(result.sampled).toBe(3);
});
});
+3
View File
@@ -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",
]);
/**
+382 -2
View File
@@ -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):
@@ -371,7 +409,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()
@@ -682,6 +722,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.
@@ -1291,7 +1499,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()
@@ -1588,6 +1798,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.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.20"
version = "2.1.0"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
+272
View File
@@ -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}}},
)