Compare commits

..
Author SHA1 Message Date
Saket Aryan 8fabc87f6e fix(openclaw): make keyFingerprint actually persist, and serialize flushes
Three findings from @kartik-mem0 on this PR. The first two mean the fingerprint
gate this PR added never worked, and the first would have broken the plugin.

keyFingerprint was not in ALLOWED_KEYS, and assertAllowedKeys throws on an
unknown key. So the first successful lookup wrote a config that every later load
of the plugin rejected outright. Added there and to the manifest schema, with a
round-trip test that parses a config carrying the field.

readPluginAuth builds its result field by field and did not include
keyFingerprint, so the comparison always ran against undefined, the resolved
email was never used again, and telemetry fell back to the API key hash for every
event. That is worse than the defect this PR set out to fix, which at least used
the email. Returned now, with a test against the real reader.

Both were invisible to the telemetry tests because those mock the config module,
and the mock returned a field the real reader drops. That is the actual lesson
here, so the new tests live in config-file.test.ts and config.test.ts against the
real implementations. Confirmed both fail without the fixes.

flush() also had no in-flight guard. Two overlapping flushes each detach the
queue and each prepend their own batch back on failure, so the later batch landed
in front of the earlier one and the truncation then dropped the OLDER events
first, inverting the priority the failure path exists to establish. Serialized
with a guard; a second caller returns and the queue waits for the next flush.
Regression test asserts one delivery in flight and the backlog order preserved.

core 37, openclaw 435, opencode 35, deepseek 47. Wire e2e 9 of 9, exit cost
unchanged at 6s failing and 12ms healthy.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 21:59:03 +05:30
Saket Aryan 7900a5d8d1 docs(plugins): say what the delivery budget actually counts
Review finding. The comments described MAX_DELIVERY_ATTEMPTS as a per-batch
budget mirroring the Python core's. It is not: Python puts the attempt count in
the claim filename so it follows one batch, while this counter lives in the
closure and counts consecutive failed flushes, so events captured during an
outage join the same queue and are dropped with it.

Per-batch accounting would mean an attempt count on every event. The queue is
already bounded, so the simpler rule stands; the comments now describe it rather
than the Python one.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:43:51 +05:30
Saket Aryan cbf97c6077 fix(plugins): bound the exit flush, and stop openclaw deleting a legacy account
Second review round. The first two are regressions from the first round.

The forced exit flush added fifteen seconds to host shutdown. Node re-emits
beforeExit whenever the handler schedules async work, so an unconditional
flush(true) looped until the five-attempt budget was spent, and against the real
3s delivery timeout that is 15s added to the shutdown of whatever editor or CLI
is hosting this. The backoff used to end that loop after one attempt; removing it
for the forced path removed the only thing bounding it. Measured at 15008ms, now
6001ms with a one-shot latch, and 12ms when delivery is healthy, which is the
only case most people ever see.

openclaw deleted a legacy account before it had anything to replace it with. An
install predating keyFingerprint has an email and no fingerprint, so the
comparison failed and clearResolvedAccount() ran immediately; if the re-resolve
then failed because the user was offline the email was gone from disk for good,
and the per-key latch was already set so nothing retried. Now cleared only when a
real fingerprint disagrees, which is the same trade the Python core makes and
documents: verify, and keep what you have until the verification succeeds. The
latch is released on a failed lookup so the next capture tries again.

The overflow test did not exercise the path it is named for: with only two
events captured the re-queue had an empty queue to merge into, so the slice on
the failure path never ran, and that is the half deciding which end gets dropped.
It now fills past the cap before flushing and asserts the exact surviving order.

core 36, openclaw 432, opencode 35, deepseek 47. Wire e2e 9 of 9 and identity
e2e 7 of 7 still pass against a real local server.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:43:25 +05:30
Saket Aryan 44c6a07ddf fix(plugins): flush on exit regardless of backoff, and prefer the backlog over new events
Findings from an independent review of this branch.

The exit-time flush was gated by its own cooldown. beforeExit called the same
flush() that opens with a retryNotBefore check, so after any failed delivery a
process exiting inside the 2s to 60s window sent nothing and the queue died with
it. That is precisely the loss this branch exists to stop, and timer.unref makes
beforeExit often the only remaining chance. flush(force) now skips the cooldown
and beforeExit passes it.

Overflow kept the newest and evicted the batch being retried, which threw away
exactly the events the retry exists to save. Both the failure path and capture()
now keep the backlog and drop the new event instead, matching Python's record(),
which refuses new events once the spool is full.

That change needs a bound, so delivery now gives up after five attempts, as the
Python core does. Without one a payload the server will never accept would be
retried for the whole session and, with the backlog now preferred, would hold the
queue against everything behind it.

Events carry a capture-time timestamp. They now sit through backoff and across
an entire outage, so without one PostHog records them at whatever moment delivery
happened to succeed. It also matters for the uuid dedupe, whose key includes the
event date.

openclaw wiped a resolved account on any capture without an apiKey: the
fingerprint comparison was `undefined === ""`, so a keyless call looked like a key
change. Guarded on a real key being present. Masked today because every call site
supplies one, which is why only a test found it.

Two smaller ones. opencode's PostHog source was "plugin", which named no
particular plugin and matched no vocabulary; it is now OPENCODE_PLUGIN like every
other surface, and a saved insight filtering source = "plugin" needs repointing.
And an em dash in opencode's published description had been rewritten to a —
escape by my own json.dumps when adding the test script; restored.

One openclaw test asserted only not.toThrow() under a name claiming it checked
the identity, and under the fingerprint gate the path it exercised no longer uses
the email at all. It now pins the real condition.

core 35, openclaw 432, opencode 35, pi-agent 89, deepseek 47. The wire e2e still
passes 9 of 9 against a real local server, and the identity e2e 7 of 7.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:30:59 +05:30
Saket Aryan 6f207d2e28 fix(plugins): treat an HTTP error response as a failed delivery
Found by driving the core against a real local server rather than an injected
delivery stub, which is exactly where it could hide: fetch only rejects on a
network-level failure, so a 500, a 503 or a 429 resolved normally and the batch
was counted as delivered and dropped. The unit tests could not catch it because
their stub throws, and real fetch does not.

That is the likelier outage than a refused connection, so the retry added in the
previous commit was covering the rarer half of the problem.

Any non-2xx now throws and takes the retry path. Matching the Python core, which
retries every HTTP error rather than classifying them: the backoff and the queue
bound contain a payload that will never be accepted, because the re-queued batch
sits at the front and is the first thing evicted.

Two tests against the real default delivery path, stubbing fetch rather than the
delivery hook, so a 503 keeps the batch and a 200 clears it.

End to end against a local server, real fetch and real retry timing: events
arrive and carry a uuid, a 503 keeps the batch, an immediate retry is suppressed
by the backoff, the retained event is delivered on recovery with its original
uuid and no duplicate, and a refused connection behaves the same way. Nine of
nine.

Identity checked on the same path: opencode's project_hash is salted, differs per
account, is stable within one, and no raw project id appears in the payload.
openclaw emits two distinct identities across a key change, which is the defect
this branch fixes, observed on the wire rather than through a mock.

agent-plugin-core/ts 32, openclaw 431, opencode 35, pi-agent 89, deepseek 47,
Python core 242 unaffected.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:02:51 +05:30
Saket Aryan c05556f3cf fix(plugins): stop the TypeScript telemetry losing events and misattributing accounts
The Python plugin telemetry was hardened across #7322 to #7326. The TypeScript
side has the same defect classes and was not touched, because the two share no
code: agent-plugin-core/python generates into six bundles, agent-plugin-core/
typescript is a separate core each plugin wraps. Fixing one surfaced nothing
about the other, which is how this survived.

Three fixes, all confirmed by running the code rather than reading it.

A failed delivery deleted the batch. The core detached the queue before the
await and swallowed the error, so one blip destroyed the events with nothing
recording that it happened. Probed: two events in, delivery throws, queue goes to
zero, no retry ever. The batch is now put back, bounded by maxQueueSize and
biased to the newest so a long outage costs the oldest events rather than
unbounded memory, and repeated failures back off to a ceiling instead of retrying
every flush against a host that is blocking us. Every event now carries a uuid
stamped at capture, which is what makes the retry safe: PostHog collapses
anything it already accepted.

Deliberately no disk spool, and that is written into the code so it reads as a
decision. Python spools because its hooks are per-tool-call processes that exit
immediately. These plugins live inside a host for a whole session, so
re-queueing covers the same transient failures without the claim and lease
machinery that took three review rounds to get right on the Python side. What it
leaves uncovered is narrow: a session that both starts and ends offline.

openclaw used a cached email forever. The refresh was guarded by !hasEmail, so
after an API key change every event kept reporting under the previous account.
The email is now bound to a fingerprint of the key it was resolved for and only
used while those agree; a mismatch forgets the account and re-resolves. The
resolution latch is per key rather than once per process, so a key changed
mid-session is actually looked up. A row with an email and no fingerprint, which
is what an upgrade from the current version looks like, is verified rather than
adopted, matching the decision reached on #7325.

opencode hashed the project id unsalted, which is reversible for anyone who can
enumerate project ids. Salted with the API key rather than a stored per-install
value: it is already in play, it is high entropy, and it needs no new file and so
no write race to get wrong. Per account rather than per machine, which also keeps
joins working across machines, and it resets on key rotation consistently with
distinctId, which already did.

Also wired opencode's tests into CI. They existed and nothing ran them, so the
regression test asked for on #7322 would not have gated anything.

Verified: agent-plugin-core/ts 30, openclaw 431, opencode 35, pi-agent 89,
deepseek 47. The new tests were each checked against the unfixed code first; the
core ones fail 3 of 3 and the openclaw one fails without the fingerprint gate.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 18:43:27 +05:30
Paurush MittalandGitHub 0df3e4b87d fix(docs): correct rendered titles and remaining SEO links (#7344) 2026-09-16 17:14:33 +05:30
85 changed files with 731 additions and 3160 deletions
@@ -32,6 +32,9 @@ jobs:
- name: Type check
run: bun run type-check
- name: Test
run: bun test
- name: Build
run: bun run build
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: "Overview"
seo:
title: "API Reference Overview - Mem0"
title: "API Reference Overview"
sidebarTitle: "Overview"
icon: "terminal"
iconType: "solid"
description: "REST APIs for memory management, search, and entity operations"
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: 'Delete Memory'
seo:
title: "Delete Memory API Endpoint - Mem0"
title: "Delete Memory API Endpoint"
sidebarTitle: "Delete Memory"
description: "Delete a single memory by its unique memory ID from the Mem0 platform using the DELETE endpoint."
openapi: delete /v1/memories/{memory_id}/
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: 'Update Memory'
seo:
title: "Update Memory API Endpoint - Mem0"
title: "Update Memory API Endpoint"
sidebarTitle: "Update Memory"
description: "Update the content, metadata, timestamp, or expiration date of a single memory by its unique ID using the PUT endpoint."
openapi: put /v1/memories/{memory_id}/
---
@@ -1,7 +1,6 @@
---
title: 'Add Member'
seo:
title: "Add Organization Member API Endpoint - Mem0"
title: "Add Organization Member API Endpoint"
sidebarTitle: "Add Member"
description: "Add a new member to an organization with a specified role such as READER or OWNER access level."
openapi: post /api/v1/orgs/organizations/{org_id}/members/
---
@@ -1,7 +1,6 @@
---
title: 'Get Members'
seo:
title: "Get Organization Members API Endpoint - Mem0"
title: "Get Organization Members API Endpoint"
sidebarTitle: "Get Members"
description: "Retrieve a list of all members belonging to a specific organization on the Mem0 platform."
openapi: get /api/v1/orgs/organizations/{org_id}/members/
---
@@ -1,5 +0,0 @@
---
title: 'Generate Profiles'
description: "Start one generation: sample a few entities, or build one for a single entity."
openapi: post /v2/profiles/jobs/
---
@@ -1,5 +0,0 @@
---
title: 'Get Generation Job'
description: "Read the progress of a generation, and whether it finished."
openapi: get /v2/profiles/jobs/{job_id}/
---
@@ -1,5 +0,0 @@
---
title: 'Get Profile Settings'
description: "Retrieve the profile schema, custom instructions, and enabled flag for the current project."
openapi: get /v2/profiles/settings/
---
@@ -1,5 +0,0 @@
---
title: 'Get Profile'
description: "Retrieve the structured profile for a user, with a status describing whether generation has completed."
openapi: get /v2/entities/{entity_type}/{entity_id}/profile/
---
@@ -1,5 +0,0 @@
---
title: 'Update Profile Settings'
description: "Set the JSON Schema, custom instructions, or enabled flag that control profile generation for the project."
openapi: post /v2/profiles/settings/
---
@@ -1,7 +1,6 @@
---
title: 'Add Member'
seo:
title: "Add Project Member API Endpoint - Mem0"
title: "Add Project Member API Endpoint"
sidebarTitle: "Add Member"
description: "Add a new member to a project with a specified role such as READER or OWNER access level."
openapi: post /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/
---
@@ -1,7 +1,6 @@
---
title: 'Get Members'
seo:
title: "Get Project Members API Endpoint - Mem0"
title: "Get Project Members API Endpoint"
sidebarTitle: "Get Members"
description: "Retrieve a list of all members belonging to a specific project on the Mem0 platform."
openapi: get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/
---
-14
View File
@@ -7,13 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-09-23" description="v2.1.0">
**New Features:**
- **Client:** Add User Profiles to `MemoryClient` and `AsyncMemoryClient`: `get_profile()`, `generate_profile()`, `get_profile_settings()`, `update_profile_settings()`, `sample_profiles()`, and `get_profile_job()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
</Update>
<Update label="2026-09-02" description="v2.0.20">
**Improvements:**
@@ -1234,13 +1227,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-09-23" description="v3.2.0">
**New Features:**
- **Client:** Add User Profiles to `MemoryClient`: `getProfile()`, `generateProfile()`, `getProfileSettings()`, `updateProfileSettings()`, `sampleProfiles()`, and `getProfileJob()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
</Update>
<Update label="2026-09-02" description="v3.1.8">
**Improvements:**
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Configurations
seo:
title: "Embedder Configuration Reference - Mem0"
title: "Embedder Configuration Reference"
sidebarTitle: "Configurations"
description: "Reference for embedder configuration options in Mem0, including provider selection and model settings."
---
@@ -1,7 +1,6 @@
---
title: AWS Bedrock
seo:
title: "AWS Bedrock as Embedding Provider - Mem0"
title: "AWS Bedrock as Embedding Provider"
sidebarTitle: "AWS Bedrock"
description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication."
---
@@ -1,7 +1,6 @@
---
title: Azure OpenAI
seo:
title: "Azure OpenAI as Embedding Provider - Mem0"
title: "Azure OpenAI as Embedding Provider"
sidebarTitle: "Azure OpenAI"
description: "Configure Azure OpenAI as an embedding provider in Mem0 with API key, deployment, and endpoint settings."
---
@@ -1,7 +1,6 @@
---
title: Google AI
seo:
title: "Google AI as Embedding Provider - Mem0"
title: "Google AI as Embedding Provider"
sidebarTitle: "Google AI"
description: "Configure Google AI as an embedding provider in Mem0 using Gemini models and the GOOGLE_API_KEY variable."
---
@@ -1,7 +1,6 @@
---
title: LangChain
seo:
title: "LangChain as Embedding Provider - Mem0"
title: "LangChain as Embedding Provider"
sidebarTitle: "LangChain"
description: "Use LangChain as an embedding provider in Mem0 to access a wide range of models through a unified interface."
---
@@ -1,7 +1,6 @@
---
title: "LM Studio"
seo:
title: "LM Studio as Embedding Provider - Mem0"
title: "LM Studio as Embedding Provider"
sidebarTitle: "LM Studio"
description: "Configure LM Studio as an embedding provider in Mem0 for local embedding generation with models like nomic-embed-text."
---
You can use embedding models from LM Studio to run Mem0 locally.
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: "Ollama"
seo:
title: "Ollama as Embedding Provider - Mem0"
title: "Ollama as Embedding Provider"
sidebarTitle: "Ollama"
description: "Configure Ollama as an embedding provider in Mem0 to generate embeddings locally using open-source models."
---
You can use embedding models from Ollama to run Mem0 locally.
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: OpenAI
seo:
title: "OpenAI as Embedding Provider - Mem0"
title: "OpenAI as Embedding Provider"
sidebarTitle: "OpenAI"
description: "Configure OpenAI as an embedding provider in Mem0 using models like text-embedding-3-large for vector generation."
---
@@ -1,7 +1,6 @@
---
title: Together
seo:
title: "Together AI as Embedding Provider - Mem0"
title: "Together AI as Embedding Provider"
sidebarTitle: "Together"
description: "Configure Together AI as an embedding provider in Mem0 with support for 1024-dimensional embedding models."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Overview
seo:
title: "Embedding Providers Overview - Mem0"
title: "Embedding Providers Overview"
sidebarTitle: Overview
description: "Overview of all supported embedding model providers in Mem0, including OpenAI, Azure, Ollama, and more."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Configurations
seo:
title: "LLM Configuration Reference - Mem0"
title: "LLM Configuration Reference"
sidebarTitle: "Configurations"
description: "Reference for LLM configuration options in Mem0 for Python and TypeScript, including value precedence rules."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: AWS Bedrock
seo:
title: "AWS Bedrock as LLM Provider - Mem0"
title: "AWS Bedrock as LLM Provider"
sidebarTitle: "AWS Bedrock"
description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authentication and Claude model support."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Azure OpenAI
seo:
title: "Azure OpenAI as LLM Provider - Mem0"
title: "Azure OpenAI as LLM Provider"
sidebarTitle: "Azure OpenAI"
description: "Configure Azure OpenAI as an LLM provider in Mem0 with Azure Identity authentication and deployment settings."
---
+1 -1
View File
@@ -3,7 +3,7 @@ title: DeepSeek
description: "Configure DeepSeek as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
---
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to "https://api.deepseek.com").
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to `https://api.deepseek.com`).
## Usage
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Google AI
seo:
title: "Google AI as LLM Provider - Mem0"
title: "Google AI as LLM Provider"
sidebarTitle: "Google AI"
description: "Configure Google Gemini as an LLM provider in Mem0 using the google.genai SDK and GOOGLE_API_KEY variable."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: LangChain
seo:
title: "LangChain as LLM Provider - Mem0"
title: "LangChain as LLM Provider"
sidebarTitle: "LangChain"
description: "Use LangChain as an LLM provider in Mem0 to integrate with various chat models through a unified interface."
---
+3 -4
View File
@@ -1,7 +1,6 @@
---
title: LM Studio
seo:
title: "LM Studio as LLM Provider - Mem0"
title: "LM Studio as LLM Provider"
sidebarTitle: "LM Studio"
description: "Configure LM Studio as an LLM provider in Mem0 for running local language models via an OpenAI-compatible API."
---
@@ -78,7 +77,7 @@ m.add(messages, user_id="alice123", metadata={"category": "movies"})
To use LM Studio, you need to:
1. Download and install [LM Studio](https://lmstudio.ai/)
2. Start a local server from the "Server" tab
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually http://localhost:1234/v1)
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually `http://localhost:1234/v1`)
</Note>
## Config
+1 -1
View File
@@ -3,7 +3,7 @@ title: MiniMax
description: "Configure MiniMax as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
---
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to "https://api.minimax.io/v1").
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to `https://api.minimax.io/v1`).
## Usage
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Ollama
seo:
title: "Ollama as LLM Provider - Mem0"
title: "Ollama as LLM Provider"
sidebarTitle: "Ollama"
description: "Configure Ollama as an LLM provider in Mem0 for running local language models with tool-calling support."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: OpenAI
seo:
title: "OpenAI as LLM Provider - Mem0"
title: "OpenAI as LLM Provider"
sidebarTitle: "OpenAI"
description: "Configure OpenAI as an LLM provider in Mem0 with support for GPT models and Openrouter compatibility."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Together
seo:
title: "Together AI as LLM Provider - Mem0"
title: "Together AI as LLM Provider"
sidebarTitle: "Together"
description: "Configure Together AI as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: xAI
seo:
title: "xAI Grok as LLM Provider - Mem0"
title: "xAI Grok as LLM Provider"
sidebarTitle: "xAI"
description: "Configure xAI Grok models as an LLM provider in Mem0 with API key setup and usage examples."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Overview
seo:
title: "LLM Providers Overview - Mem0"
title: "LLM Providers Overview"
sidebarTitle: Overview
description: "Overview of all supported LLM providers in Mem0, including OpenAI, Anthropic, Groq, Ollama, and more."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Overview
seo:
title: "Reranker Providers Overview - Mem0"
title: "Reranker Providers Overview"
sidebarTitle: "Overview"
description: 'Pick the right reranker path to boost Mem0 search relevance.'
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Configurations
seo:
title: "Vector Store Configuration Reference - Mem0"
title: "Vector Store Configuration Reference"
sidebarTitle: "Configurations"
description: "Reference for vector database configuration options in Mem0, including provider selection and connection settings."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: LangChain
seo:
title: "LangChain as Vector Store Provider - Mem0"
title: "LangChain as Vector Store Provider"
sidebarTitle: "LangChain"
description: "Use LangChain as a unified vector store provider in Mem0 to access multiple vector databases through one interface."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Overview
seo:
title: "Vector Store Providers Overview - Mem0"
title: "Vector Store Providers Overview"
sidebarTitle: "Overview"
description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Overview
seo:
title: "Cookbooks and Tutorials - Mem0"
title: "Cookbooks and Tutorials"
sidebarTitle: "Overview"
description: "Browse cookbook examples and tutorials for building AI applications with Mem0, from companion chatbots to AI agents."
---
@@ -1,7 +1,6 @@
---
title: Delete Memory
seo:
title: "Delete Memory Operation - Mem0"
title: "Delete Memory Operation"
sidebarTitle: "Delete Memory"
description: Remove memories from Mem0 either individually, in bulk, or via filters.
icon: "trash"
iconType: "solid"
@@ -1,7 +1,6 @@
---
title: Update Memory
seo:
title: "Update Memory Operation - Mem0"
title: "Update Memory Operation"
sidebarTitle: "Update Memory"
description: Modify an existing memory by updating its content or metadata.
icon: "pen-to-square"
iconType: "solid"
-12
View File
@@ -71,7 +71,6 @@
"pages": [
"platform/features/v2-memory-filters",
"platform/features/entity-scoped-memory",
"platform/features/user-profiles",
"platform/features/graph-memory",
"platform/features/async-client",
"platform/features/multimodal-support",
@@ -512,17 +511,6 @@
"api-reference/entities/delete-user"
]
},
{
"group": "Profiles",
"icon": "id-card",
"pages": [
"api-reference/profiles/get-profile",
"api-reference/profiles/get-profile-settings",
"api-reference/profiles/update-profile-settings",
"api-reference/profiles/generate-profiles",
"api-reference/profiles/get-profile-job"
]
},
{
"group": "Organizations",
"icon": "building",
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Overview
seo:
title: "Integrations Overview - Mem0"
title: "Integrations Overview"
sidebarTitle: "Overview"
description: "Overview of Mem0 integrations with popular AI frameworks and tools for persistent memory and context management."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: AWS Bedrock
seo:
title: "AWS Bedrock Integration with Mem0"
title: "AWS Bedrock Integration"
sidebarTitle: "AWS Bedrock"
description: "Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage."
---
+1 -1
View File
@@ -3,7 +3,7 @@ title: Codex
description: "Add persistent memory to OpenAI Codex with automatic capture, automatic recall, a search tool, and six memory skills."
---
Add persistent memory to [**OpenAI Codex**](https://openai.com/index/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session.
Add persistent memory to [**OpenAI Codex**](https://openai.com/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session.
<Info>Current plugin version: `0.3.1`.</Info>
+1 -1
View File
@@ -23,7 +23,7 @@ npm install -g flowise
npx flowise start
```
2. Access to the Flowise UI at http://localhost:3000
2. Access to the Flowise UI at `http://localhost:3000`
3. Basic familiarity with [Flowise's LLM orchestration](https://flowiseai.com/#features) concepts
## Setup and Configuration
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: Langchain
seo:
title: "LangChain Integration with Mem0"
title: "LangChain Integration"
sidebarTitle: "Langchain"
description: "Build personalized AI agents using LangChain for conversation flow and Mem0 for long-term memory retention."
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: n8n
seo:
title: "n8n Integration with Mem0"
title: "n8n Integration"
sidebarTitle: "n8n"
description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required."
---
-6
View File
@@ -197,7 +197,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
### Features - Essential
- [V2 Memory Filters](https://docs.mem0.ai/platform/features/v2-memory-filters) [Platform]: Use when compound filters (AND/OR on metadata, entity, time) are needed at search.
- [Entity-Scoped Memory](https://docs.mem0.ai/platform/features/entity-scoped-memory) [Platform]: Use when partitioning memories by user, agent, app, or run.
- [Profiles](https://docs.mem0.ai/platform/features/user-profiles) [Platform]: Use when a structured always-current summary of a user is needed in one read, instead of searching their memories.
- [Graph Memory](https://docs.mem0.ai/platform/features/graph-memory) [Platform]: Use when connecting facts across memories through shared entities for entity-centric or multi-hop questions.
- [Async Client](https://docs.mem0.ai/platform/features/async-client) [Platform]: Use when the app issues many concurrent Mem0 calls and needs non-blocking I/O.
- [Multimodal Support](https://docs.mem0.ai/platform/features/multimodal-support) [Platform]: Use when storing images or PDFs as memory input.
@@ -363,11 +362,6 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key).
### Entities
- [Get Users](https://docs.mem0.ai/api-reference/entities/get-users) [Platform]: Use when listing users, agents, or apps known to a project.
- [Delete User](https://docs.mem0.ai/api-reference/entities/delete-user) [Platform]: Use when removing an entity and all its memories.
- [Get Profile](https://docs.mem0.ai/api-reference/profiles/get-profile) [Platform]: Use when reading a user's structured profile and branching on its generation status.
- [Get Profile Settings](https://docs.mem0.ai/api-reference/profiles/get-profile-settings) [Platform]: Use when checking the project's profile schema, instructions, or enabled flag.
- [Update Profile Settings](https://docs.mem0.ai/api-reference/profiles/update-profile-settings) [Platform]: Use when defining or changing the JSON Schema that shapes profiles for a project.
- [Generate Profiles](https://docs.mem0.ai/api-reference/profiles/generate-profiles) [Platform]: Use when building profiles now: a sample of ten, or one entity.
- [Get Generation Job](https://docs.mem0.ai/api-reference/profiles/get-profile-job) [Platform]: Use when checking how far a generation has got, and whether it finished.
### Organizations
- [Create Organization](https://docs.mem0.ai/api-reference/organization/create-org) [Platform]: Use when setting up a new org.
@@ -1,7 +1,6 @@
---
title: Custom Instructions
seo:
title: "Open Source Custom Instructions - Mem0"
title: "Open Source Custom Instructions"
sidebarTitle: "Custom Instructions"
description: Tailor fact extraction so Mem0 stores only the details you care about.
icon: "wand-magic-sparkles"
---
@@ -1,7 +1,6 @@
---
title: Multimodal Support
seo:
title: "Open Source Multimodal Support - Mem0"
title: "Open Source Multimodal Support"
sidebarTitle: "Multimodal Support"
description: Capture and recall memories from both text and images.
icon: "image"
---
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: "Overview"
seo:
title: "Open Source Features Overview - Mem0"
title: "Open Source Features Overview"
sidebarTitle: "Overview"
description: "Self-hosting features that extend Mem0 beyond basic memory storage"
icon: "list"
---
+1 -1
View File
@@ -56,7 +56,7 @@ The Mem0 REST API server exposes every OSS memory operation over HTTP. Run it al
make bootstrap # starts Compose, creates an admin, issues the first API key
```
Or to start the stack only and finish setup via the browser wizard at http://localhost:3000:
Or to start the stack only and finish setup via the browser wizard at `http://localhost:3000`:
```bash
cd server
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: "Overview"
seo:
title: "Mem0 Open Source Overview"
title: "Open Source Overview"
sidebarTitle: "Overview"
description: "Self-host Mem0 with full control over your infrastructure and data"
icon: "house"
---
+1 -507
View File
@@ -8070,512 +8070,6 @@
}
}
}
},
"/v2/entities/{entity_type}/{entity_id}/profile/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_read",
"summary": "Get an entity's profile",
"description": "Return the memory profile for one user.\n\nGeneration is asynchronous, so a known entity that has no profile yet is a normal 200 carrying a `status`. A 404 means only that no such entity exists.",
"parameters": [
{
"name": "entity_type",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"user"
]
},
"description": "The kind of entity that carries the profile."
},
{
"name": "entity_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The entity's id, as supplied when the memory was added."
}
],
"responses": {
"200": {
"description": "The profile envelope.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"profile": {
"type": "object",
"additionalProperties": true,
"description": "The generated profile, shaped by the project's schema. Empty unless status is succeeded."
},
"status": {
"type": "string",
"enum": [
"succeeded",
"pending",
"failed",
"not_enabled",
"insufficient_data"
],
"description": "Generation state. Branch on this rather than on an empty profile."
},
"entity_type": {
"type": "string",
"enum": [
"user"
]
},
"entity_id": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"generation_count": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "Unsupported entity type."
},
"404": {
"description": "No such entity in this project."
}
}
}
},
"/v2/profiles/settings/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_settings_read",
"summary": "Get profile settings",
"description": "Return the profile settings for the project the API key is scoped to.",
"responses": {
"200": {
"description": "Current settings.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Per-entity-type settings. Only `user` is available in this release.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
},
"agent": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
}
}
},
"capabilities": {
"type": "object",
"properties": {
"jobs": {
"type": "boolean"
},
"estimates": {
"type": "boolean"
},
"samples": {
"type": "boolean"
},
"full_rebuild": {
"type": "boolean",
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
}
}
}
}
}
}
}
}
}
},
"post": {
"tags": [
"profiles"
],
"operationId": "profiles_settings_update",
"summary": "Update profile settings",
"description": "Update the project's profile settings. Only the fields present in the body are written, so one setting can change without re-sending the others.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Per-entity-type settings. Only `user` is available in this release.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step. Send null to clear it."
}
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Settings as stored after the update.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Per-entity-type settings. Only `user` is available in this release.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
},
"agent": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
}
}
},
"capabilities": {
"type": "object",
"properties": {
"jobs": {
"type": "boolean"
},
"estimates": {
"type": "boolean"
},
"samples": {
"type": "boolean"
},
"full_rebuild": {
"type": "boolean",
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
}
}
}
}
}
}
}
},
"400": {
"description": "The schema is not a valid profile schema."
}
}
}
},
"/v2/profiles/jobs/": {
"post": {
"tags": [
"profiles"
],
"operationId": "profiles_create_job",
"summary": "Generate profiles",
"description": "Start one generation. `operation` says what to build:\n\n- `sample` — up to 10 real entities, so a schema can be judged before it is used widely. These are real profiles: they are saved to those entities and count toward usage.\n- `trigger` — one entity, named by `entity_id`.\n\nSend an `Idempotency-Key` header. Replaying the same key returns the same job instead of charging twice. Poll `status_url` from the response until the status is terminal.",
"parameters": [
{
"in": "header",
"name": "Idempotency-Key",
"required": true,
"schema": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"description": "Makes a retry safe: the same key returns the same job."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"operation",
"entity_type"
],
"properties": {
"operation": {
"type": "string",
"enum": [
"sample",
"trigger"
],
"description": "What to generate. Optional only when `entity_id` is set, which means `trigger`."
},
"entity_type": {
"type": "string",
"enum": [
"user"
]
},
"entity_id": {
"type": "string",
"description": "One entity, for `trigger`."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"description": "How many entities to sample."
}
}
}
}
}
},
"responses": {
"202": {
"description": "Job accepted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"job_id": {
"type": "string"
},
"status": {
"type": "string"
},
"status_url": {
"type": "string",
"description": "Poll this. Building the path yourself breaks on a route change."
},
"operation": {
"type": "string"
},
"entity_type": {
"type": "string"
},
"entity_count_reserved": {
"type": "integer",
"description": "Entities reserved against usage for this job."
},
"event_id": {
"type": "string",
"nullable": true
},
"replayed": {
"type": "boolean",
"description": "True when an Idempotency-Key returned an existing job."
},
"sampled": {
"type": "integer",
"description": "`sample` only."
},
"entity_ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "`sample` only: the entity ids picked. Read each with `GET /v2/entities/user/{entity_id}/profile/`."
}
}
}
}
}
},
"400": {
"description": "Unknown or missing `operation`, or profiles are not configured."
},
"402": {
"description": "Payment required."
},
"409": {
"description": "A job is already running, or the Idempotency-Key was used for a different request. Branch on `error.code`."
},
"429": {
"description": "Cooldown. `retry_after_seconds` sits inside `error`."
},
"503": {
"description": "`jobs_unavailable` — generation is switched off for this project."
}
}
}
},
"/v2/profiles/jobs/{job_id}/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_get_job",
"summary": "Read a generation job",
"description": "The job nests under `job`. `total` is null until `enumeration_complete`, and `completed` is `succeeded + failed + skipped`.",
"parameters": [
{
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The job.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"job": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"operation": {
"type": "string"
},
"entity_type": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"QUEUED",
"RUNNING",
"SUCCEEDED",
"PARTIALLY_SUCCEEDED",
"FAILED",
"CANCELLED"
]
},
"total": {
"type": "integer",
"nullable": true
},
"enumeration_complete": {
"type": "boolean"
},
"completed": {
"type": "integer"
},
"succeeded": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"skipped": {
"type": "integer"
}
}
}
}
}
}
}
},
"404": {
"description": "No such job in this project."
}
}
}
}
},
"components": {
@@ -9494,4 +8988,4 @@
}
},
"x-original-swagger-version": "2.0"
}
}
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: CLI
seo:
title: "Mem0 CLI for Terminal Memory Management"
title: "CLI for Terminal Memory Management"
sidebarTitle: "CLI"
description: "Manage memories from your terminal, for both humans and AI agents."
icon: "terminal"
iconType: "solid"
@@ -1,7 +1,6 @@
---
title: Custom Instructions
seo:
title: "Platform Custom Instructions - Mem0"
title: "Platform Custom Instructions"
sidebarTitle: "Custom Instructions"
description: 'Control how Mem0 extracts and stores memories using natural language guidelines'
---
@@ -1,7 +1,6 @@
---
title: Multimodal Support
seo:
title: "Platform Multimodal Support - Mem0"
title: "Platform Multimodal Support"
sidebarTitle: "Multimodal Support"
description: Integrate images and documents into your interactions with Mem0
---
-359
View File
@@ -1,359 +0,0 @@
---
title: Profiles
description: "Build a structured, always-current summary of each user from their memories, shaped by a JSON Schema you define."
---
# Profiles
Memories are individual facts. A profile is the summary of all of them for one entity: a single structured object, shaped by a JSON Schema you define, that Mem0 keeps current as new memories arrive.
Search answers "what did this user say about X". A profile answers "who is this user", in one read, with no query to write.
<Info>
**Use profiles when…**
- You want to personalize a first response, before the user says anything in this session.
- You need a compact object to drop into a prompt instead of a list of memories.
- You want the same fields for every user, so your code can rely on their shape.
</Info>
<Note>
User Profiles are in **beta** and available on request. To enable them for your
organization, contact [support@mem0.ai](mailto:support@mem0.ai).
</Note>
## How it works
1. You define a **schema**: the fields a profile should contain, each with a description.
2. Mem0 builds each entity's profile from their memories, and rebuilds it as new memories arrive.
3. You read the profile whenever you need it.
Generation is **asynchronous**. A profile is not ready the instant an entity's first memory lands, so a read tells you where it is with a `status` rather than failing.
## Define the schema
The schema is JSON Schema. Every property needs a `description` — that is what tells the model how to fill the field, so a vague description gives a vague profile.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient()
client.update_profile_settings(
enabled=True,
schema={
"type": "object",
"properties": {
"communication_style": {
"type": "string",
"description": "How the user prefers to be addressed: terse, detailed, formal, casual",
},
"expertise_areas": {
"type": "array",
"items": {"type": "string"},
"description": "Subjects the user demonstrates working knowledge of",
},
"current_goals": {
"type": "array",
"items": {"type": "string"},
"description": "What the user is actively trying to accomplish",
},
},
},
custom_instructions="Prefer durable traits over one-off remarks.",
)
```
```typescript TypeScript
import MemoryClient from "mem0ai";
const client = new MemoryClient({ apiKey: "your-api-key" });
await client.updateProfileSettings({
enabled: true,
schema: {
type: "object",
properties: {
communication_style: {
type: "string",
description:
"How the user prefers to be addressed: terse, detailed, formal, casual",
},
expertise_areas: {
type: "array",
items: { type: "string" },
description: "Subjects the user demonstrates working knowledge of",
},
current_goals: {
type: "array",
items: { type: "string" },
description: "What the user is actively trying to accomplish",
},
},
},
customInstructions: "Prefer durable traits over one-off remarks.",
});
```
</CodeGroup>
<Note>
Your schema's property names reach the API exactly as you write them. The SDKs do not rewrite them, so a profile always comes back with the field names you chose.
</Note>
Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone.
## Read a profile
<CodeGroup>
```python Python
result = client.get_profile("alice")
if result["status"] == "succeeded":
print(result["profile"])
else:
print("not ready:", result["status"])
```
```typescript TypeScript
const result = await client.getProfile({ entityId: "alice" });
if (result.status === "succeeded") {
console.log(result.profile);
} else {
console.log("not ready:", result.status);
}
```
</CodeGroup>
A response looks like this:
```json
{
"profile": {
"communication_style": "terse",
"expertise_areas": ["distributed systems", "postgres"],
"current_goals": ["cut p99 latency", "migrate off the legacy queue"]
},
"status": "succeeded",
"entity_type": "user",
"entity_id": "alice",
"updated_at": "2026-02-08T10:30:00Z",
"generation_count": 3
}
```
`generation_count` is how many times this profile has been (re)generated — `0` before the first generation completes.
### Always branch on `status`
`profile` is empty unless `status` is `succeeded`. Check the status rather than the emptiness of the object, so a profile that is merely still building is not mistaken for a user you know nothing about.
| `status` | Meaning | What to do |
|---|---|---|
| `succeeded` | Profile is built and current | Use it |
| `pending` | Generation is queued or running | Read again shortly |
| `insufficient_data` | Not enough memories to say anything yet | Fall back to defaults |
| `not_enabled` | Profiles are off for this project | Enable them in settings |
| `failed` | The last generation did not complete | Retry, or trigger a new one |
A `404` means only that no such entity exists in your project.
## Generate a profile on demand
Profiles are built once an entity has accumulated enough messages, so a brand-new user has none during their first few interactions. Trigger one directly to close that gap:
<CodeGroup>
```python Python
client.generate_profile("alice")
```
```typescript TypeScript
await client.generateProfile({ entityId: "alice" });
```
</CodeGroup>
The call returns as soon as the work is queued. Poll the read endpoint and branch on `status`.
## Test a schema before applying it
A schema that reads well can still produce disappointing profiles. Sample a few real entities and inspect the output before committing to it.
Sampling is asynchronous: the call returns a job as soon as it is queued. Poll `status_url` until the job is terminal, then read each sampled entity's profile:
<CodeGroup>
```python Python
import time
job = client.sample_profiles(limit=5)
# Poll until the sample job reaches a terminal state (job status is UPPERCASE).
TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"}
deadline = time.time() + 120
while True:
status = client.get_profile_job(job["status_url"])["job"]
if status["status"] in TERMINAL:
break
if time.time() > deadline:
raise TimeoutError("Sample job did not finish in time")
time.sleep(3)
print(status["status"], status["succeeded"], "of", status["total"])
# The create response lists the sampled entities; read each one's saved profile.
for entity_id in job.get("entity_ids", []):
print(client.get_profile(entity_id))
```
```typescript TypeScript
const job = await client.sampleProfiles({ limit: 5 });
// Poll until the sample job reaches a terminal state (job status is UPPERCASE).
const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"];
const deadline = Date.now() + 120_000;
let status;
while (true) {
status = (await client.getProfileJob(job.statusUrl)).job;
if (TERMINAL.includes(status.status)) break;
if (Date.now() > deadline)
throw new Error("Sample job did not finish in time");
await new Promise((resolve) => setTimeout(resolve, 3000));
}
console.log(status.status, status.succeeded, "of", status.total);
// The create response lists the sampled entities; read each one's saved profile.
for (const entityId of job.entityIds ?? []) {
console.log(await client.getProfile({ entityId }));
}
```
</CodeGroup>
These are real generations. The profiles are saved to those entities and count toward your usage, so sampling is not wasted work and not a free dry run. A sample covers up to 10 entities and cannot be repeated immediately.
## Apply a new schema to existing entities
A new schema shapes the next generation. Profiles that already exist keep their values until their entity is generated again.
Each entity picks the new schema up as it sends more memories, and you can generate one now with `generate_profile`.
<Note>
Rebuilding every profile in a project at once is not available yet. Refresh profiles one entity at a time with `generate_profile`, or let each one update on its own as its entity sends more memories.
</Note>
## When profiles update
You never call an "update profile" endpoint — Mem0 keeps each profile current for you. Two things drive it:
- **Automatically, as memories accumulate.** Mem0 refreshes an entity's profile after roughly every **10 messages** it receives, folding the new memories into the existing profile. There is no schedule to wait for and no extra call to make: the same `add` you already do keeps the profile moving.
- **On demand.** Call `generate_profile` to build or refresh a profile immediately — useful for a brand-new entity that has not yet crossed the automatic threshold.
Generation is **asynchronous and incremental**. A refresh runs in the background a short while after its trigger, so a read taken immediately after an `add` may still show the previous profile (or `pending`). Branch on `status` rather than assuming the latest memory is already reflected.
<Note>
Updates are **incremental**, not a full rebuild each time — Mem0 merges what it newly learns into the stored profile and keeps the fields your schema still defines. After a schema change, existing profiles pick it up as their entities send more memories, or when you call `generate_profile` — see [Apply a new schema to existing entities](#apply-a-new-schema-to-existing-entities).
</Note>
## Use a profile in a prompt
The point of the structure is that it drops straight into a prompt:
```python
result = client.get_profile(user_id)
if result["status"] == "succeeded":
profile = result["profile"]
system_prompt = f"""You are helping {user_id}.
Communication style: {profile.get("communication_style", "unknown")}
Areas of expertise: {", ".join(profile.get("expertise_areas", []))}
Current goals: {", ".join(profile.get("current_goals", []))}
Match their style and do not explain what they already know."""
else:
system_prompt = "You are a helpful assistant."
```
## Writing a schema that works
- **Describe every field.** The description is the instruction; without it the model guesses.
- **Prefer durable traits.** "Prefers dark mode" ages well; "is annoyed today" does not.
- **Keep it small.** Ten focused fields beat forty speculative ones, and cost less to generate.
- **Say what the field is not.** A description that rules out the near-miss interpretation is worth more than one that only states the obvious.
- **Sample before you commit.** It is the only way to see what your descriptions actually produce.
<Note>
A schema has a size budget of roughly **10,000 tokens** of serialized JSON — the whole schema is sent to the model on every generation, so a handful of verbose fields can cost more than many terse ones. Oversized schemas are rejected on save.
</Note>
## Availability
The feature is in beta and enabled per organization on request — see the note at the top of this page.
Once it is on, an entity gets a profile when two more things hold:
- profiles are **enabled** with a schema for the project (see [Define the schema](#define-the-schema)), and
- the memory is scoped to an entity — a `user_id`.
On a project where profiles are turned off, a read returns `status: not_enabled` rather than an error, so you can call it unconditionally and branch on the status.
## Settings reference
| Argument | Type | Description |
|---|---|---|
| `enabled` | boolean | Whether profile generation runs for the project |
| `schema` | object | JSON Schema describing the profile. Every property needs a `description` |
| `custom_instructions` | string | Extra guidance applied during extraction |
`enabled` is project-wide. `schema` and `custom_instructions` apply to user
profiles, so the stored settings nest them under `entities`:
```json
{
"enabled": true,
"entities": {
"user": {
"schema": { "type": "object", "properties": { "...": {} } },
"custom_instructions": "Prefer durable traits over one-off remarks."
}
},
"capabilities": { "full_rebuild": false }
}
```
That is what a read returns and what a write accepts. The SDKs take the fields
flat and nest them for you, so a schema you write with
`update_profile_settings` comes back unchanged from `get_profile_settings`.
<Note>
Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
</Note>
## FAQ
**Do I need to change my `add` or `search` calls to use profiles?**
No. Profiles are built from the memories you already add. You define a schema once and read the profile when you need it — your ingestion and retrieval code is unchanged.
**Why is `profile` empty even though the entity has memories?**
Generation is asynchronous and needs enough to work with. Branch on `status`: `pending` means it is still building, and `insufficient_data` means there are not yet enough memories to fill the schema. Read again shortly, or call `generate_profile` to build one now.
**Is sampling free?**
No. `sample_profiles` runs real generations against real memories and **keeps** the profiles it produces, so it counts toward your usage like any other generation. It exists to check a schema on a few entities before you commit to it — not as a zero-cost dry run.
**Does changing the schema rewrite existing profiles?**
No. A schema change applies to the next generation. An existing profile keeps its values until its entity is generated again, which happens as that entity sends more memories, or when you call `generate_profile` for it.
**What happens to a field I remove from the schema?**
It stops being maintained. On an entity's next generation, fields your schema no longer defines are pruned from the stored profile — so keep a field in the schema for as long as you want its value kept.
**How current is a profile?**
It refreshes automatically as memories accumulate (about every 10 messages for an entity), plus any on-demand `generate_profile` calls. Because refreshes run in the background, expect a short delay after the triggering `add` rather than an instant update.
## Related
<CardGroup cols={2}>
<Card title="Entity-Scoped Memory" icon="users" href="/platform/features/entity-scoped-memory">
How users, agents, apps and runs partition memories.
</Card>
<Card title="Custom Instructions" icon="pen" href="/platform/features/custom-instructions">
Steer what Mem0 extracts in the first place.
</Card>
</CardGroup>
+2 -3
View File
@@ -1,7 +1,6 @@
---
title: "Overview"
seo:
title: "Mem0 Platform Overview"
title: "Platform Overview"
sidebarTitle: "Overview"
description: "Managed memory layer for AI agents, production-ready in minutes"
icon: "cloud"
---
-743
View File
@@ -1,743 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# User Profiles — live demo\n",
"\n",
"A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n",
"user's memories, shaped by a JSON Schema you supply.\n",
"\n",
"Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n",
"user\"*, in one read, with no query to write — and it is available on the first turn of a\n",
"session, before the user has said anything.\n",
"\n",
"**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n",
"generated from them, add 6 more turns that contradict the first set, and watch the\n",
"profile rewrite itself. Then it shows every way the API says no.\n",
"\n",
"**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n",
"\n",
"> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n",
"> production. The cells below read both from the environment.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# This notebook drives the SDK from this worktree, not the published mem0ai:\n",
"# the profile fixes below are not released yet.\n",
"%pip install -q -e ../..\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Define the schema\n",
"\n",
"The schema is handed to the model as a **tool definition**, and each field's\n",
"`description` is the only instruction the model gets about what belongs there. An\n",
"undescribed field is a field the model guesses at.\n",
"\n",
"Rules worth knowing:\n",
"\n",
"- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n",
" it would bill you to extract nothing\n",
"- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n",
" they name the storage envelope, so a schema using them could not be read back\n",
" unambiguously\n",
"- keep it small. The whole schema is sent to the model on every generation\n",
"\n",
"Descriptions are **not** enforced on write in this build — a property without one is\n",
"accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n",
"Treat descriptions as your job, not the validator's.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"SCHEMA = {\n",
" \"type\": \"object\",\n",
" \"properties\": {\n",
" \"occupation\": {\n",
" \"type\": \"string\",\n",
" \"description\": \"The person's current job title, in one short phrase.\",\n",
" },\n",
" \"location\": {\n",
" \"type\": \"string\",\n",
" \"description\": \"The city or region the person currently lives in.\",\n",
" },\n",
" \"interests\": {\n",
" \"type\": \"array\",\n",
" \"items\": {\"type\": \"string\"},\n",
" \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n",
" },\n",
" \"dietary_restrictions\": {\n",
" \"type\": \"array\",\n",
" \"items\": {\"type\": \"string\"},\n",
" \"description\": \"Foods the person avoids, and why, if they said.\",\n",
" },\n",
" \"communication_style\": {\n",
" \"type\": \"string\",\n",
" \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n",
" \"description\": \"How this person prefers to be answered.\",\n",
" },\n",
" \"expertise_level\": {\n",
" \"type\": \"string\",\n",
" \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n",
" \"description\": \"Their technical depth, judged from how they discuss their work.\",\n",
" },\n",
" },\n",
"}\n",
"\n",
"settings = client.update_profile_settings(\n",
" enabled=True,\n",
" schema=SCHEMA,\n",
" custom_instructions=(\n",
" \"Prefer facts the person stated outright over anything inferred. \"\n",
" \"Leave a field empty rather than guessing.\"\n",
" ),\n",
")\n",
"\n",
"# Sorted, because JSONB storage does not preserve the key order you sent.\n",
"# Compare a stored schema by SET, never by string or by key order.\n",
"stored = settings[\"entities\"][\"user\"][\"schema\"]\n",
"print(\"schema fields:\", sorted(stored[\"properties\"]))\n",
"print(\"enabled :\", settings[\"enabled\"])\n",
"print(\"capabilities :\", settings[\"capabilities\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"`enabled` is project-wide; `schema` and `custom_instructions` apply to user profiles\n",
"and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n",
"you write comes back unchanged from `get_profile_settings()`.\n",
"\n",
"Only the arguments you pass are written. To turn the feature off without touching your\n",
"schema, send `enabled` alone.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. The turns\n",
"\n",
"Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n",
"side effect of the normal pipeline.\n",
"\n",
"Twelve, not five, because generation fires when an entity crosses a **10-message\n",
"boundary**. Below that it waits for a flush window measured in hours, and this notebook\n",
"would sit there.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"TURNS = [\n",
" (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n",
" (\"assistant\", \"Congratulations! What's the new role?\"),\n",
" (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n",
" (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n",
" (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n",
" (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n",
" (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n",
" (\"assistant\", \"Understood — short answers from here.\"),\n",
" (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n",
" (\"assistant\", \"Bouldering or ropes?\"),\n",
" (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n",
" (\"assistant\", \"Noted, vegetarian only.\"),\n",
"]\n",
"\n",
"response = client.add(\n",
" [{\"role\": r, \"content\": c} for r, c in TURNS],\n",
" user_id=USER_ID,\n",
")\n",
"print(json.dumps(response, indent=2)[:300])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n",
"`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n",
"you know the add finished. Then let the extracted memories settle.\n",
"\n",
"Under load this can take a minute or more, so the cell says plainly whether it ran out of\n",
"time rather than printing `0 memories` as though that were the answer.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"event_id = response[\"event_id\"]\n",
"deadline = time.time() + 300\n",
"\n",
"# 1. The add itself. Terminal status, not a sleep.\n",
"event_status = None\n",
"while time.time() < deadline:\n",
" event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n",
" if event_status in (\"SUCCEEDED\", \"FAILED\"):\n",
" break\n",
" print(f\" add {event_status}\")\n",
" time.sleep(5)\n",
"print(f\"add finished: {event_status}\")\n",
"assert event_status != \"FAILED\", \"the add itself failed — nothing downstream can work\"\n",
"\n",
"# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n",
"# Wait for the count to stop growing instead of breaking on the first result.\n",
"memories, stable = [], 0\n",
"while time.time() < deadline:\n",
" page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n",
" found = page.get(\"results\", []) if isinstance(page, dict) else page\n",
" stable = stable + 1 if found and len(found) == len(memories) else 0\n",
" memories = found\n",
" if stable >= 2: # two identical polls in a row\n",
" break\n",
" print(f\" ... {len(memories)} so far\")\n",
" time.sleep(5)\n",
"\n",
"if memories:\n",
" print(f\"\\n{len(memories)} memories extracted:\\n\")\n",
" for m in memories:\n",
" print(\" \\u2022\", m.get(\"memory\"))\n",
"else:\n",
" # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n",
" # and makes the profile below look like it came from nothing.\n",
" print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n",
" print(\"worker is down. Everything below will report insufficient_data.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Read the profile\n",
"\n",
"Crossing the 10-message boundary should already have queued a generation. Read first —\n",
"and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n",
"distinction is the whole point of the envelope.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"envelope = client.get_profile(USER_ID)\n",
"print(json.dumps(envelope, indent=2))\n",
"\n",
"print(\"\\nstatus vocabulary:\")\n",
"print(\" succeeded terminal — a generation ran AND the profile has content\")\n",
"print(\" pending queued or running\")\n",
"print(\" failed terminal — the last generation did not complete\")\n",
"print(\" not_enabled feature off, or plan below Pro\")\n",
"print(\" insufficient_data no content to show: no row yet, queued, or a\")\n",
"print(\" generation that legitimately found nothing\")\n",
"print()\n",
"print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n",
"print(\"empty extraction still increments the counter, so counting generations\")\n",
"print(\"reports 'done' for a profile with nothing in it.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Force it, rather than waiting\n",
"\n",
"`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n",
"until their tenth message. One entity, a few seconds.\n",
"\n",
"Every call is sent with a fresh `Idempotency-Key`, so a retry after a dropped connection\n",
"returns the same job instead of paying twice.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n",
"\n",
"\n",
"def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n",
" \"\"\"Poll until terminal.\n",
"\n",
" `since` waits for a generation_count ABOVE that value, which is how you wait\n",
" for an UPDATE rather than accepting the profile you already had.\n",
"\n",
" `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n",
" poll through it and give up on the timeout instead.\n",
" \"\"\"\n",
" deadline = time.time() + timeout\n",
" body = None\n",
" while time.time() < deadline:\n",
" body = client.get_profile(entity_id)\n",
" status = (body.get(\"status\") or \"\").lower()\n",
" count = body.get(\"generation_count\") or 0\n",
" fresh = count > since if since is not None else True\n",
" if status == \"succeeded\" and fresh:\n",
" return body\n",
" if status in (\"failed\", \"not_enabled\"):\n",
" raise RuntimeError(f\"generation stopped: {status}\")\n",
" print(f\" ... {status} (generation_count={count})\")\n",
" time.sleep(interval)\n",
" raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n",
"\n",
"\n",
"print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
"print(\"\\npolling...\")\n",
"\n",
"try:\n",
" body = wait_for_profile(USER_ID)\n",
" print(\"\\n=== PROFILE ===\")\n",
" print(json.dumps(body[\"profile\"], indent=2))\n",
" print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n",
"except TimeoutError as e:\n",
" # Say so plainly and let the rest of the notebook skip, rather than raising\n",
" # a NameError in every cell below and burying the real cause.\n",
" body = None\n",
" print(f\"\\nNO PROFILE: {e}\")\n",
" print(\"Generation never finished. Usually the ingestion worker is down, or\")\n",
" print(\"this project has no memories for the user yet.\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# The model must not invent fields outside your schema — the forced tool call is\n",
"# what makes that structural rather than a request.\n",
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n",
" print(\"fields outside the schema:\", extra or \"none\")\n",
"\n",
" # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n",
" # so 'I found nothing' arrives as a type default: 0, \"\", [].\n",
" filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n",
" print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Now watch it update\n",
"\n",
"Six more turns that contradict and extend what we already know: a promotion, a move, a\n",
"dropped hobby. A profile is a living document, not an append-only log — the model gets the\n",
"memories and rewrites the whole thing.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" before = body[\"generation_count\"]\n",
"\n",
" MORE_TURNS = [\n",
" (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n",
" (\"assistant\", \"Congratulations. Same team?\"),\n",
" (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n",
" (\"assistant\", \"Big move. How do you feel about it?\"),\n",
" (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n",
" (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n",
" ]\n",
"\n",
" followup = client.add(\n",
" [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n",
" user_id=USER_ID,\n",
" )\n",
"\n",
" # Wait for the add to land before triggering: a generation queued before the new\n",
" # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n",
" deadline = time.time() + 300\n",
" while time.time() < deadline:\n",
" status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n",
" if status in (\"SUCCEEDED\", \"FAILED\"):\n",
" break\n",
" time.sleep(5)\n",
" print(\"follow-up add:\", status)\n",
" time.sleep(15) # let extraction settle\n",
"\n",
" print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
" print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n",
" updated = wait_for_profile(USER_ID, since=before)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" print(f\"{'field':<22} {'before':<34} after\")\n",
" print(\"-\" * 92)\n",
" for field in SCHEMA[\"properties\"]:\n",
" b = json.dumps(body[\"profile\"].get(field))\n",
" a = json.dumps(updated[\"profile\"].get(field))\n",
" mark = \" \" if a == b else \"->\"\n",
" print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Use it in a prompt\n",
"\n",
"The point of the structure is that it drops straight into a prompt — no list of memories\n",
"to summarize, no query to write.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def build_system_prompt(entity_id):\n",
" result = client.get_profile(entity_id)\n",
" if result[\"status\"] != \"succeeded\":\n",
" # Branch on status, never on an empty profile: a user whose profile is\n",
" # still building is not a user you know nothing about.\n",
" return \"You are a helpful assistant.\"\n",
"\n",
" p = result[\"profile\"]\n",
" return f\"\"\"You are helping {entity_id}.\n",
"Occupation: {p.get(\"occupation\", \"unknown\")}\n",
"Location: {p.get(\"location\", \"unknown\")}\n",
"Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n",
"Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n",
"Preferred style: {p.get(\"communication_style\", \"unknown\")}\n",
"\n",
"Match their style and do not explain what they already know.\"\"\"\n",
"\n",
"\n",
"print(build_system_prompt(USER_ID))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Judge a schema before committing to it\n",
"\n",
"`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n",
"\n",
"These are real generations and the results are **kept** — a dry run would cost exactly the\n",
"same and leave those users no better off. It is not a free preview.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 202, not 200: the sample generations are queued, not finished.\n",
"#\n",
"# A 409 `already_running` means a sample from an earlier run is still going.\n",
"# That is the cooldown working, not an error — reuse that job rather than\n",
"# failing the notebook.\n",
"try:\n",
" job = client.sample_profiles(limit=3)\n",
" print(json.dumps(job, indent=2)[:400])\n",
" print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n",
"except Exception as e:\n",
" detail = str(e)\n",
" print(\"sample refused:\", detail[:200])\n",
" running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n",
" job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n",
" print(\"reusing the running job:\", running)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n",
"so format it defensively rather than assuming a number.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Apply a new schema to existing users\n",
"\n",
"A new schema shapes the **next** generation. Profiles that already exist keep their values\n",
"until their user is generated again — which happens as that user sends more memories, or\n",
"when you call `generate_profile()` for them.\n",
"\n",
"A field you **remove** stops being maintained: on the next generation, fields your schema\n",
"no longer defines are pruned. Keep a field for as long as you want its value kept."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"# Failure scenarios\n",
"\n",
"Everything above is the path that works. These are the ways it says no, and what each one\n",
"means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n",
"\n",
"> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n",
"> ERROR level before raising, so they appear even for the failures these cells\n",
"> deliberately catch. Read the line printed *after* each one — that is the cell's\n",
"> own verdict. Nothing here is unhandled.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F1. Schemas that get rejected\n",
"\n",
"Rejections happen on **write**, where you can see and fix them — not silently at\n",
"generation time, where you would only notice as an empty profile weeks later.\n",
"\n",
"The last case matters for storage: the user schema lives in one JSONB column alongside\n",
"the envelope that separates it, so a schema using the envelope's own reserved keys could\n",
"not be read back unambiguously. It is refused rather than stored.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"BAD_SCHEMAS = [\n",
" ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n",
" ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n",
" (\n",
" {\n",
" \"type\": \"object\",\n",
" \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n",
" # At the schema ROOT, which is where the envelope's own keys live.\n",
" \"_profile_config_version\": 1,\n",
" \"entities\": {\"user\": {}},\n",
" },\n",
" \"reserved settings keys at the schema root\",\n",
" ),\n",
"]\n",
"\n",
"for bad, why in BAD_SCHEMAS:\n",
" try:\n",
" client.update_profile_settings(schema=bad)\n",
" print(f\"ACCEPTED (unexpected): {why}\")\n",
" except Exception as e:\n",
" print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n",
"\n",
"# NOT rejected: a property with no description. The validator allows it and the\n",
"# model then has nothing to go on, so the field comes back empty. Descriptions are\n",
"# your job, not the validator's.\n",
"try:\n",
" client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n",
" print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n",
"finally:\n",
" client.update_profile_settings(schema=SCHEMA) # put the good one back\n",
"\n",
"restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n",
"assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n",
"print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F2. A user that does not exist\n",
"\n",
"404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n",
"`insufficient_data`, so an ordinary empty state never looks like an error.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from mem0.exceptions import MemoryNotFoundError\n",
"\n",
"try:\n",
" client.get_profile(\"user_who_never_existed\")\n",
" print(\"ACCEPTED (unexpected)\")\n",
"except MemoryNotFoundError as e:\n",
" print(\"404 as intended:\", str(e)[:120])\n",
"\n",
"# ...versus a real user who simply has no profile row yet.\n",
"fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n",
"client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n",
"time.sleep(5)\n",
"print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F3. Profiles turned off\n",
"\n",
"`enabled` is the one project-wide switch. Every generation path then refuses.\n",
"\n",
"Nothing is deleted. Your schema and every profile you already built are kept, so turning\n",
"it back on resumes rather than restarts.\n",
"\n",
"Note what a read does **not** do — a profile that already exists keeps reporting\n",
"`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n",
"with no profile yet. Turning the feature off stops new work; it does not hide what has\n",
"already been built.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"client.update_profile_settings(enabled=False)\n",
"\n",
"print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n",
"print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n",
"try:\n",
" client.generate_profile(USER_ID)\n",
" print(\"trigger: ACCEPTED (unexpected)\")\n",
"except Exception as e:\n",
" print(\"trigger:\", str(e)[:160])\n",
"\n",
"back = client.update_profile_settings(enabled=True) # put it back\n",
"print(\"\\nrestored:\", back[\"enabled\"])\n",
"print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Cleanup\n",
"\n",
"Removes the demo users. The profile row cascades with the entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "for entity_id in (USER_ID, fresh):\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\n\n# Restore the project's profile settings to the start-of-run snapshot, so a shared\n# env is left exactly as we found it. Passing the original values (including None)\n# clears anything this notebook set — the SDK distinguishes an explicit None from an\n# omitted argument.\n_user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\nclient.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n)\nprint(\"profile settings restored to the pre-notebook snapshot\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Cheat sheet\n",
"\n",
"| Want | Call | Cost |\n",
"| --- | --- | --- |\n",
"| configure | `update_profile_settings(...)` | free |\n",
"| read | `get_profile(user_id)` | free |\n",
"| one user now | `generate_profile(user_id)` | 1 LLM call |\n",
"| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n",
"| poll a job | `get_profile_job(status_url)` | free |\n",
"\n",
"**Settings apply to user profiles.** The stored shape is:\n",
"\n",
"```json\n",
"{\"enabled\": true,\n",
" \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n",
" \"capabilities\": {\"full_rebuild\": false}}\n",
"```\n",
"\n",
"The SDK takes these flat and nests them for you. Only the fields you pass are written;\n",
"`enabled` is the one project-wide switch.\n",
"\n",
"**Left alone, generation fires** on a 10-message boundary, or after a flush window\n",
"measured in hours. `generate_profile()` is how you skip the wait for one user.\n",
"\n",
"**Three traps:**\n",
"\n",
"1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n",
" through it and give up on a timeout instead.\n",
"2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n",
" extraction still increments the counter.\n",
"3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n",
" arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import { redactSecrets } from "./lifecycle.ts";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
@@ -74,34 +76,108 @@ export function errorKind(error: unknown): string {
return error instanceof Error ? error.constructor.name : "other";
}
// Delivery is retried in memory, not spooled to disk, and that is a decision
// rather than an omission. The Python core spools because its hooks are separate
// processes that fire per tool call and exit immediately, so nothing survives
// without a file. These plugins are loaded into a host that lives for a whole
// session, so re-queueing covers the same transient failures without the claim
// and lease machinery a correct cross-process spool needs. What that leaves
// uncovered is narrow: a session that both starts and ends with no connectivity.
const RETRY_BACKOFF_CEILING_MS = 60_000;
// Consecutive failed flushes before the queue is dropped. Deliberately NOT the
// same thing as Python's budget, which rides in the claim filename and so
// follows one batch: this counter lives in the closure and counts the outage,
// not the payload. Events captured between attempts join the same queue and go
// with it. Per-batch accounting would need an attempt count on every event, and
// the queue is already bounded, so the simpler rule is the one in force here.
// Without any bound a payload the server will never accept is retried for the
// whole session and, now that the backlog is preferred over new events, holds
// the queue against everything behind it.
const MAX_DELIVERY_ATTEMPTS = 5;
export function createTelemetry(config: TelemetryConfig) {
let queue: Record<string, unknown>[] = [];
let timer: ReturnType<typeof setInterval> | undefined;
let consecutiveFailures = 0;
let retryNotBefore = 0;
let exitFlushAttempted = false;
let flushing = false;
const flushThreshold = config.flushThreshold ?? 10;
const maxQueueSize = config.maxQueueSize ?? 100;
const deliver = config.delivery ?? (async (batch: Record<string, unknown>[]) => {
await fetch(POSTHOG_BATCH_URL, {
const response = await fetch(POSTHOG_BATCH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: AbortSignal.timeout(3_000),
});
// fetch only rejects on a network-level failure. Without this check a 500,
// a 503 or a 429 resolved normally and the batch was counted as delivered
// and dropped, which is the likelier outage than a refused connection.
// Any non-2xx is retried, matching the Python core: the backoff and the
// queue bound contain a payload that will never be accepted, because the
// re-queued batch sits at the front and is the first thing evicted.
if (!response.ok) throw new Error(`posthog responded ${response.status}`);
});
async function flush(): Promise<void> {
async function flush(force = false): Promise<void> {
// One at a time. Two overlapping flushes each detach the queue and each
// prepend their own batch back on failure, so the later batch lands in front
// of the earlier one and the truncation then drops the OLDER events first,
// inverting the priority the failure path exists to establish. A second
// caller returns immediately; the queue waits for the next flush.
if (flushing) return;
if (!queue.length) return;
// `force` skips the cooldown. beforeExit is the last chance this process
// gets, and gating it on the same backoff meant that after any failure the
// exit flush did nothing and the queue died with the process, which is the
// loss this whole mechanism exists to prevent.
if (!force && Date.now() < retryNotBefore) return;
const batch = queue;
queue = [];
flushing = true;
try {
await deliver(batch);
consecutiveFailures = 0;
retryNotBefore = 0;
} catch {
// Telemetry must never affect plugin behavior.
// Put it back. Detaching the batch and swallowing the error deleted the
// events outright, so any blip silently dropped telemetry with nothing
// recording that it had happened. Every event carries a uuid, so a retry
// that duplicates one PostHog already accepted is collapsed there.
//
consecutiveFailures += 1;
if (consecutiveFailures >= MAX_DELIVERY_ATTEMPTS) {
// Give up on the queue so a failing outage cannot hold it for the
// session. This drops whatever is queued now, which includes events
// captured during the outage, not only the batch that kept failing.
consecutiveFailures = 0;
retryNotBefore = 0;
return;
}
// Keep the FRONT on overflow, so the batch being retried survives and a
// new event is what gets dropped. Matches the Python core, where record()
// refuses new events once the spool is full rather than evicting the
// backlog. Keeping the newest would throw away exactly the events this
// retry exists to save.
queue = [...batch, ...queue].slice(0, maxQueueSize);
retryNotBefore = Date.now() + Math.min(2 ** consecutiveFailures * 1_000, RETRY_BACKOFF_CEILING_MS);
} finally {
flushing = false;
}
}
function beforeExit(): void {
void flush();
// Once, and only once. Node re-emits beforeExit whenever the handler
// schedules more async work, so an unconditional forced flush looped until
// the attempt budget was spent: five attempts against a 3s delivery timeout
// is fifteen seconds added to the shutdown of whatever editor or CLI is
// hosting this. The backoff used to end that loop after one attempt, and
// removing it for the forced path removed the only thing bounding it.
if (exitFlushAttempted) return;
exitFlushAttempted = true;
void flush(true);
}
function build(event: string, properties: Record<string, unknown> = {}): Record<string, unknown> | null {
@@ -112,6 +188,15 @@ export function createTelemetry(config: TelemetryConfig) {
return {
event: config.eventName?.(event) ?? event,
distinct_id: distinctId,
// Stamped once, at capture. This is what makes retrying safe: a batch
// re-sent after a failure carries the same ids, so PostHog collapses
// anything it already accepted instead of counting it twice.
uuid: randomUUID(),
// Capture time, not ingestion time. Events now sit through backoff and
// across a whole outage, so without this PostHog records them whenever
// delivery happened to succeed. It also matters for the uuid dedupe
// above, whose key includes the event date.
timestamp: new Date().toISOString(),
properties: {
...safeProperties(properties),
...safeProperties(config.commonProperties ?? {}),
@@ -134,8 +219,10 @@ export function createTelemetry(config: TelemetryConfig) {
try {
const payload = build(event, properties);
if (!payload) return;
// Full means drop this event, not evict the backlog. Same rule as the
// failure path above and as Python's record().
if (queue.length >= maxQueueSize) return;
queue.push(payload);
if (queue.length > maxQueueSize) queue = queue.slice(-maxQueueSize);
if (!timer) {
timer = setInterval(() => void flush(), config.flushIntervalMs ?? 5_000);
timer.unref?.();
@@ -149,6 +236,8 @@ export function createTelemetry(config: TelemetryConfig) {
function resetForTesting(): void {
queue = [];
consecutiveFailures = 0;
retryNotBefore = 0;
if (timer) clearInterval(timer);
timer = undefined;
process.off("beforeExit", beforeExit);
@@ -122,3 +122,250 @@ test("error classification does not expose messages", () => {
assert.equal(errorKind(new Error("request timeout")), "timeout");
assert.equal(errorKind(new Error("fetch failed")), "network");
});
test("a failed delivery keeps the batch instead of deleting it", async () => {
// The defect: the queue was detached before the await and the error swallowed,
// so one blip destroyed the events with nothing recording that it happened.
const attempts: Record<string, unknown>[][] = [];
let failNext = true;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000,
delivery: async (batch) => {
attempts.push(batch);
if (failNext) throw new Error("network down");
},
});
telemetry.capture("one");
telemetry.capture("two");
await telemetry.flush();
assert.equal(attempts.length, 1);
assert.equal(telemetry.queueForTesting().length, 2, "events were dropped on failure");
failNext = false;
// Backoff is in force, so wait it out the way wall time would.
await new Promise((resolve) => setTimeout(resolve, 2_100));
await telemetry.flush();
assert.equal(attempts.length, 2, "never retried");
assert.equal(telemetry.queueForTesting().length, 0);
telemetry.resetForTesting();
});
test("a retried event carries the same uuid so PostHog can collapse it", async () => {
const attempts: Record<string, unknown>[][] = [];
let failNext = true;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000,
delivery: async (batch) => {
attempts.push(batch);
if (failNext) throw new Error("network down");
},
});
telemetry.capture("once");
await telemetry.flush();
failNext = false;
await new Promise((resolve) => setTimeout(resolve, 2_100));
await telemetry.flush();
assert.equal(attempts.length, 2);
const first = attempts[0][0].uuid;
assert.ok(first, "events carry no uuid, so a retry would double count");
assert.equal(attempts[1][0].uuid, first, "retry minted a new uuid");
telemetry.resetForTesting();
});
test("repeated failures back off instead of retrying every flush", async () => {
let calls = 0;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000,
delivery: async () => { calls += 1; throw new Error("blocked"); },
});
telemetry.capture("one");
await telemetry.flush();
await telemetry.flush();
await telemetry.flush();
assert.equal(calls, 1, "a blocked host was hammered on every flush");
assert.equal(telemetry.queueForTesting().length, 1, "the event was lost while backing off");
telemetry.resetForTesting();
});
test("a full queue drops the new event and keeps the batch being retried", async () => {
// Python's record() refuses new events once the spool is full rather than
// evicting the backlog. Keeping the newest here would throw away exactly the
// events the retry exists to save.
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000, maxQueueSize: 3,
delivery: async () => { throw new Error("down"); },
});
// Fill past the cap BEFORE the flush, so the re-queue actually has to truncate.
// Capturing only two left the queue empty at re-queue time and the slice on the
// failure path never ran, which is the half that decides the direction.
telemetry.capture("a");
telemetry.capture("b");
telemetry.capture("c");
await telemetry.flush();
telemetry.capture("d");
telemetry.capture("e");
const events = telemetry.queueForTesting().map((e) => (e as any).event);
assert.equal(events.length, 3, "queue grew past maxQueueSize");
assert.deepEqual(events, ["a", "b", "c"], "the retried batch was evicted instead of the new events");
telemetry.resetForTesting();
});
test("the exit-time flush ignores the backoff", async () => {
// beforeExit is the last chance the process gets. Gating it on the same
// cooldown meant that after any failure it did nothing and the queue died.
let attempts = 0;
let failing = true;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async () => { attempts += 1; if (failing) throw new Error("down"); },
});
telemetry.capture("a");
await telemetry.flush();
assert.equal(attempts, 1);
failing = false;
await telemetry.flush();
assert.equal(attempts, 1, "the backoff should still hold for an ordinary flush");
await telemetry.flush(true);
assert.equal(attempts, 2, "the exit flush was suppressed by the backoff");
assert.equal(telemetry.queueForTesting().length, 0);
telemetry.resetForTesting();
});
test("every event carries a capture-time timestamp", async () => {
const sent: Record<string, unknown>[][] = [];
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async (batch) => { sent.push(batch); },
});
telemetry.capture("a");
const capturedAt = Date.now();
await new Promise((resolve) => setTimeout(resolve, 50));
await telemetry.flush();
const stamped = sent[0][0].timestamp as string;
assert.ok(stamped, "no timestamp, so PostHog would record delivery time");
assert.ok(Math.abs(Date.parse(stamped) - capturedAt) < 1_000, "not capture time");
telemetry.resetForTesting();
});
test("a batch the server will never accept is eventually given up on", async () => {
let attempts = 0;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async () => { attempts += 1; throw new Error("permanently bad"); },
});
telemetry.capture("doomed");
for (let i = 0; i < 8; i += 1) await telemetry.flush(true);
assert.ok(attempts <= 6, `retried ${attempts} times with no cap`);
assert.equal(telemetry.queueForTesting().length, 0, "a doomed batch held the queue forever");
telemetry.resetForTesting();
});
test("an HTTP error response is a failure, not a delivery", async () => {
// fetch only rejects on a network-level failure, so a 500 used to resolve
// normally and the batch was dropped as delivered. Exercises the real default
// delivery path rather than an injected one, which is where this hid.
const realFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
return new Response("upstream is unwell", { status: 503 });
}) as typeof fetch;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
});
try {
telemetry.capture("during.outage");
await telemetry.flush();
assert.equal(calls, 1, "never reached the network");
assert.equal(telemetry.queueForTesting().length, 1, "a 503 was counted as delivered");
} finally {
globalThis.fetch = realFetch;
telemetry.resetForTesting();
}
});
test("a 2xx is a delivery", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response("ok", { status: 200 })) as typeof fetch;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
});
try {
telemetry.capture("fine");
await telemetry.flush();
assert.equal(telemetry.queueForTesting().length, 0, "a good response did not clear the queue");
} finally {
globalThis.fetch = realFetch;
telemetry.resetForTesting();
}
});
test("the exit flush is attempted once, not until the budget is spent", async () => {
// Node re-emits beforeExit whenever the handler schedules async work, so an
// unconditional forced flush looped until MAX_DELIVERY_ATTEMPTS. Against the
// real 3s delivery timeout that is fifteen seconds added to a host's shutdown.
let attempts = 0;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async () => { attempts += 1; throw new Error("down"); },
});
telemetry.capture("a");
const handlers = process.listeners("beforeExit");
const ours = handlers[handlers.length - 1] as () => void;
ours();
ours();
ours();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(attempts, 1, `exit flush ran ${attempts} times`);
telemetry.resetForTesting();
});
test("overlapping flushes do not reorder the backlog behind newer events", async () => {
// Each flush detaches the queue and prepends its own batch back on failure, so
// two in flight at once put the LATER batch in front of the earlier one. The
// truncation then drops the older events first, inverting the priority the
// failure path exists to establish.
let release: (() => void)[] = [];
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: () => new Promise((_resolve, reject) => { release.push(() => reject(new Error("down"))); }),
});
telemetry.capture("first");
const a = telemetry.flush();
telemetry.capture("second");
const b = telemetry.flush();
release.forEach((fn) => fn());
await Promise.all([a, b]);
const events = telemetry.queueForTesting().map((e) => (e as any).event);
assert.equal(release.length, 1, "a second delivery started while one was in flight");
assert.deepEqual(events, ["first", "second"], `backlog reordered: ${events.join(",")}`);
telemetry.resetForTesting();
});
+20
View File
@@ -35,6 +35,8 @@ export interface PluginAuthConfig {
autoCapture?: boolean;
topK?: number;
anonymousTelemetryId?: string;
/** SHA-256 prefix of the API key userEmail was resolved for. */
keyFingerprint?: string;
}
// ============================================================================
@@ -135,6 +137,9 @@ export function readPluginAuth(): PluginAuthConfig {
autoCapture: cfg.autoCapture as boolean | undefined,
topK: cfg.topK as number | undefined,
anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined,
// Without this the reader silently drops it, every fingerprint comparison
// fails against undefined, and the resolved email is never used again.
keyFingerprint: cfg.keyFingerprint as string | undefined,
};
}
@@ -236,6 +241,21 @@ export function getBaseUrl(): string {
return auth.baseUrl || DEFAULT_BASE_URL;
}
/** Forget the resolved account, so the next capture re-resolves for the current key. */
export function clearResolvedAccount(): void {
const full = readFullConfig() as any;
const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
if (!cfg) return;
let changed = false;
for (const key of ["userEmail", "keyFingerprint"]) {
if (key in cfg) {
delete cfg[key];
changed = true;
}
}
if (changed) writeFullConfig(full);
}
/** Remove anonymousTelemetryId from config (after PostHog aliasing) */
export function clearAnonymousTelemetryId(): void {
const full = readFullConfig() as any;
+1
View File
@@ -148,6 +148,7 @@ const ALLOWED_KEYS = [
"mode",
"apiKey",
"anonymousTelemetryId",
"keyFingerprint",
"baseUrl",
"userId",
"userEmail",
@@ -209,6 +209,10 @@
"type": "string",
"description": "Persistent anonymous telemetry identifier"
},
"keyFingerprint": {
"type": "string",
"description": "Digest of the API key userEmail was resolved for. Set automatically; a mismatch re-resolves the account."
},
"oss": {
"type": "object",
"properties": {
+53 -11
View File
@@ -1,14 +1,20 @@
import { createHash, randomUUID } from "node:crypto";
import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts";
import { clearAnonymousTelemetryId, getBaseUrl, readPluginAuth, writePluginAuth } from "./cli/config-file.ts";
import {
clearAnonymousTelemetryId,
clearResolvedAccount,
getBaseUrl,
readPluginAuth,
writePluginAuth,
} from "./cli/config-file.ts";
declare const __OPENCLAW_PLUGIN_VERSION__: string;
export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__;
let cachedAnonymousId: string | undefined;
let aliasCheckDone = false;
let emailResolutionAttempted = false;
let resolutionAttemptedFor = "";
let currentDistinctId = "";
function enabled(): boolean {
@@ -33,10 +39,35 @@ function anonymousId(): string {
return (cachedAnonymousId = created);
}
/** SHA-256 prefix of the key an account was resolved for. */
function keyFingerprint(apiKey?: string): string {
return apiKey ? createHash("sha256").update(apiKey).digest("hex").slice(0, 16) : "";
}
function distinctId(apiKey?: string): string {
try {
const email = readPluginAuth().userEmail;
if (email) return createHash("sha256").update(email).digest("hex");
const auth = readPluginAuth();
if (auth.userEmail) {
// Only when it belongs to the key in hand. Without this check a cached
// email was used forever: switch to a different account and every event
// kept reporting under the previous one, with nothing to notice it by.
if (auth.keyFingerprint === keyFingerprint(apiKey)) {
return createHash("sha256").update(auth.userEmail).digest("hex");
}
// Only a REAL key that disagrees means the account changed. Without the
// apiKey guard the comparison is `undefined === ""` for any call that
// simply omits the key, so a capture with no context wiped a perfectly
// good account out of openclaw.json.
//
// A row with an email and NO fingerprint is the legacy shape, from an
// install predating this field. Clearing it here deleted a real account
// before anything had replaced it, and if the re-resolve then failed
// because the user was offline the email was gone from disk for good. The
// Python core refuses the same trade: verify, and keep what you have until
// the verification succeeds. resolveEmail below overwrites both fields
// when it does, so there is nothing to clear first.
if (apiKey && auth.keyFingerprint) clearResolvedAccount();
}
} catch {
// Fall through to the API key or anonymous identity.
}
@@ -66,8 +97,16 @@ function identifyAnonymous(id: string): void {
}
function resolveEmail(apiKey: string): void {
if (emailResolutionAttempted) return;
emailResolutionAttempted = true;
// Latched per key, not once per process. A single boolean meant a key changed
// mid-session was never looked up, so the fallback identity stuck until restart.
const fingerprint = keyFingerprint(apiKey);
if (resolutionAttemptedFor === fingerprint) return;
resolutionAttemptedFor = fingerprint;
const releaseLatch = () => {
// A failed lookup must not pin the fallback identity for the rest of the
// process. Released so the next capture tries again.
if (resolutionAttemptedFor === fingerprint) resolutionAttemptedFor = "";
};
fetch(`${getBaseUrl().replace(/\/+$/, "")}/v1/ping/`, {
method: "GET",
headers: { Authorization: `Token ${apiKey}`, "Content-Type": "application/json" },
@@ -76,7 +115,7 @@ function resolveEmail(apiKey: string): void {
.then((response) => response.json())
.then((data: any) => {
if (!data?.user_email) return;
writePluginAuth({ userEmail: data.user_email });
writePluginAuth({ userEmail: data.user_email, keyFingerprint: fingerprint });
const oldId = createHash("sha256").update(apiKey).digest("hex");
const newId = createHash("sha256").update(data.user_email).digest("hex");
for (const event of telemetry.queueForTesting()) {
@@ -84,7 +123,8 @@ function resolveEmail(apiKey: string): void {
}
})
.catch(() => {
// The API-key hash remains a stable fallback.
// The API-key hash remains a stable fallback, and the next capture retries.
releaseLatch();
});
}
@@ -96,13 +136,15 @@ export function captureEvent(
if (!enabled()) return;
try {
currentDistinctId = distinctId(context?.apiKey);
let hasEmail = false;
let resolvedForThisKey = false;
try {
hasEmail = Boolean(readPluginAuth().userEmail);
const auth = readPluginAuth();
resolvedForThisKey =
Boolean(auth.userEmail) && auth.keyFingerprint === keyFingerprint(context?.apiKey);
} catch {
// Resolve it below when possible.
}
if (context?.apiKey && !hasEmail) resolveEmail(context.apiKey);
if (context?.apiKey && !resolvedForThisKey) resolveEmail(context.apiKey);
identifyAnonymous(currentDistinctId);
telemetry.capture(eventName, {
mode: context?.mode,
@@ -237,3 +237,43 @@ describe("getBaseUrl", () => {
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
});
});
// ---------------------------------------------------------------------------
// keyFingerprint round trip
// ---------------------------------------------------------------------------
describe("keyFingerprint survives a write and read", () => {
it("readPluginAuth returns a persisted keyFingerprint", () => {
// It did not. readPluginAuth builds its result field by field, and this one
// was missing, so every fingerprint comparison ran against undefined, the
// resolved email was never used again, and telemetry silently fell back to
// the API key hash. The telemetry tests could not see it because they mock
// this module and their mock returned the field the real reader dropped.
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
config: {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
},
},
},
},
});
expect(readPluginAuth().keyFingerprint).toBe("0123456789abcdef");
});
it("writePluginAuth persists it where readPluginAuth looks", () => {
setConfigFile({ plugins: { entries: { "openclaw-mem0": { config: {} } } } });
writePluginAuth({ userEmail: "person@example.com", keyFingerprint: "abc123" });
const written = JSON.parse(mockWriteText.mock.calls.at(-1)![1] as string);
const cfg = written.plugins.entries["openclaw-mem0"].config;
expect(cfg.keyFingerprint).toBe("abc123");
expect(cfg.userEmail).toBe("person@example.com");
});
});
@@ -485,3 +485,18 @@ describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
expect(cfg.needsSetup).toBe(false);
});
});
describe("telemetry fingerprint round trip", () => {
it("a config carrying keyFingerprint is accepted by the real schema", () => {
// What writePluginAuth persists after a successful lookup. It was not in
// ALLOWED_KEYS, and assertAllowedKeys throws, so the first successful
// resolve wrote a config that broke every subsequent load of the plugin.
const persisted = {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
};
expect(() => mem0ConfigSchema.parse(persisted)).not.toThrow();
});
});
+76 -4
View File
@@ -3,15 +3,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock config-file before importing telemetry
vi.mock("../cli/config-file.ts", () => ({
readPluginAuth: vi.fn().mockReturnValue({}),
writePluginAuth: vi.fn(),
clearAnonymousTelemetryId: vi.fn(),
clearResolvedAccount: vi.fn(),
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
}));
import { captureEvent } from "../telemetry.ts";
import { readPluginAuth } from "../cli/config-file.ts";
import { clearResolvedAccount, readPluginAuth } from "../cli/config-file.ts";
/** sha256(key).slice(0, 16), the shape telemetry.ts stores. */
async function fingerprintOf(apiKey: string): Promise<string> {
const { createHash } = await import("node:crypto");
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
}
describe("telemetry", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Call history has to be cleared per test, not just restored: the mocks are
// module-level vi.fn()s, so without this one test's calls are visible to the
// next and assertions on "was not called" pass or fail by ordering.
vi.clearAllMocks();
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
// Reset telemetry enabled state
(globalThis as any).__mem0_telemetry_override = undefined;
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
@@ -40,11 +55,31 @@ describe("telemetry", () => {
expect(() => captureEvent("test_event")).not.toThrow();
});
it("uses userEmail as distinct ID when available", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
it("uses userEmail as distinct ID when it belongs to the current key", async () => {
// Previously asserted only not.toThrow(), which passed whatever the identity
// turned out to be, and under the fingerprint gate the no-context path does
// not use the email at all. Pin the real condition instead.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "test@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
expect(() => captureEvent("test_event")).not.toThrow();
captureEvent("test_event", {}, { apiKey: "key-a" });
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("a capture with no apiKey leaves a resolved account alone", async () => {
// `undefined === ""` made every keyless capture look like a key change, so
// one context-free call wiped a good account out of openclaw.json.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "test@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event");
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("falls back to a generated anonymous id when no apiKey", () => {
@@ -52,6 +87,43 @@ describe("telemetry", () => {
expect(() => captureEvent("test_event", {}, {})).not.toThrow();
});
it("keeps using a cached email only while it belongs to the current key", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-a" });
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("forgets the account when the API key changes", async () => {
// The defect: the cached email was used forever, so events after an account
// switch kept reporting under the previous account.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-b" });
expect(clearResolvedAccount).toHaveBeenCalled();
});
it("re-resolves for a key it has not looked up before", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-c" });
// The resolution latch is per key, not once per process, so a key changed
// mid-session is actually looked up instead of sticking to the fallback.
expect(fetchSpy).toHaveBeenCalled();
});
it("handles readPluginAuth errors gracefully", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
throw new Error("config read failed");
@@ -38,6 +38,7 @@
"build": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext]",
"dev": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext] --watch",
"type-check": "tsc --noEmit",
"test": "bun test",
"prepack": "bun run build",
"postpack": ""
},
+47 -4
View File
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import { afterEach, describe, expect, test } from "bun:test";
import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry";
@@ -13,7 +15,10 @@ describe("opencode telemetry", () => {
expect(payload).not.toBeNull();
const props = payload!.properties as Record<string, unknown>;
expect(payload!.event).toBe("plugin.session_start");
expect(props.source).toBe("plugin");
// Was "plugin", which named no particular plugin and matched no vocabulary.
// Now shaped like every other surface. Any saved PostHog insight filtering
// source = "plugin" needs repointing; historical data is untouched.
expect(props.source).toBe("OPENCODE_PLUGIN");
expect(props.platform).toBe("opencode");
expect(props.memory_count).toBe(5);
expect(props.$process_person_profile).toBe(false);
@@ -30,7 +35,7 @@ describe("opencode telemetry", () => {
const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)!
.properties as Record<string, unknown>;
expect(props.platform).toBe("opencode");
expect(props.source).toBe("plugin");
expect(props.source).toBe("OPENCODE_PLUGIN");
});
test("returns null without an API key (no anonymous events)", () => {
@@ -56,9 +61,12 @@ describe("opencode telemetry", () => {
expect(typeof props.os_version).toBe("string");
});
test("project_hash is sha256(projectId) when a project id is supplied", async () => {
test("project_hash is a salted digest of the project id", async () => {
// Previously asserted the bare sha256(projectId), which is the defect: that
// digest is reversible by anyone who can guess a project id. Salted with the
// API key, which is already in play here and is high entropy.
const { createHash } = await import("node:crypto");
const expected = createHash("sha256").update("acme-repo").digest("hex");
const expected = createHash("sha256").update(`${KEY}:acme-repo`).digest("hex");
const props = buildEvent("session_start", {}, KEY, "acme-repo")!
.properties as Record<string, unknown>;
expect(props.project_hash).toBe(expected);
@@ -76,3 +84,38 @@ describe("opencode telemetry", () => {
}
});
});
describe("project_hash salting", () => {
const PROJECT = "my-project";
test("is not a bare digest of the project id", () => {
// The defect: an unsalted SHA-256 over a guessable identifier is reversible
// by anyone who can enumerate project ids.
const unsalted = createHash("sha256").update(PROJECT).digest("hex");
const payload = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
expect(payload.properties.project_hash).toBeDefined();
expect(payload.properties.project_hash).not.toBe(unsalted);
});
test("differs per account for the same project", () => {
const a = buildEvent("session_start", {}, "m0-account-a", PROJECT) as Record<string, any>;
const b = buildEvent("session_start", {}, "m0-account-b", PROJECT) as Record<string, any>;
expect(a.properties.project_hash).not.toBe(b.properties.project_hash);
});
test("is stable for one account, so joins still work", () => {
const first = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
const second = buildEvent("session_end", {}, KEY, PROJECT) as Record<string, any>;
expect(first.properties.project_hash).toBe(second.properties.project_hash);
});
test("is omitted rather than unsalted when there is no key", () => {
const payload = buildEvent("session_start", {}, undefined, PROJECT);
// No key means no event at all, so there is no unsalted hash to leak.
expect(payload).toBeNull();
});
});
+22 -5
View File
@@ -25,7 +25,9 @@ const PLUGIN_VERSION = (() => {
let currentDistinctId = "";
const telemetry = createTelemetry({
host: "opencode",
source: "plugin",
// Shaped like the platform's EventSource values, as every other surface is.
// "plugin" said nothing about which plugin and matched no vocabulary.
source: "OPENCODE_PLUGIN",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
eventName: (event) => `plugin.${event}`,
@@ -40,8 +42,23 @@ function distinctId(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
}
function projectHash(projectId?: string): Record<string, string> {
return projectId ? { project_hash: createHash("sha256").update(projectId).digest("hex") } : {};
/**
* Salted so the hash is not enumerable.
*
* An unsalted SHA-256 of a project id is reversible by anyone who can guess the
* id, which for a project identifier is a small space. The API key is the salt:
* it is already in play here (distinctId is a digest of it), it is high entropy,
* and using it needs no per-install file and so no write race to get wrong. The
* hash is therefore per account rather than per machine, which also keeps joins
* working for one user across machines. It resets when the key rotates, which is
* consistent, because distinctId resets with it.
*
* Both are omitted without a key. An event cannot be built without a distinctId
* anyway, so this costs nothing.
*/
function projectHash(projectId?: string, apiKey?: string): Record<string, string> {
if (!projectId || !apiKey) return {};
return { project_hash: createHash("sha256").update(`${apiKey}:${projectId}`).digest("hex") };
}
export function buildEvent(
@@ -51,7 +68,7 @@ export function buildEvent(
projectId?: string,
): Record<string, unknown> | null {
currentDistinctId = apiKey ? distinctId(apiKey) : "";
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId) });
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId, apiKey) });
return event ? { api_key: POSTHOG_API_KEY, ...event } : null;
}
@@ -62,5 +79,5 @@ export function captureEvent(
projectId?: string,
): void {
currentDistinctId = apiKey ? distinctId(apiKey) : "";
telemetry.capture(eventType, { ...properties, ...projectHash(projectId) });
telemetry.capture(eventType, { ...properties, ...projectHash(projectId, apiKey) });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.2.0",
"version": "3.1.8",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
-9
View File
@@ -23,15 +23,6 @@ export type {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileStatus,
ProfileResponse,
ProfileJobResponse,
ProfileJobStatus,
ProfileSettings,
ProfileSettingsResponse,
EntityProfileSettings,
ProfileSampleResult,
} from "./mem0.types";
// Re-export enums as values (not type-only)
+3 -207
View File
@@ -1,5 +1,4 @@
import axios from "axios";
import { v4 as uuidv4 } from "uuid";
import {
AllUsers,
PaginatedMemories,
@@ -21,12 +20,6 @@ import {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileResponse,
ProfileJobResponse,
ProfileJobStatus,
ProfileSettings,
ProfileSettingsResponse,
} from "./mem0.types";
import {
captureClientEvent,
@@ -99,9 +92,6 @@ interface ClientIdentity {
}
// Shares one ping per (host, api key) across clients; FIFO-capped.
// One collection for every generation; the operation is a body field.
const PROFILE_JOBS_PATH = "/v2/profiles/jobs/";
const IDENTITY_CACHE_MAX_DEFAULT = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
@@ -254,8 +244,7 @@ export default class MemoryClient {
});
}
/** Fetch with no key conversion, for payloads carrying user-controlled property names. */
async _fetchRawJson(url: string, options: any): Promise<any> {
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
const response = await fetch(url, {
...options,
headers: {
@@ -268,11 +257,8 @@ export default class MemoryClient {
const errorData = await response.text();
throw createExceptionFromResponse(response.status, errorData);
}
return response.json();
}
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
return snakeToCamelKeys(await this._fetchRawJson(url, options));
const jsonResponse = await response.json();
return snakeToCamelKeys(jsonResponse);
}
_preparePayload(
@@ -769,196 +755,6 @@ export default class MemoryClient {
return response;
}
/**
* Get the memory profile for a single user.
*
* Branch on `status`, not on an empty `profile`: generation is asynchronous,
* so a known user without a profile yet is a normal response.
*/
async getProfile(data: { entityId: string }): Promise<ProfileResponse> {
this._captureEvent("get_profile", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/entities/user/${encodeURIComponent(data.entityId)}/profile/`,
{
headers: this.headers,
},
);
return response;
}
/**
* Generate or refresh the profile for one user, now.
*
* Profiles are otherwise built once a user crosses an internal message
* threshold, so a new user has none for its first few memories. Returns as
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
*
* Pass `idempotencyKey` and reuse it to retry a lost request without starting
* (and being billed for) a second job.
*/
async generateProfile(data: {
entityId: string;
idempotencyKey?: string;
}): Promise<ProfileJobResponse> {
this._captureEvent("generate_profile", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: {
...this.headers,
"Idempotency-Key": data.idempotencyKey ?? uuidv4(),
},
body: JSON.stringify({
operation: "trigger",
entity_type: "user",
entity_id: data.entityId,
}),
},
);
return response;
}
/** Get the profile settings for the current project. */
async getProfileSettings(): Promise<ProfileSettingsResponse> {
this._captureEvent("get_profile_settings", []);
await this._awaitIdentity();
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
headers: this.headers,
});
return this._settingsWithVerbatimSchema(raw);
}
/**
* The envelope keys are ours; the schema's property names are the customer's.
*
* Each entity type carries its own schema, so every one has to be restored
* from the raw body — otherwise camel-casing rewrites the customer's field
* names and a profile comes back under keys they never chose.
*/
private _settingsWithVerbatimSchema(raw: any): ProfileSettingsResponse {
const settings = snakeToCamelKeys(raw) as ProfileSettingsResponse;
if (!raw || typeof raw !== "object") {
return settings;
}
if ("schema" in raw) {
settings.schema = raw.schema;
}
const rawEntities = raw.entities;
if (rawEntities && typeof rawEntities === "object") {
for (const [entityType, entitySettings] of Object.entries(rawEntities)) {
if (
entitySettings &&
typeof entitySettings === "object" &&
"schema" in entitySettings &&
settings.entities?.[entityType as ProfileEntityType]
) {
settings.entities[entityType as ProfileEntityType]!.schema = (
entitySettings as Record<string, any>
).schema;
}
}
}
return settings;
}
/**
* Update profile settings. Only the fields you pass are written.
*
* `schema` and `customInstructions` are per user and are nested under
* `entities` for the API; only `enabled` is project-wide. Sending them flat
* is rejected with `Unsupported settings`.
*/
async updateProfileSettings(
settings: ProfileSettings,
): Promise<ProfileSettingsResponse> {
const payloadKeys = Object.keys(settings || {});
this._captureEvent("update_profile_settings", [payloadKeys]);
await this._awaitIdentity();
const { schema, customInstructions, enabled } = settings || {};
const body: Record<string, any> = {};
if (enabled !== undefined) {
body.enabled = enabled;
}
const entitySettings: Record<string, any> = {};
// The schema's property names are the customer's and must reach the API verbatim.
if (schema !== undefined) {
entitySettings.schema = schema;
}
if (customInstructions !== undefined) {
entitySettings.custom_instructions = customInstructions;
}
if (Object.keys(entitySettings).length > 0) {
body.entities = { user: entitySettings };
}
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
return this._settingsWithVerbatimSchema(raw);
}
/**
* Generate profiles for a few real users, to check a schema.
*
* Real generations against real memories, and the results are kept: the
* profiles are written to those users and count toward usage.
*/
async sampleProfiles(data?: {
limit?: number;
idempotencyKey?: string;
}): Promise<ProfileJobResponse> {
this._captureEvent("sample_profiles", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: {
...this.headers,
"Idempotency-Key": data?.idempotencyKey ?? uuidv4(),
},
body: JSON.stringify({
operation: "sample",
// Required: the API refuses a job that does not name an entity kind.
entity_type: "user",
...this._prepareParams({ limit: data?.limit }),
}),
},
);
return response;
}
/**
* Read one generation job. Accepts the `statusUrl` from a create call, or a
* bare job id. Prefer `statusUrl` so a route change needs no client update.
*/
async getProfileJob(jobIdOrStatusUrl: string): Promise<ProfileJobStatus> {
await this._awaitIdentity();
const path = jobIdOrStatusUrl.startsWith("/")
? jobIdOrStatusUrl
: `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`;
return this._fetchWithErrorHandling(`${this.host}${path}`, {
method: "GET",
headers: this.headers,
});
}
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
-102
View File
@@ -236,105 +236,3 @@ export interface GetMemoryExportPayload {
filters?: Record<string, any>;
memoryExportId?: string;
}
// ─── Profile Types ──────────────────────────────────────────
/** The entity kind that carries a profile. */
export type ProfileEntityType = "user";
/**
* `succeeded` is the only state in which `profile` is guaranteed to hold content.
*
* These are values, not keys, so the client does not camel-case them: the wire
* spelling is what a comparison has to match.
*/
export type ProfileStatus =
| "succeeded"
| "pending"
| "failed"
| "not_enabled"
| "insufficient_data";
export interface ProfileResponse {
/** Shaped by the project's schema; keys are not camel-cased. */
profile: Record<string, any>;
status: ProfileStatus;
entityType: ProfileEntityType;
entityId: string;
updatedAt: string | null;
generationCount: number;
/** The last generation's failure reason, when `status` is `failed`. */
error?: string | null;
}
/** Every accepted generation. `statusUrl` is the server's own poll path. */
export interface ProfileJobResponse {
jobId: string;
status: string;
statusUrl: string;
operation: string;
entityType: ProfileEntityType;
/** Entities reserved against usage for this job. */
entityCountReserved?: number;
eventId?: string;
replayed?: boolean;
/** Sample runs only: how many entities were picked. */
sampled?: number;
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
entityIds?: string[];
}
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
export interface ProfileSettings {
/** Turn profile generation on or off. Project-wide. */
enabled?: boolean;
/** JSON Schema for the profile. Every property needs a `description`. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
}
/** One entity type's stored configuration. */
export interface EntityProfileSettings {
/** The customer's JSON Schema, with its property names verbatim. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
[key: string]: any;
}
/**
* The settings as stored. `schema` and `customInstructions` are per entity
* type and nest under `entities`; only `enabled` is project-wide.
*/
export interface ProfileSettingsResponse {
enabled?: boolean;
entities?: Partial<Record<ProfileEntityType, EntityProfileSettings>>;
capabilities?: Record<string, any>;
[key: string]: any;
}
export interface ProfileSampleResult {
entityType: ProfileEntityType;
entityId: string;
profileId?: string;
[key: string]: any;
}
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
export interface ProfileJobStatus {
job: {
id: string;
operation: string;
entityType: ProfileEntityType;
status: string;
/** Null until `enumerationComplete`. */
total: number | null;
enumerationComplete: boolean;
/** succeeded + failed + skipped. */
completed: number;
succeeded: number;
failed: number;
skipped: number;
results?: Array<ProfileSampleResult>;
[key: string]: any;
};
}
@@ -1,341 +0,0 @@
/**
* MemoryClient unit tests — profiles.
* Verifies request construction and the verbatim round-trip of user-controlled
* profile/schema keys, not mock response echo.
*/
import { MemoryClient } from "../mem0";
import type { ProfileStatus } from "../mem0.types";
import { TEST_API_KEY } from "./helpers";
import {
setupMockFetch,
findFetchCall,
getFetchBody,
installConsoleSuppression,
} from "./setup";
installConsoleSuppression();
describe("MemoryClient - getProfile()", () => {
test("reads the v2 entity route and keeps profile keys verbatim", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/alice/profile/", {
status: 200,
body: {
// Customer schema keys: camel-casing these would break the schema they wrote.
profile: {
favorite_topics: ["hiking"],
work_style: { preferred_hours: "mornings" },
},
status: "succeeded",
entity_type: "user",
entity_id: "alice",
updated_at: "2026-02-08T00:00:00Z",
generation_count: 3,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/entities/user/alice/profile/");
expect(call).toBeDefined();
expect(result.profile).toEqual({
favorite_topics: ["hiking"],
work_style: { preferred_hours: "mornings" },
});
expect(result.entityType).toBe("user");
expect(result.entityId).toBe("alice");
expect(result.generationCount).toBe(3);
expect(result.status).toBe("succeeded");
});
test("status keeps its wire spelling", async () => {
// A status is a VALUE, not a key, so the client does not camel-case it.
// Declaring the union as `insufficientData` made tsc reject the comparison
// that works and accept one that can never be true.
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/bob/profile/", {
status: 200,
body: {
profile: {},
status: "insufficient_data",
entity_type: "user",
entity_id: "bob",
},
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfile({ entityId: "bob" });
expect(result.status).toBe("insufficient_data");
// Assignable without a cast: the declared union must contain the wire value.
const status: ProfileStatus = result.status;
expect(status).not.toBe("insufficientData");
});
test("scopes to user and encodes the entity id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/", {
status: 200,
body: { profile: {}, status: "pending", entity_type: "user" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getProfile({ entityId: "a/b" });
const call = findFetchCall(mock, "/v2/entities/user/a%2Fb/profile/");
expect(call).toBeDefined();
});
});
describe("MemoryClient - generateProfile()", () => {
test("sends operation trigger with the entity to the jobs collection", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "00000000-0000-7000-8000-000000000001",
status: "QUEUED",
status_url: "/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
operation: "trigger",
entity_type: "user",
entity_count_reserved: 1,
event_id: "00000000-0000-7000-8000-000000000002",
replayed: false,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.generateProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
expect(body.operation).toBe("trigger");
expect(body.entity_type).toBe("user");
expect(body.entity_id).toBe("alice");
// A generated Idempotency-Key is always sent so the create can be retried safely.
const headers = call![1].headers as Record<string, string>;
expect(headers["Idempotency-Key"]).toBeTruthy();
expect(result.jobId).toBe("00000000-0000-7000-8000-000000000001");
expect(result.statusUrl).toBe(
"/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
);
expect(result.replayed).toBe(false);
});
test("reuses a caller-supplied idempotency key", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "j1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/j1/",
operation: "trigger",
entity_type: "user",
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.generateProfile({
entityId: "alice",
idempotencyKey: "retry-key-123",
});
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
const headers = call![1].headers as Record<string, string>;
expect(headers["Idempotency-Key"]).toBe("retry-key-123");
});
});
describe("MemoryClient - profile settings", () => {
test("sends schema property names verbatim and returns them unchanged", async () => {
const schema = {
type: "object",
properties: {
favorite_topics: {
type: "array",
description: "Topics the user returns to",
items: { type: "string" },
},
workStyle: {
type: "string",
description: "How the user prefers to work",
},
},
};
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: true,
entities: {
user: {
schema,
custom_instructions: "Focus on durable preferences",
},
},
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.updateProfileSettings({
enabled: true,
schema,
customInstructions: "Focus on durable preferences",
});
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
// Mixed casing goes out exactly as written, nested under the entity type.
expect(body.entities.user.schema).toEqual(schema);
expect(body.entities.user.custom_instructions).toBe(
"Focus on durable preferences",
);
expect(body.enabled).toBe(true);
// A flat schema is rejected by the API with "Unsupported settings".
expect("schema" in body).toBe(false);
// And the customer's property names survive the round trip.
expect(result.entities?.user?.schema).toEqual(schema);
expect(result.entities?.user?.customInstructions).toBe(
"Focus on durable preferences",
);
});
test("scopes entity-level settings under user", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: { enabled: true },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.updateProfileSettings({
schema: { type: "object", properties: {} },
});
const body = getFetchBody(
findFetchCall(mock, "/v2/profiles/settings/", "POST")!,
);
expect(Object.keys(body.entities)).toEqual(["user"]);
});
test("omits fields the caller did not set", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: false,
entities: { user: { schema: null, custom_instructions: null } },
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.updateProfileSettings({ enabled: false });
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
const body = getFetchBody(call!);
expect(body.enabled).toBe(false);
// Nothing entity-scoped was passed, so no entities key is sent at all.
expect("entities" in body).toBe(false);
expect("schema" in body).toBe(false);
expect("custom_instructions" in body).toBe(false);
});
test("getProfileSettings reads the v2 route", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: true,
entities: {
user: {
schema: { properties: { favorite_topics: { type: "array" } } },
custom_instructions: null,
},
},
capabilities: { full_rebuild: false },
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfileSettings();
expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined();
expect(result.enabled).toBe(true);
expect(result.entities?.user?.schema).toEqual({
properties: { favorite_topics: { type: "array" } },
});
expect(result.capabilities?.fullRebuild).toBe(false);
});
});
describe("MemoryClient - sampleProfiles()", () => {
test("sampleProfiles omits limit when unset", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "job_1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_1/",
operation: "sample",
entity_type: "user",
sampled: 5,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.sampleProfiles();
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
// Every job names an entity kind: the API refuses one that does not.
expect(getFetchBody(call!)).toEqual({
operation: "sample",
entity_type: "user",
});
});
test("sampleProfiles passes an explicit limit", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "job_2",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_2/",
operation: "sample",
entity_type: "user",
sampled: 3,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.sampleProfiles({ limit: 3 });
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(getFetchBody(call!).operation).toBe("sample");
expect(getFetchBody(call!).limit).toBe(3);
expect(getFetchBody(call!).entity_type).toBe("user");
expect(result.sampled).toBe(3);
});
});
-3
View File
@@ -34,9 +34,6 @@ const OPAQUE_VALUE_KEYS = new Set([
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
"customCategories",
"custom_categories",
// A profile's keys come from the customer's own JSON Schema. The schema itself
// is handled in `updateProfileSettings`, so that `schema` is not opaque globally.
"profile",
]);
/**
+2 -382
View File
@@ -1,7 +1,6 @@
import hashlib
import logging
import os
import uuid
import warnings
from typing import Any, Dict, List, Optional
from urllib.parse import quote
@@ -40,43 +39,6 @@ setup_config()
# Entity parameters that must be passed via filters, not top-level
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
# One collection for every generation; the operation is a body field.
PROFILE_JOBS_PATH = "/v2/profiles/jobs/"
PROFILE_SETTINGS_PATH = "/v2/profiles/settings/"
# Distinguishes an omitted argument from an explicit ``None`` that clears a field.
_UNSET: Any = object()
def _profile_settings_payload(
enabled: Optional[bool],
schema: Any = _UNSET,
custom_instructions: Any = _UNSET,
) -> Dict[str, Any]:
"""Build the settings body the API accepts.
``schema`` and ``custom_instructions`` are per user and nest under
``entities``; only ``enabled`` is project-wide. This mirrors what
``get_profile_settings`` returns, so the two round-trip.
Sending them flat is rejected with ``Unsupported settings``, so this shape is
not cosmetic. ``_UNSET`` leaves a field unchanged; an explicit ``None`` clears it.
"""
payload: Dict[str, Any] = {}
if enabled is not None:
payload["enabled"] = enabled
entity_settings: Dict[str, Any] = {}
if schema is not _UNSET:
entity_settings["schema"] = schema
if custom_instructions is not _UNSET:
entity_settings["custom_instructions"] = custom_instructions
if entity_settings:
payload["entities"] = {"user": entity_settings}
return payload
def _validate_and_trim_search_query(query: str) -> str:
if not isinstance(query, str):
@@ -409,9 +371,7 @@ class MemoryClient:
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
if not payload:
raise ValueError(
"At least one of text, metadata, timestamp, or expiration_date must be provided for update."
)
raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.")
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"})
params = self._prepare_params()
@@ -722,174 +682,6 @@ class MemoryClient:
capture_client_event("client.get_summary", self, {"sync_type": "sync"})
return response.json()
@api_error_handler
def get_profile(self, entity_id: str) -> Dict[str, Any]:
"""Get the memory profile for a single user.
Branch on ``status``, not on an empty ``profile``: generation is
asynchronous, so a known user without a profile yet is a normal response.
Args:
entity_id: The user's id, as you supplied it on ``add`` (e.g. "alice").
Returns:
Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
``updated_at`` and ``generation_count``. ``status`` is one of
"succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
Raises:
AuthenticationError: If authentication fails.
NotFoundError: If no such user exists in the project.
"""
response = self.client.get(f"/v2/entities/user/{_encode_path_segment(entity_id)}/profile/")
response.raise_for_status()
capture_client_event("client.get_profile", self, {"sync_type": "sync"})
return response.json()
@api_error_handler
def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
"""Generate or refresh the profile for a single user, now.
Profiles are otherwise built once a user crosses an internal message
threshold, so a new user has none for its first few memories. Returns as
soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
Args:
entity_id: The user's id, as you supplied it on ``add``.
idempotency_key: Optional key that makes the create idempotent. Reuse
the same value to safely retry a lost request without starting
(and being billed for) a second job. A fresh key is generated when
omitted.
Returns:
Dict containing ``job_id``, ``status``, ``status_url``, ``operation``,
``entity_type``, ``entity_count_reserved``, ``event_id`` and
``replayed``. Poll :meth:`get_profile` and branch on ``status``.
Raises:
ValidationError: If profiles are not enabled and configured for the
project.
NotFoundError: If no such user exists in the project.
"""
response = self.client.post(
PROFILE_JOBS_PATH,
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
)
response.raise_for_status()
capture_client_event("client.generate_profile", self, {"sync_type": "sync"})
return response.json()
@api_error_handler
def get_profile_settings(self) -> Dict[str, Any]:
"""Get the profile settings for the current project.
Returns:
Dict with ``enabled`` and ``capabilities`` at the top level, and
``entities`` holding each entity type's ``schema`` and
``custom_instructions``.
"""
response = self.client.get(PROFILE_SETTINGS_PATH)
response.raise_for_status()
capture_client_event("client.get_profile_settings", self, {"sync_type": "sync"})
return response.json()
@api_error_handler
def update_profile_settings(
self,
enabled: Optional[bool] = None,
schema: Any = _UNSET,
custom_instructions: Any = _UNSET,
) -> Dict[str, Any]:
"""Update the profile settings for the current project.
Only the arguments you pass are written.
Args:
enabled: Turn profile generation on or off. Project-wide.
schema: JSON Schema for the profile. Every property needs a
``description``. Pass ``None`` to clear it; omit to leave it
unchanged. Applies to user profiles.
custom_instructions: Extra guidance for the extraction step. Pass
``None`` to clear it; omit to leave it unchanged. Applies to user
profiles.
Returns:
Dict with the settings as stored after the update, in the same
shape :meth:`get_profile_settings` returns.
Raises:
ValidationError: If the schema is not a valid profile schema.
"""
payload = _profile_settings_payload(enabled, schema, custom_instructions)
response = self.client.post(PROFILE_SETTINGS_PATH, json=payload)
response.raise_for_status()
capture_client_event(
"client.update_profile_settings",
self,
{"keys": list(payload.keys()), "sync_type": "sync"},
)
return response.json()
@api_error_handler
def sample_profiles(self, limit: Optional[int] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
"""Generate profiles for a few real users, to check a schema.
Real generations against real memories, and the results are kept. The
profiles are written to those users and count toward usage.
Args:
limit: How many users to sample, 1-10. Defaults to the server value.
idempotency_key: Optional key that makes the create idempotent. Reuse
the same value to safely retry without starting a second sample
run. A fresh key is generated when omitted.
Returns:
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
and the ``entity_ids`` that were picked. Poll
:meth:`get_profile_job` with ``status_url`` until the status is terminal.
Raises:
ValidationError: If profiles are not enabled and configured.
RateLimitError: If a sample run was already started very recently.
"""
payload = self._prepare_params({"limit": limit})
payload["operation"] = "sample"
payload["entity_type"] = "user"
response = self.client.post(
PROFILE_JOBS_PATH,
json=payload,
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
)
response.raise_for_status()
capture_client_event("client.sample_profiles", self, {"sync_type": "sync"})
return response.json()
@api_error_handler
def get_profile_job(self, job_id_or_status_url: str) -> Dict[str, Any]:
"""Read one generation job.
Accepts the ``status_url`` from a create call, or a bare job id. Prefer
passing ``status_url`` so a route change needs no client update.
Returns:
Dict whose ``job`` key holds the job: ``status``, ``total``,
``completed``, ``succeeded``, ``failed`` and ``skipped``. ``total`` is
null until ``enumeration_complete``.
"""
path = job_id_or_status_url
if not path.startswith("/"):
path = f"{PROFILE_JOBS_PATH}{path}/"
response = self.client.get(path)
response.raise_for_status()
return response.json()
@api_error_handler
def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Get instructions or categories for the current project.
@@ -1499,9 +1291,7 @@ class AsyncMemoryClient:
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
if not payload:
raise ValueError(
"At least one of text, metadata, timestamp, or expiration_date must be provided for update."
)
raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.")
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"})
params = self._prepare_params()
@@ -1798,176 +1588,6 @@ class AsyncMemoryClient:
capture_client_event("client.get_summary", self, {"sync_type": "async"})
return response.json()
@api_error_handler
async def get_profile(self, entity_id: str) -> Dict[str, Any]:
"""Get the memory profile for a single user.
Branch on ``status``, not on an empty ``profile``: generation is
asynchronous, so a known user without a profile yet is a normal response.
Args:
entity_id: The user's id, as you supplied it on ``add`` (e.g. "alice").
Returns:
Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
``updated_at`` and ``generation_count``. ``status`` is one of
"succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
Raises:
AuthenticationError: If authentication fails.
NotFoundError: If no such user exists in the project.
"""
response = await self.async_client.get(f"/v2/entities/user/{_encode_path_segment(entity_id)}/profile/")
response.raise_for_status()
capture_client_event("client.get_profile", self, {"sync_type": "async"})
return response.json()
@api_error_handler
async def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
"""Generate or refresh the profile for a single user, now.
Profiles are otherwise built once a user crosses an internal message
threshold, so a new user has none for its first few memories. Returns as
soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
Args:
entity_id: The user's id, as you supplied it on ``add``.
idempotency_key: Optional key that makes the create idempotent. Reuse
the same value to safely retry a lost request without starting
(and being billed for) a second job. A fresh key is generated when
omitted.
Returns:
Dict containing ``job_id``, ``status``, ``status_url``, ``operation``,
``entity_type``, ``entity_count_reserved``, ``event_id`` and
``replayed``. Poll :meth:`get_profile` and branch on ``status``.
Raises:
ValidationError: If profiles are not enabled and configured for the
project.
NotFoundError: If no such user exists in the project.
"""
response = await self.async_client.post(
PROFILE_JOBS_PATH,
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
)
response.raise_for_status()
capture_client_event("client.generate_profile", self, {"sync_type": "async"})
return response.json()
@api_error_handler
async def get_profile_settings(self) -> Dict[str, Any]:
"""Get the profile settings for the current project.
Returns:
Dict with ``enabled`` and ``capabilities`` at the top level, and
``entities`` holding each entity type's ``schema`` and
``custom_instructions``.
"""
response = await self.async_client.get(PROFILE_SETTINGS_PATH)
response.raise_for_status()
capture_client_event("client.get_profile_settings", self, {"sync_type": "async"})
return response.json()
@api_error_handler
async def update_profile_settings(
self,
enabled: Optional[bool] = None,
schema: Any = _UNSET,
custom_instructions: Any = _UNSET,
) -> Dict[str, Any]:
"""Update the profile settings for the current project.
Only the arguments you pass are written.
Args:
enabled: Turn profile generation on or off. Project-wide.
schema: JSON Schema for the profile. Every property needs a
``description``. Pass ``None`` to clear it; omit to leave it
unchanged. Applies to user profiles.
custom_instructions: Extra guidance for the extraction step. Pass
``None`` to clear it; omit to leave it unchanged. Applies to user
profiles.
Returns:
Dict with the settings as stored after the update, in the same
shape :meth:`get_profile_settings` returns.
Raises:
ValidationError: If the schema is not a valid profile schema.
"""
payload = _profile_settings_payload(enabled, schema, custom_instructions)
response = await self.async_client.post(PROFILE_SETTINGS_PATH, json=payload)
response.raise_for_status()
capture_client_event(
"client.update_profile_settings",
self,
{"keys": list(payload.keys()), "sync_type": "async"},
)
return response.json()
@api_error_handler
async def sample_profiles(
self, limit: Optional[int] = None, idempotency_key: Optional[str] = None
) -> Dict[str, Any]:
"""Generate profiles for a few real users, to check a schema.
Real generations against real memories, and the results are kept. The
profiles are written to those users and count toward usage.
Args:
limit: How many users to sample, 1-10. Defaults to the server value.
idempotency_key: Optional key that makes the create idempotent. Reuse
the same value to safely retry without starting a second sample
run. A fresh key is generated when omitted.
Returns:
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
and the ``entity_ids`` that were picked. Poll
:meth:`get_profile_job` with ``status_url`` until the status is terminal.
Raises:
ValidationError: If profiles are not enabled and configured.
RateLimitError: If a sample run was already started very recently.
"""
payload = self._prepare_params({"limit": limit})
payload["operation"] = "sample"
payload["entity_type"] = "user"
response = await self.async_client.post(
PROFILE_JOBS_PATH,
json=payload,
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
)
response.raise_for_status()
capture_client_event("client.sample_profiles", self, {"sync_type": "async"})
return response.json()
@api_error_handler
async def get_profile_job(self, job_id_or_status_url: str) -> Dict[str, Any]:
"""Read one generation job.
Accepts the ``status_url`` from a create call, or a bare job id. Prefer
passing ``status_url`` so a route change needs no client update.
Returns:
Dict whose ``job`` key holds the job: ``status``, ``total``,
``completed``, ``succeeded``, ``failed`` and ``skipped``. ``total`` is
null until ``enumeration_complete``.
"""
path = job_id_or_status_url
if not path.startswith("/"):
path = f"{PROFILE_JOBS_PATH}{path}/"
response = await self.async_client.get(path)
response.raise_for_status()
return response.json()
@api_error_handler
async def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Get instructions or categories for the current project.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.1.0"
version = "2.0.20"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
-272
View File
@@ -1,272 +0,0 @@
"""Tests for the MemoryClient profile methods.
These assert request construction — path, verb, body — rather than echoing a
mocked response back. The profile payload itself is the customer's own JSON
Schema shape, so the tests also pin that the SDK passes it through untouched.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def mock_memory_client():
"""A MemoryClient whose transport is mocked."""
with patch("mem0.client.main.httpx.Client") as mock_httpx:
mock_http_client = MagicMock()
mock_http_client.get.return_value = MagicMock(
json=lambda: {"org_id": "org1", "project_id": "proj1", "user_email": "test@test.com"},
raise_for_status=lambda: None,
)
mock_httpx.return_value = mock_http_client
with patch("mem0.client.main.capture_client_event"):
from mem0.client.main import MemoryClient
client = MemoryClient(api_key="test-api-key")
# The constructor pings through this same mock; drop that call.
mock_http_client.get.reset_mock()
yield client
def _assert_job_call(post_mock, expected_json):
"""One jobs collection, operation in the body, idempotency key per attempt."""
post_mock.assert_called_once()
args, kwargs = post_mock.call_args
assert args[0] == "/v2/profiles/jobs/"
assert kwargs["json"] == expected_json
assert kwargs["headers"]["Idempotency-Key"]
def _mock_response(payload):
response = MagicMock()
response.json.return_value = payload
response.raise_for_status.return_value = None
return response
class TestGetProfile:
def test_reads_the_v2_entity_route(self, mock_memory_client):
mock_memory_client.client.get.return_value = _mock_response(
{"profile": {}, "status": "pending", "entity_type": "user", "entity_id": "alice"}
)
mock_memory_client.get_profile("alice")
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
def test_encodes_path_segments(self, mock_memory_client):
"""An id with a slash must not open a new path segment."""
mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
mock_memory_client.get_profile("tenant/alice")
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/tenant%2Falice/profile/")
def test_returns_the_envelope_verbatim(self, mock_memory_client):
"""The customer's schema keys reach the caller exactly as stored."""
payload = {
"profile": {"favorite_topics": ["hiking"], "work_style": {"preferred_hours": "mornings"}},
"status": "succeeded",
"entity_type": "user",
"entity_id": "alice",
"updated_at": "2026-02-08T00:00:00Z",
"generation_count": 3,
}
mock_memory_client.client.get.return_value = _mock_response(payload)
assert mock_memory_client.get_profile("alice") == payload
class TestGenerateProfile:
def test_posts_entity_type_and_id(self, mock_memory_client):
mock_memory_client.client.post.return_value = _mock_response(
{"job_id": "j1", "status": "QUEUED", "status_url": "/v2/profiles/jobs/j1/"}
)
mock_memory_client.generate_profile("alice")
_assert_job_call(
mock_memory_client.client.post,
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
)
def test_reuses_caller_idempotency_key(self, mock_memory_client):
"""A caller-supplied key lets a retry hit the same job instead of billing twice."""
mock_memory_client.client.post.return_value = _mock_response({"job_id": "j1", "status": "QUEUED"})
mock_memory_client.generate_profile("alice", idempotency_key="retry-key-123")
_, kwargs = mock_memory_client.client.post.call_args
assert kwargs["headers"]["Idempotency-Key"] == "retry-key-123"
class TestProfileSettings:
def test_get_reads_v2(self, mock_memory_client):
mock_memory_client.client.get.return_value = _mock_response(
{"enabled": True, "entities": {"user": {"schema": None, "custom_instructions": None}}}
)
mock_memory_client.get_profile_settings()
mock_memory_client.client.get.assert_called_once_with("/v2/profiles/settings/")
def test_update_sends_only_supplied_fields(self, mock_memory_client):
"""A partial update must not blank the fields it never mentions."""
mock_memory_client.client.post.return_value = _mock_response({"enabled": False})
mock_memory_client.update_profile_settings(enabled=False)
mock_memory_client.client.post.assert_called_once_with(
"/v2/profiles/settings/",
json={"enabled": False},
)
def test_update_nests_schema_under_entities(self, mock_memory_client):
"""The API takes only ``enabled`` and ``entities`` at the top level.
A flat body is rejected with ``Unsupported settings``, so this nesting is
what makes the call work at all.
"""
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
mock_memory_client.update_profile_settings(enabled=True, schema=schema)
_, kwargs = mock_memory_client.client.post.call_args
assert set(kwargs["json"]) == {"enabled", "entities"}
assert "schema" not in kwargs["json"]
def test_update_targets_the_user_entity_type(self, mock_memory_client):
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
mock_memory_client.update_profile_settings(schema=schema)
_, kwargs = mock_memory_client.client.post.call_args
assert kwargs["json"] == {"entities": {"user": {"schema": schema}}}
def test_update_passes_schema_verbatim(self, mock_memory_client):
schema = {
"type": "object",
"properties": {
"favorite_topics": {
"type": "array",
"description": "Topics the user returns to",
"items": {"type": "string"},
}
},
}
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
mock_memory_client.update_profile_settings(enabled=True, schema=schema, custom_instructions="Keep it durable")
mock_memory_client.client.post.assert_called_once_with(
"/v2/profiles/settings/",
json={
"enabled": True,
"entities": {"user": {"schema": schema, "custom_instructions": "Keep it durable"}},
},
)
def test_update_clears_fields_with_explicit_none(self, mock_memory_client):
"""Explicit ``None`` clears a field; the sentinel default leaves it untouched."""
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
mock_memory_client.update_profile_settings(schema=None, custom_instructions=None)
_, kwargs = mock_memory_client.client.post.call_args
assert kwargs["json"] == {"entities": {"user": {"schema": None, "custom_instructions": None}}}
class TestSampleProfiles:
def test_sample_without_limit(self, mock_memory_client):
mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "entity_ids": []})
mock_memory_client.sample_profiles()
_assert_job_call(mock_memory_client.client.post, {"operation": "sample", "entity_type": "user"})
def test_sample_with_limit(self, mock_memory_client):
mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "entity_ids": []})
mock_memory_client.sample_profiles(limit=3)
_assert_job_call(
mock_memory_client.client.post,
{"operation": "sample", "limit": 3, "entity_type": "user"},
)
class TestAsyncClientParity:
"""The async client must speak the same wire protocol as the sync one."""
@pytest.fixture
def async_client(self):
# AsyncMemoryClient validates the key synchronously, through requests.
validation = MagicMock()
validation.json.return_value = {
"org_id": "org1",
"project_id": "proj1",
"user_email": "test@test.com",
}
validation.raise_for_status.return_value = None
with patch("mem0.client.main.httpx.AsyncClient") as mock_httpx:
mock_httpx.return_value = MagicMock()
with patch("mem0.client.main.requests.get", return_value=validation):
with patch("mem0.client.main.capture_client_event"):
from mem0.client.main import AsyncMemoryClient
yield AsyncMemoryClient(api_key="test-api-key")
def test_get_profile(self, async_client):
async_client.async_client.get = AsyncMock(return_value=_mock_response({"profile": {}, "status": "pending"}))
asyncio.run(async_client.get_profile("alice"))
async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
def test_generate_profile(self, async_client):
async_client.async_client.post = AsyncMock(return_value=_mock_response({"job_id": "j1", "status": "QUEUED"}))
asyncio.run(async_client.generate_profile("alice"))
_assert_job_call(
async_client.async_client.post,
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
)
def test_update_settings_partial(self, async_client):
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
asyncio.run(async_client.update_profile_settings(enabled=True))
async_client.async_client.post.assert_called_once_with(
"/v2/profiles/settings/",
json={"enabled": True},
)
def test_update_settings_nests_schema(self, async_client):
"""The async client builds the same body as the sync one."""
schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}}
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
asyncio.run(async_client.update_profile_settings(enabled=True, schema=schema))
async_client.async_client.post.assert_called_once_with(
"/v2/profiles/settings/",
json={"enabled": True, "entities": {"user": {"schema": schema}}},
)
def test_update_settings_clears_with_none(self, async_client):
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
asyncio.run(async_client.update_profile_settings(custom_instructions=None))
async_client.async_client.post.assert_called_once_with(
"/v2/profiles/settings/",
json={"entities": {"user": {"custom_instructions": None}}},
)