Compare commits

...

37 Commits

Author SHA1 Message Date
Himanshu-Sangshetti 921052d641 docs(integrations): add Agent Plugins overview page 2026-10-06 10:59:12 +05:30
Harsh Vardhan Gupta c93420c49a fix(security): resolve 13 Vanta HIGH Dependabot vulnerabilities (#7528)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:52:59 +05:30
Harsh Vardhan Gupta abb81c88e1 fix(security): resolve 7 Vanta MEDIUM Dependabot vulnerabilities (undici, ip-address, adm-zip) (#7510)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:46:18 +05:30
Kartik 94c3fe9f23 chore(release): bump Python SDK to 2.2.1 and TypeScript SDK to 3.3.1 (#7456) 2026-09-25 23:04:12 +05:30
Kartik 947ac79818 fix(ts-sdk): stop forcing pg on installs (optional ranged pg peer, optional natural) (#7450) 2026-09-25 20:46:24 +05:30
Yash Singh 8127e8bd47 fix(vector_stores/turbopuffer): make search score respect distance_metric (#6559) 2026-09-25 17:08:20 +05:30
Abhay Singh 5fd01d284a fix(ts-oss/turbopuffer): bound euclidean_squared distance to a similarity score (#6580) 2026-09-25 17:07:11 +05:30
Ratish jain 70c676c8c4 fix: wire dev tooling (ruff/isort/pre-commit) into hatch dev environments (#6684) 2026-09-25 17:04:47 +05:30
Yash Singh a2d8a8a849 fix(vector_stores/s3_vectors): make search score metric-aware (#6547) 2026-09-25 16:21:16 +05:30
Ayaan Gazali fb6d5e1917 fix(llms/aws_bedrock): iterate Converse content blocks for Anthropic text (#6369) 2026-09-25 16:20:09 +05:30
Yash Singh ccd216cff0 fix(vector_stores/turbopuffer): apply all filter operators instead of dropping them (#6564) 2026-09-25 15:53:35 +05:30
Abhay Singh 545306db5a fix(ts-oss/turbopuffer): apply all filter operators, not just gte/lte (#6578) 2026-09-25 15:30:31 +05:30
mintlify[bot] 8d6c001966 Fix grammar & typos: correct OpenSearch spelling in changelog (#7442)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-09-25 09:58:06 +05:30
Varun S G 989c7da0fc fix(oss): stop ConfigManager from injecting OpenAI's baseURL and model into other providers (#7350) 2026-09-24 22:14:28 +05:30
Mohd Quamar Tyagi d675cf68ad fix(memory): restore Memory and AsyncMemory context-manager protocol (#7354) 2026-09-24 22:04:57 +05:30
Diwakar Ray Yadav 2c6ff619d1 fix(memory): exclude vector-store-rejected records from ADD results (#7066) 2026-09-24 22:01:31 +05:30
Kartik f4acc89a29 docs: remove memory types page and preserve redirects (#7437) 2026-09-24 18:00:54 +05:30
Kartik 43849c6e9d feat(integrations): migrate and validate standalone Hermes Mem0 plugin (#7372) 2026-09-24 17:53:29 +05:30
Kartik 47a69e1e72 docs(changelog): add v2.2.1 entry for Valkey None-timestamps fix (#7425) 2026-09-24 00:30:19 +05:30
Karthik ea9bbcabed feat(profiles): User Profiles v1 — SDK methods + docs (#7340)
Co-authored-by: Pratik <10096516+pratikgajjar@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-24 00:11:22 +05:30
Brennan 0cddc36d52 Protect against None timestamps in Valkey layer (#6993)
Signed-off-by: Brennan Cathcart <brennancathcart@gmail.com>
2026-09-23 22:32:04 +05:30
mintlify[bot] 83b07b1537 Fix grammar & typos: minor fixes across docs (#7423)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-09-23 19:53:11 +05:30
Kartik 8c02c425a5 fix(agent-plugins): match memo prompts and tool descriptions, release 0.3.3 (#7420)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-23 19:35:25 +05:30
mintlify[bot] f8082a7345 Fix grammar & typos: API casing in template heading (#7410)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-09-22 20:06:21 +05:30
Deshraj Yadav 5d38e3703a chore: remove CLI_SPECIFICATION.md (#4578)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-09-22 19:52:54 +05:30
88lin fd8fd087ea docs(vectordbs/pinecone): document extra_params on the Python config table (#7222) 2026-09-22 19:25:51 +05:30
Shahil kadia a214ec37bc fix(examples): migrate OSS Memory search/get_all calls to v3 filters API (#7300)
Co-authored-by: Shahil Kadia <nexiouscaliver@users.noreply.github.com>
2026-09-22 19:01:33 +05:30
mintlify[bot] 8b38da9ab8 Fix grammar & typos: SDK changelog v2.0.8 entry (#6716)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-09-22 18:51:03 +05:30
mintlify[bot] 17852dc648 SEO & metadata audit: shorten supabase cookbook description (#7380)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-09-22 18:50:39 +05:30
Himanshu a39a802bbc docs(cookbooks): Company Brain with Mem0 Platform + Supabase (#7309) 2026-09-18 23:19:15 +05:30
Saket Aryan 19f7134082 chore(release): bump every package the telemetry attribution work changed (#7373)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-09-18 16:28:55 +05:30
Saket Aryan a8d3634312 fix(plugins): stop the TypeScript telemetry losing events and misattributing accounts (#7358) 2026-09-18 14:13:22 +05:30
Saket Aryan 1a5c7ad28c feat(integrations): declare which surface each client is, and its version (#7326) 2026-09-18 14:13:11 +05:30
Saket Aryan 4e38b057fd fix(plugins): count installs once, and re-resolve the email when the key changes (#7325) 2026-09-18 13:46:25 +05:30
Saket Aryan 3362999095 fix(plugins): stop delivering telemetry events twice, and stop losing parked ones (#7324) 2026-09-18 13:43:33 +05:30
Saket Aryan 012cd32c3a fix(plugins): report the plugin that produced the event, not the one that sent it (#7323) 2026-09-18 13:42:34 +05:30
Saket Aryan e4e0307ae6 fix(plugins): say what telemetry actually sends, and salt the hashes (#7322) 2026-09-18 13:23:05 +05:30
218 changed files with 14932 additions and 3066 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/claude-code-plugin",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.1"
"version": "0.3.3"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/cursor-plugin",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.1"
"version": "0.3.3"
}
]
}
@@ -32,6 +32,9 @@ jobs:
- name: Type check
run: bun run type-check
- name: Test
run: bun test
- name: Build
run: bun run build
+1 -1
View File
@@ -5,7 +5,7 @@
{
"id": "mem0",
"displayName": "Mem0",
"version": "0.3.1",
"version": "0.3.3",
"description": "Cross-session memory and token savings for coding agents.",
"homepage": "https://mem0.ai",
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/cli",
"version": "0.2.13",
"version": "0.2.14",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
+2 -1
View File
@@ -31,7 +31,8 @@ export class PlatformBackend implements Backend {
this.headers = {
Authorization: `Token ${config.apiKey}`,
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Source": "CLI",
"X-Mem0-Client": `mem0-cli-node/${CLI_VERSION}`,
"X-Mem0-Client-Language": "node",
"X-Mem0-Client-Version": CLI_VERSION,
};
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.12"
version = "0.2.13"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
+1 -1
View File
@@ -1,3 +1,3 @@
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
__version__ = "0.2.12"
__version__ = "0.2.13"
+2 -1
View File
@@ -27,7 +27,8 @@ class PlatformBackend(Backend):
headers={
"Authorization": f"Token {config.api_key}",
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Source": "CLI",
"X-Mem0-Client": f"mem0-cli-python/{__version__}",
"X-Mem0-Client-Language": "python",
"X-Mem0-Client-Version": __version__,
},
@@ -0,0 +1,5 @@
---
title: 'Generate Profiles'
description: "Start one generation: sample a few entities, or build one for a single entity."
openapi: post /v2/profiles/jobs/
---
@@ -0,0 +1,5 @@
---
title: 'Get Generation Job'
description: "Read the progress of a generation, and whether it finished."
openapi: get /v2/profiles/jobs/{job_id}/
---
@@ -0,0 +1,5 @@
---
title: 'Get Profile Settings'
description: "Retrieve the profile schema, custom instructions, and enabled flag for the current project."
openapi: get /v2/profiles/settings/
---
@@ -0,0 +1,5 @@
---
title: 'Get Profile'
description: "Retrieve the structured profile for a user, with a status describing whether generation has completed."
openapi: get /v2/entities/{entity_type}/{entity_id}/profile/
---
@@ -0,0 +1,5 @@
---
title: 'Update Profile Settings'
description: "Set the JSON Schema, custom instructions, or enabled flag that control profile generation for the project."
openapi: post /v2/profiles/settings/
---
+221 -37
View File
@@ -7,6 +7,36 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-09-25" description="v2.2.1">
**Bug Fixes:**
- **Core:** `add()` (`Memory` and `AsyncMemory`) no longer reports records the vector store rejected as successful `ADD` events. Only records that were actually inserted are written to history, entity-linked, and returned. If none of the extracted memories could be inserted, `add()` now raises `VectorStoreError` instead of returning memories that were never stored ([#7066](https://github.com/mem0ai/mem0/pull/7066))
- **Core:** Restore the context-manager protocol on `Memory` (`with Memory() as m:`) and `AsyncMemory` (`async with AsyncMemory() as m:`), which closes the instance on exit ([#7354](https://github.com/mem0ai/mem0/pull/7354))
- **LLMs:** The AWS Bedrock Anthropic path now returns the first Converse content block that carries text instead of always reading `content[0]`. Claude reasoning models can emit a `reasoningContent` block before the text block, which previously made the call fail with a `KeyError` ([#6369](https://github.com/mem0ai/mem0/pull/6369))
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only `gte` and `lte` were read before, so any other operator was silently dropped and the query returned unfiltered results. An unsupported operator now raises `ValueError` ([#6564](https://github.com/mem0ai/mem0/pull/6564))
- **Vector Stores:** Turbopuffer `search()` scores now respect `distance_metric`. With `euclidean_squared`, the unbounded squared distance maps to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6559](https://github.com/mem0ai/mem0/pull/6559))
- **Vector Stores:** S3 Vectors `search()` scores are now metric-aware. With `euclidean`, the distance maps to `1 / (1 + distance)` instead of `max(0, 1 - distance)`, which collapsed most scores to 0 ([#6547](https://github.com/mem0ai/mem0/pull/6547))
</Update>
<Update label="2026-09-23" description="v2.2.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. Every job POST carries an `Idempotency-Key`; to retry a lost request without starting a second job, pass the same `idempotency_key` on each attempt ([#7340](https://github.com/mem0ai/mem0/pull/7340))
**Bug Fixes:**
- **Vector Stores:** Guard against `None` timestamps in the Valkey vector store's `insert()` and `update()` paths. `created_at` and `updated_at` fields that were present in the payload but set to `None` previously passed the `"created_at" not in payload` / `"updated_at" in payload` checks and raised `TypeError` when `datetime.fromisoformat()` received `None`. Both paths now use `.get()` with a truthiness check so `None` values fall through to the default, matching the Redis provider's behavior ([#6993](https://github.com/mem0ai/mem0/pull/6993))
</Update>
<Update label="2026-09-18" description="v2.1.0">
**Improvements:**
- **Client:** Requests now carry three surface-identity headers so the platform can tell which product made a call. `X-Mem0-Source` names the surface and `X-Application` the host app it runs inside, both set-once so a wrapper that already declared its identity keeps it. `X-Mem0-Client` is append-only and carries `name/version` per layer, outermost first, so a plugin calling this SDK reports the whole chain rather than only the last speaker. `MEM0_SOURCE`, `MEM0_APPLICATION` and `MEM0_CLIENT_STACK` set them from the environment for wrappers that cannot pass options ([#7326](https://github.com/mem0ai/mem0/pull/7326))
- **Client:** The client stack is bounded by dropping whole entries rather than slicing characters, and this SDK's own entry is the reserved one. Truncating the joined string could sever an identifier mid-name and the platform parsed the fragment as a real client ([#7326](https://github.com/mem0ai/mem0/pull/7326))
</Update>
<Update label="2026-09-02" description="v2.0.20">
**Improvements:**
@@ -174,7 +204,7 @@ mode: "wide"
<Update label="2026-06-24" description="v2.0.8">
**New Features:**
- **Embeddings:** Add native `embed_batch` to five embedders: LM Studio, Together, HuggingFace, Vertex AI, and Google GenAI: for batched embedding requests ([#5609](https://github.com/mem0ai/mem0/pull/5609))
- **Embeddings:** Add native `embed_batch` to five embedders for batched embedding requests: LM Studio, Together, HuggingFace, Vertex AI, and Google GenAI ([#5609](https://github.com/mem0ai/mem0/pull/5609))
**Bug Fixes:**
- **Core:** Guard against malformed `image_url` entries in `parse_vision_messages` to prevent crashes ([#5631](https://github.com/mem0ai/mem0/pull/5631))
@@ -964,7 +994,7 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
**New Features:**
- **OpenMemory:** Added OpenMemory support
- **Neo4j:** Added weights to Neo4j model
- **AWS:** Added support for Opsearch Serverless
- **AWS:** Added support for OpenSearch Serverless
- **Examples:** Added ElizaOS Example
**Improvements:**
@@ -1227,6 +1257,31 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-09-25" description="v3.3.1">
**Bug Fixes:**
- **Config (OSS):** `ConfigManager` no longer injects OpenAI's default `baseURL` and `model` into non-OpenAI LLM providers. The defaults now apply only to `openai` and `openai_structured`, so a DeepSeek, xAI, or other provider config without an explicit `baseURL` or `model` falls back to that provider's own defaults and env vars (`DEEPSEEK_API_BASE`, `XAI_API_BASE`, ...) instead of pointing at OpenAI with an OpenAI model name ([#7350](https://github.com/mem0ai/mem0/pull/7350))
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only the range operators were read before, so `eq`, `ne`, `in`, and `nin` were silently dropped. A `"*"` value now matches anything instead of nothing, an array value is treated as `in`, and an unsupported operator throws ([#6578](https://github.com/mem0ai/mem0/pull/6578))
- **Vector Stores:** Turbopuffer `search()` with `euclidean_squared` now maps the unbounded squared distance to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6580](https://github.com/mem0ai/mem0/pull/6580))
- **Packaging:** `pg`, `@types/pg`, and `natural` are now optional peer dependencies, and `pg` / `@types/pg` accept caret ranges instead of the exact `8.11.3` / `8.11.0` pins, so installing `mem0ai` no longer pulls in `pg` or conflicts with an app's own `pg` version. The PGVector store now imports `pg` only when it is used, so install it alongside `mem0ai` (`npm install pg`) if you use that store. `@types/jest` moved from peer dependencies to dev dependencies ([#7450](https://github.com/mem0ai/mem0/pull/7450))
</Update>
<Update label="2026-09-23" description="v3.3.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. Every job POST carries an `Idempotency-Key`; to retry a lost request without starting a second job, pass the same `idempotencyKey` on each attempt ([#7340](https://github.com/mem0ai/mem0/pull/7340))
</Update>
<Update label="2026-09-18" description="v3.2.0">
**Improvements:**
- **Client:** Requests now carry `X-Mem0-Source`, `X-Application` and `X-Mem0-Client`, matching the Python SDK. The first two are set-once so an outer wrapper keeps its identity; the third is append-only and reports the whole layer chain. Read from `MEM0_SOURCE`, `MEM0_APPLICATION` and `MEM0_CLIENT_STACK` when set ([#7326](https://github.com/mem0ai/mem0/pull/7326))
- **Client:** The SDK version in `X-Mem0-Client` is injected at build time rather than hardcoded, so it cannot go stale at the next release ([#7326](https://github.com/mem0ai/mem0/pull/7326))
</Update>
<Update label="2026-09-02" description="v3.1.8">
**Improvements:**
@@ -1864,6 +1919,13 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="CLI">
<Update label="2026-09-18" description="Python v0.2.13 / Node v0.2.14">
**Improvements:**
- **Client:** Requests now carry the three surface-identity headers (`X-Mem0-Source`, `X-Application`, `X-Mem0-Client`) introduced in the Python and TypeScript SDKs, so the platform can attribute calls made through the CLI to the correct surface and version ([#7326](https://github.com/mem0ai/mem0/pull/7326))
</Update>
<Update label="2026-08-24" description="Python v0.2.12 / Node v0.2.13">
**New Features:**
@@ -2076,7 +2138,7 @@ A full-featured command-line interface for Mem0, available in both Python and No
- New Git repository writes use a hash of the remote identity in `agent_id`. Search and explicit shared-memory deletion include both current and legacy repository IDs within the repository's `app_id`. Existing memories are not rewritten. Legacy IDs retain their original ambiguity for matching owner/repository names on different Git hosts.
**Packaging:**
- Claude Code, Cursor, Codex, Kimi, Antigravity, and the portable Python bundle are versioned at `0.3.1`. OpenCode, Pi Agent, and DeepSeek Harness are `0.3.0`; OpenClaw is `1.1.0`. Each host's changes and upgrade considerations are listed in its tab.
- Claude Code, Cursor, Codex, Kimi, Antigravity, and the portable Python bundle are versioned at `0.3.1`. OpenCode and DeepSeek Harness are `0.3.0`; Pi Agent is `0.3.0`; OpenClaw is `1.1.0`. Each host's changes and upgrade considerations are listed in its tab.
- Python and TypeScript CI run their respective runtime suites. Package checks build the installable artifacts, check generated-file consistency, and reject TypeScript output that still imports monorepo source.
[#7203](https://github.com/mem0ai/mem0/pull/7203)
@@ -2371,9 +2433,25 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Claude Code">
<Update label="Unreleased" description="Sidekick availability">
<Update label="2026-09-23" description="Claude Code plugin v0.3.3">
Sidekick is now available only in Claude Code, with Sonnet, worktree isolation, and parent memories.
**Improvements:**
- **Search:** The `search_memories` tool description no longer tells the agent to call it before answering anything that could depend on prior context. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help, which reduces unnecessary searches ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Sidekick:** Sidekick searches memories when earlier sessions could help, instead of before every answer ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Extraction:** Repository memory instructions are shorter. They no longer ask for a dedicated memory for each command that failed and was then fixed, and no longer carry separate rules against saving personal preferences or memories that only name the repository, branch, or directory ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Search skill:** `/search` no longer describes categories as best-effort labels or asks for a retry without the category ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.3`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with these prompts ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="Claude Code plugin v0.3.2">
**Improvements:**
- **Telemetry:** `PLUGIN_VERSION` bumped to `0.3.2`. The `mem0-plugin/<version>` wire header and `plugin_version` telemetry field now reflect the fixes from #7322 through #7358 ([#7373](https://github.com/mem0ai/mem0/pull/7373))
- **Telemetry:** Events are no longer delivered twice, no longer lose parked events on flush, and now attribute each event to the plugin that produced it ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
**Changes:**
- **Sidekick:** Sidekick is now available only in Claude Code, with Sonnet, worktree isolation, and parent memories.
</Update>
@@ -2396,9 +2474,24 @@ Sidekick is now available only in Claude Code, with Sonnet, worktree isolation,
<Tab title="Cursor">
<Update label="Unreleased" description="Sidekick availability">
<Update label="2026-09-23" description="Cursor plugin v0.3.3">
Removes Sidekick and its start/stop hooks. Memory capture, search, and six skills remain available.
**Improvements:**
- **Search:** The `search_memories` tool description no longer tells the agent to call it before answering anything that could depend on prior context. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help, which reduces unnecessary searches ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Extraction:** Repository memory instructions are shorter. They no longer ask for a dedicated memory for each command that failed and was then fixed, and no longer carry separate rules against saving personal preferences or memories that only name the repository, branch, or directory ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Search skill:** `/search` no longer describes categories as best-effort labels or asks for a retry without the category ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.3`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with these prompts ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="Cursor plugin v0.3.2">
**Improvements:**
- **Telemetry:** `PLUGIN_VERSION` bumped to `0.3.2`. The `mem0-plugin/<version>` wire header and `plugin_version` telemetry field now reflect the fixes from #7322 through #7358 ([#7373](https://github.com/mem0ai/mem0/pull/7373))
- **Telemetry:** Events are no longer delivered twice, no longer lose parked events on flush, and now attribute each event to the plugin that produced it ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
**Changes:**
- **Sidekick:** Removes Sidekick and its start/stop hooks. Memory capture, search, and six skills remain available.
</Update>
@@ -2421,9 +2514,24 @@ Removes Sidekick and its start/stop hooks. Memory capture, search, and six skill
<Tab title="Codex">
<Update label="Unreleased" description="Sidekick availability">
<Update label="2026-09-23" description="Codex plugin v0.3.3">
Renames shared tracking to use subagent terminology. Native subagent memory support remains available.
**Improvements:**
- **Search:** The `search_memories` tool description no longer tells the agent to call it before answering anything that could depend on prior context. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help, which reduces unnecessary searches ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Extraction:** Repository memory instructions are shorter. They no longer ask for a dedicated memory for each command that failed and was then fixed, and no longer carry separate rules against saving personal preferences or memories that only name the repository, branch, or directory ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Search skill:** `/search` no longer describes categories as best-effort labels or asks for a retry without the category ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.3`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with these prompts ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="Codex plugin v0.3.2">
**Improvements:**
- **Telemetry:** `PLUGIN_VERSION` bumped to `0.3.2`. The `mem0-plugin/<version>` wire header and `plugin_version` telemetry field now reflect the fixes from #7322 through #7358 ([#7373](https://github.com/mem0ai/mem0/pull/7373))
- **Telemetry:** Events are no longer delivered twice, no longer lose parked events on flush, and now attribute each event to the plugin that produced it ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
**Changes:**
- **Sidekick:** Renames shared tracking to use subagent terminology. Native subagent memory support remains available.
</Update>
@@ -2443,32 +2551,25 @@ Renames shared tracking to use subagent terminology. Native subagent memory supp
</Tab>
<Tab title="Agent Plugins v1">
<Update label="Unreleased" description="Sidekick availability">
Sidekick is available only in Claude Code, not in the portable package.
</Update>
<Update label="2026-09-08" description="Portable Mem0 plugin v0.3.1">
**Added:**
- One portable package at `integrations/mem0-agent-plugin/`, using the Agent Plugins 1.0.0 root `plugin.json`, `mcp.json`, and fixed `skills/` locations.
- Ships a local, read-only `search_memories` server and the six shared memory skills. Uses `PLUGIN_ROOT` for bundled files and `PLUGIN_DATA` for persistent plugin state; all package files remain inside the installable directory.
**Packaging:**
- Generated from the shared Python runtime and skill templates. Builds validate the manifest, MCP configuration, skills, and generated-file consistency.
- Host lifecycle hooks and native Sidekick declarations remain in the native plugin packages; the portable package does not provide automatic lifecycle capture or host-specific subagent isolation. Its bundled remember skill cannot persist a new memory on its own because the portable package has no capture hooks or write tool.
[#7203](https://github.com/mem0ai/mem0/pull/7203)
</Update>
</Tab>
<Tab title="OpenCode">
<Update label="2026-09-23" description="OpenCode plugin v0.4.1">
**Improvements:**
- **Search:** The `search_memories` tool description no longer asks the agent to search proactively or to run several searches for multi-part questions. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help, the same wording as the other coding-agent plugins ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Session context:** Removed two system-context lines that told the agent to run 2 parallel searches before responding and 2-4 parallel searches for non-trivial tasks ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Skills:** `/mem0-search` and `/mem0-context-loader` make one `search_memories` call instead of 2 and 2-4 parallel calls. `/mem0-context-loader` now uses the search skill description from the other coding-agent plugins instead of asking to load at every new task or context switch ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="OpenCode plugin v0.4.0">
**Changes:**
- **Telemetry:** The PostHog `source` tag changed from the literal `"plugin"` to `OPENCODE_PLUGIN`, and `project_hash` is now salted. Saved PostHog insights filtering on `source = "plugin"` will stop matching new events; historical data is unaffected ([#7322](https://github.com/mem0ai/mem0/pull/7322))
- **Config:** A new `keyFingerprint` key appears in the install-count deduplication logic; installs are now counted once per key rather than on every activation ([#7325](https://github.com/mem0ai/mem0/pull/7325))
</Update>
<Update label="2026-09-08" description="OpenCode plugin v0.3.0">
**Changed:**
@@ -2567,9 +2668,24 @@ Sidekick is available only in Claude Code, not in the portable package.
<Tab title="Antigravity">
<Update label="Unreleased" description="Sidekick availability">
<Update label="2026-09-23" description="Antigravity plugin v0.3.3">
Removes Sidekick. Memory capture, search, and six skills remain available.
**Improvements:**
- **Search:** The `search_memories` tool description no longer tells the agent to call it before answering anything that could depend on prior context. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help, which reduces unnecessary searches ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Extraction:** Repository memory instructions are shorter. They no longer ask for a dedicated memory for each command that failed and was then fixed, and no longer carry separate rules against saving personal preferences or memories that only name the repository, branch, or directory ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Search skill:** `/search` no longer describes categories as best-effort labels or asks for a retry without the category ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.3`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with these prompts ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="Antigravity plugin v0.3.2">
**Improvements:**
- **Telemetry:** `PLUGIN_VERSION` bumped to `0.3.2`. The `mem0-plugin/<version>` wire header and `plugin_version` telemetry field now reflect the fixes from #7322 through #7358 ([#7373](https://github.com/mem0ai/mem0/pull/7373))
- **Telemetry:** Events are no longer delivered twice, no longer lose parked events on flush, and now attribute each event to the plugin that produced it ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
**Changes:**
- **Sidekick:** Removes Sidekick. Memory capture, search, and six skills remain available.
</Update>
@@ -2665,9 +2781,24 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="Kimi">
<Update label="Unreleased" description="Sidekick availability">
<Update label="2026-09-23" description="Kimi Code plugin v0.3.3">
Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skills remain available.
**Improvements:**
- **Search:** The `search_memories` tool description no longer tells the agent to call it before answering anything that could depend on prior context. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help, which reduces unnecessary searches ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Extraction:** Repository memory instructions are shorter. They no longer ask for a dedicated memory for each command that failed and was then fixed, and no longer carry separate rules against saving personal preferences or memories that only name the repository, branch, or directory ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Search skill:** `/search` no longer describes categories as best-effort labels or asks for a retry without the category ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.3`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with these prompts ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="Kimi Code plugin v0.3.2">
**Improvements:**
- **Telemetry:** `PLUGIN_VERSION` bumped to `0.3.2`. The `mem0-plugin/<version>` wire header and `plugin_version` telemetry field now reflect the fixes from #7322 through #7358 ([#7373](https://github.com/mem0ai/mem0/pull/7373))
- **Telemetry:** Events are no longer delivered twice, no longer lose parked events on flush, and now attribute each event to the plugin that produced it ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
**Changes:**
- **Sidekick:** Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skills remain available.
</Update>
@@ -2705,6 +2836,21 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
<Tab title="OpenClaw">
<Update label="2026-09-23" description="openclaw-mem0 v1.2.1">
**Improvements:**
- **Search:** The `memory_search` tool description no longer asks the agent to search proactively or to run several searches for multi-part questions. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help. Recall strategies (`smart`, `always`, `manual`) are unchanged ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="openclaw-mem0 v1.2.0">
**Changes:**
- **Config:** Added `keyFingerprint` to the config schema for install-count deduplication; installs are now counted once per key rather than on every activation ([#7325](https://github.com/mem0ai/mem0/pull/7325))
- **Telemetry:** Events are no longer delivered twice, and the `plugin_version` field now reflects the plugin that produced the event ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
</Update>
<Update label="2026-09-08" description="openclaw-mem0 v1.1.0">
**Changed:**
@@ -2993,6 +3139,22 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
<Tab title="Pi Agent">
<Update label="2026-09-23" description="Pi Agent plugin v0.3.2">
**Improvements:**
- **Search:** The memory policy, `mem0_memory` tool description, and prompt guidelines no longer ask the agent to search before answering anything that may depend on earlier context or to run several searches per question. They now ask for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Skills:** `context-loader` makes one search instead of 2-4 parallel searches, and uses the search skill description from the other coding-agent plugins ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Automatic recall:** Injected memories are introduced as "Mem0 found these relevant memories from earlier work in this repository:", the same heading as the Python plugins. The old heading called them a shallow first pass and told the agent to search again ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="Pi Agent plugin v0.3.1">
**Improvements:**
- **Telemetry:** Events are no longer delivered twice, no longer lose parked events, and now attribute each event to the plugin that produced it. The `plugin_version` wire field reflects the fixed release ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
</Update>
<Update label="2026-09-08" description="Pi Agent plugin v0.3.0">
**Changed:**
@@ -3106,6 +3268,21 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
<Tab title="DeepSeek Harness">
<Update label="2026-09-23" description="deepseek-plugin v0.3.2">
**Improvements:**
- **Search:** The `search_memory` tool description no longer asks the agent to search proactively before answering anything that may depend on earlier context. It now asks for a search before repeating investigation or when earlier decisions, fixes, commands, or results may help ([#7420](https://github.com/mem0ai/mem0/pull/7420))
- **Automatic recall:** Injected memories are introduced as "Mem0 found these relevant memories from earlier work:". The old heading called them a shallow first pass and told the agent to search again with `mem0_memory`, a tool DeepSeek does not have ([#7420](https://github.com/mem0ai/mem0/pull/7420))
</Update>
<Update label="2026-09-18" description="deepseek-plugin v0.3.1">
**Improvements:**
- **Telemetry:** Rebuild with the fixed shared telemetry core from `agent-plugin-core`. Events are no longer delivered twice, no longer lose parked events on flush, and now attribute each event to the plugin that produced it ([#7323](https://github.com/mem0ai/mem0/pull/7323), [#7324](https://github.com/mem0ai/mem0/pull/7324), [#7358](https://github.com/mem0ai/mem0/pull/7358))
</Update>
<Update label="2026-09-08" description="deepseek-plugin v0.3.0">
**Added:**
@@ -3150,6 +3327,13 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
<Tab title="Vercel AI SDK">
<Update label="2026-09-18" description="Vercel AI SDK v3.0.3">
**Improvements:**
- **Client:** Inherits the three surface-identity headers (`X-Mem0-Source`, `X-Application`, `X-Mem0-Client`) from the TypeScript SDK bump, so platform calls made through the Vercel AI SDK provider are now correctly attributed ([#7326](https://github.com/mem0ai/mem0/pull/7326))
</Update>
<Update label="2026-08-24" description="Vercel AI SDK v3.0.2">
**Security:**
@@ -11,7 +11,7 @@ To use Together embedding models, set the `TOGETHER_API_KEY` environment variabl
<Note> The `embedding_model_dims` parameter for `vector_store` should be set to `1024` for Together embedder. </Note>
<Warning>
**Breaking default change.** The default Together embedding model is now `intfloat/multilingual-e5-large-instruct` (**1024-dim**), replacing the previous default `togethercomputer/m2-bert-80M-8k-retrieval` (**768-dim**). If you created a self-hosted vector store with the old default, its collection is 768-dim and will reject the new 1024-dim vectors **recreate/reindex the collection at 1024 dimensions** after upgrading. To defer the change, pin the previous values explicitly (`model="togethercomputer/m2-bert-80M-8k-retrieval"`, `embedding_dims=768`) note Together no longer lists this model among its recommended embeddings, so reindexing at 1024 is the durable path.
**Breaking default change.** The default Together embedding model is now `intfloat/multilingual-e5-large-instruct` (**1024-dim**), replacing the previous default `togethercomputer/m2-bert-80M-8k-retrieval` (**768-dim**). If you created a self-hosted vector store with the old default, its collection is 768-dim and will reject the new 1024-dim vectors — **recreate/reindex the collection at 1024 dimensions** after upgrading. To defer the change, pin the previous values explicitly (`model="togethercomputer/m2-bert-80M-8k-retrieval"`, `embedding_dims=768`) — note Together no longer lists this model among its recommended embeddings, so reindexing at 1024 is the durable path.
</Warning>
<CodeGroup>
+1 -1
View File
@@ -95,7 +95,7 @@ Uses the identity from Azure PowerShell (`Connect-AzAccount`).
7. **Azure Developer CLI Credential:**
Uses the session from Azure Developer CLI (`azd auth login`).
<Note> If an API is provided, it will be used for authentication over an Azure Identity </Note>
<Note> If an API key is provided, it will be used for authentication over an Azure Identity </Note>
To enable Role-Based Access Control (RBAC) for Azure AI Search, follow these steps:
1. In the Azure Portal, navigate to your **Azure AI Search** service.
@@ -5,6 +5,12 @@ description: "Use pgvector as a vector store in Mem0 for PostgreSQL-based vector
[pgvector](https://github.com/pgvector/pgvector) is an open-source vector similarity search extension for Postgres. After connecting to Postgres, run `CREATE EXTENSION IF NOT EXISTS vector;` to create the vector extension.
The TypeScript SDK loads the `pg` driver only when you use this store, so install it alongside `mem0ai`:
```bash
npm install pg
```
### Usage
<CodeGroup>
@@ -94,6 +94,7 @@ Here are the parameters available for configuring Pinecone:
| `hybrid_search` | Whether to enable hybrid search | `False` |
| `metric` | Distance metric for vector similarity | `"cosine"` |
| `batch_size` | Batch size for operations | `100` |
| `extra_params` | Additional keyword arguments passed to the `Pinecone` client constructor. Ignored when `client` is supplied. | `None` |
| `namespace` | Namespace for the collection, useful for multi-tenancy. | `None` |
</Tab>
<Tab title="TypeScript">
@@ -30,7 +30,7 @@ pip install google-adk mem0ai python-dotenv
## Code Breakdown
Let's get started and understand the different components required in building a healthcare assistant powered by memory
Let's get started and understand the different components required in building a healthcare assistant powered by memory.
```python
# Import dependencies
+388
View File
@@ -0,0 +1,388 @@
---
title: Build a Company Brain with Mem0 Platform and Supabase
description: "Build a shared company brain using Mem0 Platform as the managed memory layer, Supabase as your system of record, and the Mem0 MCP server."
---
<Info icon="server">
**Uses:** Mem0 **Platform** (`MemoryClient`) · **System of record:** Supabase (Postgres + Auth) · **Access layer:** the hosted Mem0 MCP server. **You'll build:** a company brain your whole org (and every agent) writes to and queries, ending with a new-hire onboarding demo.
</Info>
Companies lose knowledge constantly: why you picked Postgres over Mongo, who owns billing, the deploy rule only one engineer remembers. A **company brain** captures this and answers questions about it, for every employee and every agent, and keeps it after people leave.
We'll build one on **Mem0 Platform** (the managed memory layer, so there's no vector DB to run) with **Supabase as the system of record** (where your employees, teams, and source documents actually live) and the **Mem0 MCP server** as the wire that lets Claude Code, Cursor, or a Slack bot all reach the same brain.
<Note>
**How Platform and Supabase divide the work.** Mem0 Platform manages storage and extraction server-side, you do **not** point it at your own database. Supabase is your app's source of truth and identity provider; we *ingest* knowledge from Supabase into the brain and use Supabase Auth to decide who's asking. (If you want to self-host the vector store instead, that's the OSS path, see the [Supabase vector store reference](/components/vectordbs/dbs/supabase).)
</Note>
## Architecture
```mermaid
flowchart LR
subgraph SB["Supabase: system of record"]
K[(knowledge / employees / teams)]
AU[Auth · who is asking]
end
subgraph M0["Mem0 Platform: the brain"]
B[(managed memory)]
end
K -->|ingest| B
AU -->|maps to scope| B
CC[Claude Code] --> MCP[Mem0 MCP server]
CU[Cursor] --> MCP
SL[Slack bot] --> MCP
MCP --> B
```
Memory splits by entity. An individual is a **`user_id`** (their Supabase Auth id). Shared knowledge lives on an **`agent_id`**: the company-wide brain is `org:acme`, and each team is its own agent, e.g. `team:payments`. A person's own facts route to their `user_id`; company and team facts route to the agent. This split is what lets one search return "my" context alongside the shared org knowledge.
## Prerequisites
- **Python 3.9+**
- A **Mem0 Platform API key**, [app.mem0.ai/dashboard/api-keys](https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cookbook-company-brain). (Platform runs extraction and embeddings for you, so there's no OpenAI key to manage.)
- A **Supabase** project, [supabase.com](https://supabase.com)
About 20 minutes.
---
## Step 1: Get your Mem0 Platform API key
Sign in at [app.mem0.ai](https://app.mem0.ai) and copy a key from **Dashboard → API Keys**. The key is scoped to your org and project; Mem0 resolves both server-side, so you never pass IDs by hand.
## Step 2: Create the Supabase system of record
In the Supabase **SQL editor**, create the tables your company already thinks in: people, teams, and a `knowledge` table the brain will ingest from. Identity reuses Supabase Auth's built-in `auth.users`.
```sql
-- Employees extend Supabase Auth's users; identity is auth.users.id (uuid)
create table public.employees (
id uuid primary key references auth.users (id) on delete cascade,
name text not null,
team text not null
);
-- The company knowledge the brain ingests. `scope` decides who can recall it.
create table public.knowledge (
id bigint generated always as identity primary key,
scope text not null, -- the shared agent this belongs to: 'org:acme' | 'team:payments'
content text not null,
author uuid references auth.users (id), -- who recorded it (their user_id); null for org seed data
created_at timestamptz default now(),
mem0_synced_at timestamptz -- null until ingested into the brain
);
create index on public.knowledge (mem0_synced_at, created_at);
-- Seed a little company knowledge to ingest.
insert into public.knowledge (scope, content) values
('org:acme', 'We chose Postgres over MongoDB for the core product for strong transactional guarantees and relational joins.'),
('org:acme', 'All production deploys go out Tuesday and Thursday; never on Fridays.'),
('org:acme', 'Customer data must stay in the EU region for GDPR compliance.'),
('org:acme', 'Billing is owned by the Payments team, and Alice is the Payments tech lead.'),
('team:payments', 'Stripe is our processor; webhooks are verified with PAYMENTS_WEBHOOK_SECRET.');
```
Grab your project URL and **service-role** key from **Settings → API** (the ingestion job runs server-side and needs to read every scope).
## Step 3: Project setup
```bash
mkdir company-brain && cd company-brain
pip install "mem0ai>=2.0.17" supabase requests # 2.0.17+ for agent_custom_instructions
```
```bash
export MEM0_API_KEY="m0-..."
export SUPABASE_URL="https://<project-ref>.supabase.co"
export SUPABASE_SERVICE_KEY="<service-role-key>"
```
## Step 4: Configure the brain
Create **`brain.py`**. This constructs the Platform client and teaches it what to remember. The key is the **two** instruction sets: `custom_instructions` governs a person's own (`user_id`) memories, and `agent_custom_instructions` governs shared (`agent_id`) memories, phrased in the third person so company facts read "The company…", not "The user's organization…". `custom_categories` files each memory under a useful label.
```python
# brain.py
import os
from mem0 import MemoryClient
client = MemoryClient(api_key=os.environ["MEM0_API_KEY"])
# Steer extraction (project-wide). Runs server-side; no LLM key needed here.
client.project.update(
# Governs a person's OWN memories (user_id).
custom_instructions=(
"Extract the individual's own durable preferences, context, and how they work. "
"Ignore greetings and one-off chatter."
),
# Governs SHARED memories (agent_id); write them in the third person.
agent_custom_instructions=(
"Extract durable company/team knowledge in the third person "
"(\"The company...\", \"The team...\"): decisions and their rationale, ownership "
"(who owns what), processes, policies, tooling choices, and gotchas. "
"Ignore greetings, scheduling, and one-off chatter."
),
custom_categories=[
{"decision": "Architectural or product decisions and why they were made"},
{"ownership": "Who owns a system, service, or process"},
{"policy": "Compliance, security, and process rules"},
{"tooling": "Tools, services, and how they're configured"},
],
)
# Scopes. A person is a user_id; shared brains are agent_ids.
COMPANY = "org:acme" # agent_id: company-wide shared brain
def team(name): return f"team:{name}" # agent_id: a team's shared brain
def person(uid): return uid # user_id: an individual (Supabase auth id)
```
Run it once to apply the project settings:
```bash
python -c "import brain; print('brain configured')"
```
## Step 5: Ingest company knowledge from Supabase
This is where Supabase and the brain connect. Create **`ingest.py`**: read un-synced rows from `knowledge`, add each to the Platform brain under its scope, then mark it synced. Platform `add()` is **asynchronous**, it returns an `event_id` you can poll, so we include a small `wait_for` helper.
```python
# ingest.py
import os, time, requests
from supabase import create_client
from brain import client, person
sb = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_KEY"])
MEM0_HEADERS = {"Authorization": f"Token {os.environ['MEM0_API_KEY']}"}
def wait_for(event_id, timeout=30):
"""Platform extraction is async; poll the event until it settles."""
for _ in range(timeout):
r = requests.get(f"https://api.mem0.ai/v1/event/{event_id}/", headers=MEM0_HEADERS).json()
if r.get("status") in ("SUCCEEDED", "FAILED"):
return r["status"]
time.sleep(1)
return "TIMEOUT"
# 1. Read knowledge that hasn't been ingested yet
rows = sb.table("knowledge").select("*").is_("mem0_synced_at", "null").execute().data
for row in rows:
# 2. Add it. agent_id = the shared scope (org/team); user_id = who recorded it.
# Mem0 routes shared facts to the agent and personal facts to the individual,
# so pass both when there's an author.
add_kwargs = {
"agent_id": row["scope"],
"metadata": {"source": "supabase", "knowledge_id": row["id"]},
}
if row["author"]:
add_kwargs["user_id"] = person(row["author"])
res = client.add([{"role": "user", "content": row["content"]}], **add_kwargs)
# 3. Platform returns an event_id; wait for extraction to finish
event_id = res.get("event_id") if isinstance(res, dict) else None
if event_id:
wait_for(event_id)
# 4. Mark the row synced so we never double-ingest
sb.table("knowledge").update({"mem0_synced_at": "now()"}).eq("id", row["id"]).execute()
print(f"Ingested {len(rows)} knowledge items into the company brain.")
```
```bash
python ingest.py
```
```text
Ingested 5 knowledge items into the company brain.
```
Re-running is safe, `mem0_synced_at` gates it, so a nightly cron can keep the brain in step with Supabase.
## Step 6: Ask the brain
Create **`ask.py`**. It searches everything relevant to the asker: their own (`user_id`) memories **plus** the shared company and team (`agent_id`) memories. This has to be an **`OR`**, each memory row belongs to exactly one entity, so a flat filter or an `AND` of a `user_id` and an `agent_id` matches nothing.
```python
# ask.py
import sys
from brain import client, COMPANY, team, person
def ask(question: str, uid: str | None = None, user_team: str | None = None) -> str:
scopes = [{"agent_id": COMPANY}] # company-wide brain
if user_team:
scopes.append({"agent_id": team(user_team)}) # the asker's team
if uid:
scopes.append({"user_id": person(uid)}) # the asker's own memories
hits = client.search(
query=question,
filters={"OR": scopes}, # OR, never AND (one FK per memory row)
top_k=5,
rerank=True,
)
return "\n".join(f"- {h['memory']}" for h in hits.get("results", hits))
if __name__ == "__main__":
print(ask(" ".join(sys.argv[1:]) or "When can we deploy?"))
```
```bash
python ask.py "Why did we pick Postgres, and can I deploy on Friday?"
```
```text
- The company chose Postgres over MongoDB for strong transactional guarantees and relational joins
- The company's production deploys go out Tuesday and Thursday, never on Fridays
```
Search returns every relevant memory, so a question resolves across separate facts, here it pulls both the owning team and the person:
```bash
python ask.py "Who should I talk to about billing?"
```
```text
- Billing is owned by the Payments team
- Alice is the Payments tech lead
```
## Step 7: Sharper retrieval
Platform search is hybrid (semantic + keyword) and filterable. Combine a keyword pass with a category filter to answer precise questions:
```python
client.search(
query="webhook signing secret",
filters={"agent_id": "team:payments", "categories": {"in": ["tooling"]}},
keyword_search=True, # hybrid keyword + semantic
rerank=True,
threshold=0.3,
)
```
Filters use keyword operators (`in`, `gte`, `contains`, …) and AND/OR/NOT, so you can scope by date, category, or metadata, for example the company's policies added this quarter:
```python
client.search(
query="compliance rules",
filters={"AND": [
{"agent_id": "org:acme"},
{"categories": {"in": ["policy"]}},
{"created_at": {"gte": "2026-01-01"}},
]},
)
```
## Step 8: Expose the brain to every agent (MCP)
A brain only your script can reach isn't a company brain. Mem0's **hosted MCP server** lets any agent (Claude Code, Cursor, a Slack bot) query and contribute to the *same* brain. The endpoint is `https://mcp.mem0.ai/mcp`, and the supported way to connect is the `mcp-add` helper, which registers the server and runs Mem0's OAuth login so no key ever lands in a config file.
<Tabs>
<Tab title="Claude Code / Cursor">
```bash
npx mcp-add --url "https://mcp.mem0.ai/mcp" --clients "claude code,cursor"
```
Complete the browser login on first connect. Now the agent has the brain's memory tools (`add_memory`, `search_memories`, and more) available in-editor.
</Tab>
<Tab title="Manual (.mcp.json)">
```json
{
"mcpServers": {
"mem0": { "url": "https://mcp.mem0.ai/mcp" }
}
}
```
Auth happens via Mem0's OAuth flow on first use, don't paste a static token into the file (the hosted gateway may reject a raw `Token` header).
</Tab>
<Tab title="Slack bot">
```python
# A Slack bot is just another MCP client. Point its MCP layer at the same URL,
# authenticate via Mem0's OAuth flow, and pass the company scope on each call.
await mcp.call_tool("search_memories", {
"query": user_message,
"agent_id": "org:acme",
})
```
</Tab>
</Tabs>
With this, an engineer asks the brain from their editor and a teammate asks it from Slack, one shared memory behind both.
## Step 9: Onboard a new hire (the payoff)
This is what a company brain is *for*. Dana joins, and her identity comes from **Supabase Auth**, which maps straight to her Mem0 `user_id`. She asks the questions every new hire asks and gets real answers on day one, drawn from the shared company (and her team's) brain, plus anything she's told it herself.
```python
# onboarding.py
from brain import client, person
from ask import ask
# In a real app these come from sb.auth.get_user(jwt) and the employees table.
dana_uid, dana_team = "8f3c...-dana", "payments"
# Dana also tells the brain how *she* works. This is personal, so it goes to her
# user_id, not the shared agent, and stays scoped to her.
client.add(
[{"role": "user", "content": "I prefer early returns over nested ifs, and I review PRs in the morning."}],
user_id=person(dana_uid),
)
for q in [
"Who owns billing and who do I talk to?", # company (agent) knowledge
"When are deploys, and are there hard rules?",
"How do I like to write code?", # Dana's own (user) knowledge
]:
print(f"Q: {q}\nA: {ask(q, uid=dana_uid, user_team=dana_team)}\n")
```
```text
Q: Who owns billing and who do I talk to?
A: - Billing is owned by the Payments team; Alice is the Payments tech lead
Q: When are deploys, and are there hard rules?
A: - The company's production deploys go out Tuesday and Thursday, never on Fridays
Q: How do I like to write code?
A: - User prefers early returns over nested ifs
```
The same `ask()` blends the shared company facts with Dana's own preference, because the `OR` filter spans both her `user_id` and the org and team `agent_id`s.
Dana onboarded herself by asking, drawing on the shared brain the rest of the team had been filling.
## Production notes
<Warning>
**`user_id` vs `agent_id`.** An individual is a `user_id`; shared brains (company, team) are `agent_id`s. Keeping them separate is what gives you the third-person "The company…" framing and lets a person's own context sit alongside org knowledge. Put a secret like a webhook key on a **team** agent, never the company agent, or everyone can recall it, and mirror the boundary in Supabase with a Row Level Security policy on `knowledge`.
</Warning>
<Warning>
**Search must `OR` the scopes.** A memory row belongs to exactly one entity, so `filters={"OR": [{"user_id": ...}, {"agent_id": "org:acme"}, {"agent_id": "team:..."}]}`. A flat filter, or an `AND` of a `user_id` and an `agent_id`, returns nothing.
</Warning>
<Warning>
**`add()` is asynchronous.** It returns `{event_id, status: "PENDING"}` and extraction finishes a moment later, poll `GET /v1/event/{event_id}/` (as in Step 5) when you need to know a write has landed before searching for it.
</Warning>
<Note>
**Where the entity ID goes differs by call.** `search()` and `get_all()` take the scope inside `filters={...}` (a top-level `user_id=`/`agent_id=` is rejected). `add()` and `delete_all()` are the opposite, they take it as a top-level keyword: `client.delete_all(agent_id="team:payments")`. Deletes are asynchronous too, so a `get_all` right after a `delete_all` can still show rows for a few seconds.
</Note>
## Where to take it next
- **Auto-feed the brain** from PR descriptions, RFCs, and incident write-ups so it grows without anyone thinking about it, just insert into Supabase `knowledge` and let the cron ingest.
- **Scope by real identity** end to end: verify the Supabase JWT, read `sb.auth.get_user(jwt).user.id` for the `user_id`, look up the person's team, and `OR` their `user_id` with the company and team `agent_id`s on every recall.
- **Give teams a private view** with Supabase RLS so `team:` knowledge is only readable by that team.
---
<CardGroup cols={2}>
<Card title="Mem0 MCP Server" icon="plug" href="/platform/mem0-mcp">
Connect any agent or editor to the brain over MCP.
</Card>
<Card title="Custom Categories & Instructions" icon="sliders" href="/platform/features/custom-instructions">
Steer exactly what the brain extracts and how it's filed.
</Card>
</CardGroup>
<Snippet file="star-on-github.mdx" />
+2 -2
View File
@@ -89,8 +89,8 @@ On Mem0 Platform, these stores are managed for you. In OSS, you choose and opera
## Next steps
<CardGroup cols={3}>
<Card title="Memory types" icon="brain" href="/core-concepts/memory-types">
Choose the right scope for user, agent, run, and session memory.
<Card title="Entity scoping" icon="brain" href="/platform/features/entity-scoped-memory">
Organize Platform memories by user, agent, app, and run.
</Card>
<Card title="Memory operations" icon="database" href="/core-concepts/memory-operations/add">
Add, search, update, and delete memories from your app.
-118
View File
@@ -1,118 +0,0 @@
---
title: Memory Types
description: "What memory_type actually does in Mem0: procedural memory is implemented, semantic and episodic are not."
icon: "tag"
iconType: "solid"
---
# Memory Types
Mem0's Python SDK exposes a `memory_type` parameter on `add()`. The underlying `MemoryType` enum defines three values, but only one of them is wired up. This page states plainly which is which so you don't build against a type that doesn't exist yet.
## Status
| Type | Enum value | Status | Notes |
| --- | --- | --- | --- |
| Procedural memory | `procedural_memory` | **Implemented** | Python OSS only (`Memory`/`AsyncMemory`). Pass `memory_type="procedural_memory"` and `agent_id` to `add()`. Not available on the Platform `MemoryClient`, and not available in the TypeScript SDK (OSS or Platform). |
| Semantic memory | `semantic_memory` | **Not implemented** | Defined in the `MemoryType` enum but never read anywhere else in the codebase. Passing it to `add()` raises a validation error. There is no evidence in this repo of a roadmap date for this. |
| Episodic memory | `episodic_memory` | **Not implemented** | Same as above: defined, never wired into the extraction pipeline, rejected by validation, no documented roadmap. |
<Warning>
Only `procedural_memory` is a real, working value. Calling `memory.add(messages, memory_type="semantic_memory")` (or `episodic_memory`) is rejected and tells you to pass `procedural_memory` instead. Sync `Memory.add()` raises `Mem0ValidationError`; `AsyncMemory.add()` raises a plain `ValueError`.
</Warning>
## Procedural memory
Procedural memory stores step-by-step task knowledge (how an agent performs a workflow) rather than facts about a user. It requires `agent_id`:
```python
from mem0 import Memory
memory = Memory()
memory.add(
[
{"role": "user", "content": "Book a flight from SFO to NYC"},
{"role": "assistant", "content": "1. Search flights. 2. Filter by price. 3. Confirm booking."},
],
agent_id="travel-agent",
memory_type="procedural_memory",
)
```
Omit `memory_type` entirely and Mem0 stores the messages as an ordinary memory: there is no semantic/episodic pathway for it to fall into. Any other explicit value is rejected by validation rather than quietly falling back to an ordinary memory.
## How every other memory is scoped
Outside of the `procedural_memory` special case, Mem0 does not sort memories into named types. Every memory is scoped by the identifiers you pass in, and the same identifiers are used to retrieve it later:
- **`user_id`**: ties a memory to a specific person or account.
- **`agent_id`**: ties a memory to a specific agent or assistant persona.
- **`run_id`**: ties a memory to a specific session, task, or conversation thread.
- **`app_id`** (Platform only): ties a memory to a specific application or tenant, in addition to the three above. See <Link href="/platform/features/entity-scoped-memory">Entity-Scoped Memory</Link>.
At least one identifier is required on `add()`. Passing more than one narrows the scope further (for example, `user_id` + `run_id` together).
```python
from mem0 import Memory
memory = Memory()
memory.add(
"I'm Alex and I prefer boutique hotels.",
user_id="alex",
run_id="trip-planning-2025",
)
results = memory.search(
"Any hotel preferences?",
filters={"user_id": "alex", "run_id": "trip-planning-2025"},
)
```
<Tip>
Use `run_id` when you want a set of memories to stay tied to one session or task; use `user_id` alone for anything that should persist across every session for that person.
</Tip>
## How memories are extracted and updated
When `infer=True` (the default) on `add()`, Mem0 runs a single pipeline rather than routing through separate type-specific paths:
1. **Context gathering**: pulls the most recent messages already stored for the same `user_id`/`agent_id`/`run_id` scope.
2. **Existing memory retrieval**: embeds the new messages and runs a vector search against memories already in that same scope, to find candidates that might need to change.
3. **Extraction**: a single LLM call compares the new messages against the retrieved candidates and decides, per fact, whether to `ADD`, `UPDATE`, `DELETE`, or leave a memory alone.
Alongside this, both OSS and Platform extract named entities (people, places, organizations) from memory text and use shared entities between memories to boost related results at search time. On Platform, that entity graph is also queryable directly; see <Link href="/platform/features/graph-memory">Graph Memory</Link>. In OSS, entities only affect ranking, there is no separate graph to query.
<Warning>
Avoid storing secrets or unredacted PII in memories: they are retrievable by design. Encrypt or hash sensitive values before calling `add()`.
</Warning>
## Put it into practice
<CardGroup cols={2}>
<Card
title="Explore Memory Operations"
description="Dive into the add/search/update/delete operations next."
icon="circle-check"
href="/core-concepts/memory-operations/add"
/>
<Card
title="Advanced Memory Operations"
description="Tune metadata, filters, and retrieval on Platform."
icon="sliders"
href="/platform/advanced-memory-operations"
/>
<Card
title="AI Tutor Cookbook"
description="See user_id-scoped memory used in a real tutoring agent."
icon="rocket"
href="/cookbooks/companions/ai-tutor"
/>
<Card
title="Support Inbox Cookbook"
description="See user_id-scoped memory used in a support workflow."
icon="inbox"
href="/cookbooks/operations/support-inbox"
/>
</CardGroup>
+26 -3
View File
@@ -54,7 +54,6 @@
"icon": "brain",
"pages": [
"core-concepts/how-it-works",
"core-concepts/memory-types",
"core-concepts/memory-operations/add",
"core-concepts/memory-operations/search",
"core-concepts/memory-operations/update",
@@ -72,6 +71,7 @@
"pages": [
"platform/features/v2-memory-filters",
"platform/features/entity-scoped-memory",
"platform/features/user-profiles",
"platform/features/graph-memory",
"platform/features/async-client",
"platform/features/multimodal-support",
@@ -366,6 +366,13 @@
{
"tab": "Agent Plugins",
"groups": [
{
"group": "Overview",
"icon": "puzzle-piece",
"pages": [
"integrations/agent-plugins"
]
},
{
"group": "Coding Agents",
"icon": "terminal",
@@ -444,7 +451,8 @@
"cookbooks/integrations/mastra-agent",
"cookbooks/integrations/healthcare-google-adk",
"cookbooks/integrations/aws-bedrock",
"cookbooks/integrations/tavily-search"
"cookbooks/integrations/tavily-search",
"cookbooks/integrations/supabase"
]
},
{
@@ -512,6 +520,17 @@
"api-reference/entities/delete-user"
]
},
{
"group": "Profiles",
"icon": "id-card",
"pages": [
"api-reference/profiles/get-profile",
"api-reference/profiles/get-profile-settings",
"api-reference/profiles/update-profile-settings",
"api-reference/profiles/generate-profiles",
"api-reference/profiles/get-profile-job"
]
},
{
"group": "Organizations",
"icon": "building",
@@ -1020,7 +1039,11 @@
},
{
"source": "/concepts/memory-scoring",
"destination": "/core-concepts/memory-types"
"destination": "/core-concepts/how-it-works"
},
{
"source": "/core-concepts/memory-types",
"destination": "/core-concepts/how-it-works"
},
{
"source": "/cookbooks/research-copilot",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 424 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Cursor</title><rect x="0.25" y="0.25" width="23.5" height="23.5" rx="5.5" fill="#161616" stroke="#3a3a3a" stroke-width="0.5"/><g transform="translate(4.000 4.000) scale(0.66667)" fill="#ffffff" fill-rule="evenodd"><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"/></g></svg>

After

Width:  |  Height:  |  Size: 671 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Kimi</title><rect x="0.25" y="0.25" width="23.5" height="23.5" rx="5.5" fill="#161616" stroke="#3a3a3a" stroke-width="0.5"/><g transform="translate(4 4) scale(0.66667)"><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></g></svg>

After

Width:  |  Height:  |  Size: 900 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>opencode</title><rect x="0.25" y="0.25" width="23.5" height="23.5" rx="5.5" fill="#161616" stroke="#3a3a3a" stroke-width="0.5"/><g transform="translate(4.000 4.000) scale(0.66667)" fill="#ffffff" fill-rule="evenodd"><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Pi</title><rect x="0.25" y="0.25" width="23.5" height="23.5" rx="5.5" fill="#161616" stroke="#3a3a3a" stroke-width="0.5"/><g transform="translate(4.000 4.000) scale(0.02857)" fill="#ffffff" fill-rule="evenodd"><path d="M420 280H280V140H0V0H420V280Z"/><path d="M560 560H420V280H560V560Z"/><path d="M140 560H0V140H140V280H280V420H140V560Z"/></g></svg>

After

Width:  |  Height:  |  Size: 439 B

+111
View File
@@ -0,0 +1,111 @@
---
title: "Agent Plugins Overview"
sidebarTitle: "Overview"
description: "Add persistent memory to the coding agent or agent harness you already use. Compare the Mem0 plugins and pick yours."
---
Coding agents forget everything when a session ends: your conventions, the bug you fixed yesterday, the command that finally worked. A Mem0 plugin fixes that inside the tool you already use. It captures what matters while you work and brings it back in the next session.
<Info>
Plugins use a [Mem0 Platform](https://app.mem0.ai?utm_source=oss&utm_medium=agent-plugins-overview) account. OpenClaw and Hermes can also run fully self-hosted.
</Info>
## How a plugin works
<Steps>
<Step title="Capture">
While you work, the plugin records your prompts, the agent's answers and useful tool results. Credentials are redacted before anything leaves your machine.
</Step>
<Step title="Extract">
Mem0 turns each session into short, durable memories: project conventions, decisions, fixes that worked, and your personal preferences.
</Step>
<Step title="Recall">
In the next session, relevant memories are added to the agent's context automatically or found with a search tool, depending on the plugin.
</Step>
</Steps>
## Coding agents
<CardGroup cols={3}>
<Card title="Claude Code" icon="/images/provider-icons/claudecode-color.svg" href="/integrations/claude-code">
Automatic capture and recall, six commands, and the Sidekick agent.
</Card>
<Card title="Codex" icon="/images/provider-icons/codex-color.svg" href="/integrations/codex">
Automatic capture and recall in the Codex CLI and app.
</Card>
<Card title="Cursor" icon="/images/provider-icons/cursor.svg" href="/integrations/cursor">
Automatic capture, with recall through a search tool.
</Card>
<Card title="OpenCode" icon="/images/provider-icons/opencode.svg" href="/integrations/opencode">
Recall before every prompt and ten native memory tools.
</Card>
<Card title="Kimi Code" icon="/images/provider-icons/kimi-color.svg" href="/integrations/kimi">
Automatic capture and recall in the Kimi Code CLI.
</Card>
<Card title="Antigravity" icon="/images/provider-icons/antigravity-color.svg" href="/integrations/antigravity">
Automatic capture, with recall through a search tool.
</Card>
</CardGroup>
## Agent harnesses and chat apps
<CardGroup cols={3}>
<Card title="OpenClaw" icon="/images/provider-icons/openclaw.svg" href="/integrations/openclaw">
Recall and capture on every turn. Platform or self-hosted.
</Card>
<Card title="Hermes Agent" icon="/images/provider-icons/hermes-tile.svg" href="/integrations/hermes">
Mem0 as Hermes' memory provider. Platform or self-hosted.
</Card>
<Card title="Pi Agent" icon="/images/provider-icons/pi.svg" href="/integrations/pi-agent">
Recall before every turn and automatic capture.
</Card>
<Card title="DeepSeek Harness" icon="/images/provider-icons/deepseek-color.svg" href="/integrations/deepseek-plugin">
Automatic recall and capture with two native tools.
</Card>
<Card title="Claude.ai" icon="/images/provider-icons/claude-color.svg" href="/integrations/claude-ai">
Add the verified Mem0 connector from Claude's directory and sign in. No API key needed.
</Card>
<Card title="Any MCP client" icon="plug" href="/platform/mem0-mcp">
Your tool is not listed? Connect it to the hosted Mem0 MCP server.
</Card>
</CardGroup>
## Compare plugins
<Tabs>
<Tab title="Coding agents">
| Plugin | Auto capture | Auto recall | Skills | Team |
| --- | --- | --- | --- | --- |
| [Claude Code](/integrations/claude-code) | Yes | First prompt | 6, plus Sidekick | Shared |
| [Codex](/integrations/codex) | Yes | First prompt | 6 | Shared |
| [Cursor](/integrations/cursor) | Yes | Search tool | 6 | Shared |
| [Kimi Code](/integrations/kimi) | Yes | First prompt | 6 | Shared |
| [Antigravity](/integrations/antigravity) | Yes | Search tool | 6 | Shared |
| [OpenCode](/integrations/opencode) | Yes | Every prompt | 7 | Per user |
**First prompt:** relevant memories are added before the agent answers the first prompt of a session. **Search tool:** the agent calls `search_memories` when it needs context. **Shared:** teammates on the same repo share project memory.
</Tab>
<Tab title="Harnesses and chat apps">
| Plugin | Auto capture | Auto recall | Tools | Self-hosted |
| --- | --- | --- | --- | --- |
| [OpenClaw](/integrations/openclaw) | Yes | Every turn | Tools and skills | Yes |
| [Hermes Agent](/integrations/hermes) | Yes | Every turn | 4 | Yes |
| [Pi Agent](/integrations/pi-agent) | Yes | Every turn | 1 | No |
| [DeepSeek Harness](/integrations/deepseek-plugin) | Yes | Every prompt | 2 | No |
| [Claude.ai](/integrations/claude-ai) | When asked | When relevant | 9 | No |
</Tab>
</Tabs>
<Note>
Claude Code, Codex, Cursor, Kimi Code and Antigravity share the same memory core. Teammates working in the same repository, in any of these tools, read and write one shared project memory while their personal preferences stay private.
</Note>
## Which plugin should I use?
- **You code in a terminal or IDE agent:** install the plugin for that tool from the cards above. Each page has its own install steps.
- **Your team uses different agents on the same repo:** pick any of Claude Code, Codex, Cursor, Kimi Code or Antigravity. They share project memory across tools.
- **You chat in Claude:** add the [Mem0 connector](https://claude.com/connectors/mem0) from Claude's directory.
- **Your tool has no plugin:** connect it to the [hosted Mem0 MCP server](/platform/mem0-mcp).
- **You need to keep data on your own infrastructure:** use OpenClaw or Hermes in self-hosted mode.
<Snippet file="star-on-github.mdx" />
+3 -3
View File
@@ -130,10 +130,10 @@ print(response.msgs[0].content)
<CardGroup cols={2}>
<Card
title="Memory types in Mem0"
description="Choose between chat history and semantic search for your Camel agents."
title="How Mem0 works"
description="Understand how Mem0 extracts, stores, and retrieves memories for your Camel agents."
icon="sparkles"
href="/core-concepts/memory-types"
href="/core-concepts/how-it-works"
/>
<Card
title="Try LangChain next"
+30
View File
@@ -172,6 +172,36 @@ claude plugin update mem0@mem0-plugins --scope user
| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. |
| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` |
## Telemetry
The plugin sends usage events (which hook ran, timing, result counts, failure
types) so Mem0 can see what's used and what's breaking.
These events are **not anonymous**. When an API key is configured, which
installing the plugin requires, they are sent under your Mem0 account email,
the same way the Python SDK and the CLI attribute theirs. Without a key they
are sent under a random per-machine id.
Each event carries the event name, the plugin version, the harness it ran in,
your OS and Python version, and per-event properties describing what happened:
timings, counts, coarse outcome and failure labels, and which model was
configured. Repository and session identifiers are hashed with a random salt
generated on your machine, so they cannot be linked back to a repository name
or path.
The exact set is enforced in code rather than by this list: every property is
filtered through a denylist of sensitive keys and credential-shaped values are
redacted before anything is sent.
Prompts, memory text, queries, file paths, repository names, and API keys are
never sent.
Turn it off:
```bash
export MEM0_TELEMETRY=false
```
<CardGroup cols={2}>
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
Detailed MCP configuration for all clients
+1 -1
View File
@@ -114,7 +114,7 @@ Both tools also accept optional per-call `userId`, `agentId`, and `runId` params
## Telemetry
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to this integration. Anonymous usage events include operation names, durations, result counts, and coarse failure kinds. Queries, memory text, entity IDs, and API keys are never included. Set `MEM0_TELEMETRY=false` to opt out.
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to this integration. Usage events include operation names, durations, result counts, and coarse failure kinds. They are **not anonymous**: when an API key is configured they are sent under your Mem0 account email, the same way the SDK attributes its own. Queries, memory text, entity IDs, and API keys are never included. Set `MEM0_TELEMETRY=false` to opt out.
<Note>
This plugin is a developer preview and tracks the evolving DeepSeek Harness plugin API.
+116 -92
View File
@@ -1,9 +1,9 @@
---
title: Hermes Agent
description: "Add long-term memory to Hermes agents using Mem0 Platform, a self-hosted server, or local OSS mode with background fact extraction."
description: "Add persistent memory to Hermes Agent with Mem0 Cloud, a self-hosted server, or the in-process OSS SDK."
---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones for the current question, without slowing down the chat.
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. The [standalone Mem0 plugin](https://github.com/mem0ai/mem0/tree/main/integrations/hermes-plugin-mem0) learns facts from conversations and recalls relevant memories for the current question.
You can run Mem0 in three ways:
@@ -17,11 +17,15 @@ Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alon
### 1. Current-turn recall (bounded wait)
When you send a message, Hermes searches your stored memories for the current question and waits up to 3 seconds for results. If they arrive in time, they are injected into the system prompt so the model can see them. If the backend is slower, Hermes skips the injection and the model can still call `mem0_search` itself — so a slow backend never blocks a turn.
When you send a message, Hermes searches your stored memories for the current question and waits up to 3 seconds for results. If they arrive in time, they are injected into the system prompt so the model can see them. If the backend is slower, Hermes skips the injection and the model can still call `mem0_search` itself after the bounded recall wait.
### 2. Background fact extraction (sync)
Once the model finishes, Hermes sends the `(user message, assistant response)` pair to Mem0 in a background thread. Mem0 extracts facts automatically (for example, "user prefers Python" or "user works at Acme Corp"), so you never have to tell it what to remember. Each write is tagged with the gateway channel it came from.
Once the model finishes, the plugin sends the user message and assistant response to Mem0 in a background thread for fact extraction. Each write includes the agent identifier and gateway channel.
<Note>
Automatic capture truncates each message to **450 characters by default in every mode**, preferring a sentence boundary. Adjust `sync_max_chars` for your model's context limit. Capture is best effort: if the previous sync is still running after a five-second wait, the next turn is skipped. Use `mem0_add` to store specific text verbatim.
</Note>
## Agent Tools
@@ -29,21 +33,33 @@ When Mem0 is active, the model gets four tools it can call during a conversation
| Tool | Description | Parameters |
|------|-------------|------------|
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `false`, Platform mode only) |
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (uses the configured default, Platform mode only) |
| `mem0_add` | Store a fact verbatim, with no LLM extraction | `content` (required) |
| `mem0_update` | Update a memory's text by ID | `memory_id`, `text` (both required) |
| `mem0_delete` | Delete a memory by ID | `memory_id` (required) |
## Installation
Install Hermes Agent:
Install [Hermes Agent](https://github.com/NousResearch/hermes-agent) with memory-provider plugin support and Python 3.11 or later. Once the plugin directory is available on Mem0's main branch, install it from the repository subdirectory:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
source ~/.bashrc
hermes plugins install mem0ai/mem0/integrations/hermes-plugin-mem0
hermes plugins enable mem0
hermes memory setup
hermes memory status
```
The `mem0ai` package is installed automatically when you enable the Mem0 provider, so there is no manual pip step. OSS providers may need extra packages (for example `qdrant-client`, `psycopg2-binary`, or `ollama`), which the setup flow installs for you when you pick them.
Select **mem0** in setup and choose one of the modes below. Start a fresh Hermes conversation after setup.
Hermes installers with plugin dependency support install `mem0ai>=2.0.10,<3` and `httpx>=0.27,<1` from the plugin's `pyproject.toml`. Older hosts such as Hermes v0.21.3 require those packages to be installed explicitly into the Hermes Python environment. The OSS setup flow installs additional provider packages as needed.
<Note>
Hermes versions that still bundle Mem0 prefer the bundled provider. Use a Hermes release that has completed the standalone-provider migration; installing this plugin alone does not replace the bundled implementation. Existing users should keep their current configuration; see [Migration for existing users](#migration-for-existing-users).
</Note>
<Note>
Run the setup wizard in an interactive terminal. On Hermes hosts whose `hermes memory setup --help` lists only a provider argument, options such as `--mode`, `--host`, and `--oss-llm` are rejected by Hermes before the plugin runs. Use `hermes memory setup mem0` or the manual configuration below. Redirected input cannot select the mode picker and falls back to Platform.
</Note>
## Platform Setup
@@ -52,10 +68,10 @@ Platform mode uses managed Mem0 Cloud and is the fastest way to start.
### Option 1: Interactive wizard (recommended)
```bash
hermes memory setup
hermes memory setup mem0
```
Select **mem0**, choose **Platform**, and paste your API key when prompted. The wizard writes the non-secret settings to `~/.hermes/mem0.json` and keeps the key in `~/.hermes/.env`.
Choose **Platform** and paste your API key when prompted. The wizard writes settings to `$HERMES_HOME/mem0.json` and keeps the key in that profile's `.env`. The default Hermes home is `~/.hermes`; named profiles use their own home directory.
<Note>Get your API key from <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-hermes">app.mem0.ai</a>.</Note>
@@ -63,17 +79,25 @@ Select **mem0**, choose **Platform**, and paste your API key when prompted. The
```bash
hermes config set memory.provider mem0
echo "MEM0_API_KEY=your-api-key" >> ~/.hermes/.env
```
Then in your `config.yaml`:
Add your key to the active Hermes profile's `.env`:
```yaml
memory:
provider: mem0
```dotenv
MEM0_API_KEY=your-api-key
```
That's it. Mem0 runs automatically from here.
Set these values in the active profile's `mem0.json`, choosing a stable user identity:
```json
{
"mode": "platform",
"host": "",
"user_id": "my-hermes-user"
}
```
Remove any stale `MEM0_HOST` from the environment and profile `.env`, and remove an old inline `api_key` from `mem0.json` so the new `.env` key is used. The config command sets `memory.provider: mem0` in that profile's `config.yaml`. Restart Hermes and check `hermes memory status`.
## Self-Hosted Server Setup
@@ -82,48 +106,47 @@ Run the [Mem0 server](https://github.com/mem0ai/mem0/tree/main/server) (FastAPI
### Interactive
```bash
hermes memory setup
# Select "mem0", then "Self-hosted server", and enter the server URL
hermes memory setup mem0
# Choose "Self-hosted server", then enter the server URL and API key
```
### With flags
### Manual configuration
```bash
hermes memory setup mem0 --mode selfhosted \
--host http://localhost:8888 \
--api-key your-admin-api-key
Select `mem0` with `hermes config set memory.provider mem0`. Set these values in the active profile's `mem0.json`:
```json
{
"mode": "platform",
"host": "http://localhost:8888",
"user_id": "my-hermes-user"
}
```
### With environment variables
Add the server key to that profile's `.env`:
```bash
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
```dotenv
MEM0_API_KEY=your-admin-api-key
```
Remove an old inline `api_key` from `mem0.json` so the `.env` key is used. `MEM0_HOST` can also supply the server URL, but a non-empty `host` in `mem0.json` overrides it. Keep `mode` set to `platform` for the HTTP server backend.
Then start a fresh Hermes session and call `mem0_search` — it connects to your server. The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. The API key is optional only for servers running with `AUTH_DISABLED`.
<Note>Setting `host` routes to the self-hosted server automatically. Don't combine it with `mode: oss` — OSS takes precedence and ignores `host`.</Note>
## OSS (Self-Hosted) Setup
OSS mode runs Mem0 entirely on your own infrastructure: your LLM, your embedder, and your vector store. No data is sent to Mem0 Cloud, and no Mem0 API key is required.
OSS mode runs the Mem0 SDK in the Hermes process with your chosen LLM, embedder, and vector store. It does not use Mem0 Cloud or require a Mem0 API key. Data goes to the model services you configure; use local Ollama models and local storage for a fully local setup.
### Interactive
```bash
hermes memory setup
# Select "mem0", then "Open Source (self-hosted)"
hermes memory setup mem0
# Choose "Open Source"
# Follow the prompts for LLM, embedder, and vector store
```
### With flags
```bash
hermes memory setup mem0 --mode oss \
--oss-llm openai --oss-llm-key sk-... \
--oss-vector qdrant
```
The wizard uses the listed default OpenAI models and local Qdrant storage. For custom OpenAI-compatible endpoints, deployment names, or a Qdrant server, use manual configuration below.
### Supported providers
@@ -133,52 +156,24 @@ hermes memory setup mem0 --mode oss \
| Embedder | `openai` (default `text-embedding-3-small`), `ollama` (local, default `nomic-embed-text`) |
| Vector store | `qdrant` (local path or server), `pgvector` |
### Flag reference
| Flag | Description |
|------|-------------|
| `--mode` | `platform`, `selfhosted`, or `oss` |
| `--api-key` | Platform API key, or the admin key of a self-hosted server |
| `--host` | Self-hosted server URL (with `--mode selfhosted`) |
| `--oss-llm` | LLM provider (`openai` or `ollama`, default `openai`) |
| `--oss-llm-key` | LLM API key (for `openai`) |
| `--oss-llm-model` | Override the LLM model |
| `--oss-llm-url` | LLM base URL (for `ollama` or a custom endpoint) |
| `--oss-embedder` | Embedder provider (default `openai`) |
| `--oss-embedder-key` | Embedder API key |
| `--oss-embedder-model` | Override the embedder model |
| `--oss-embedder-url` | Embedder base URL (for `ollama` or a custom endpoint) |
| `--oss-vector` | Vector store (`qdrant` or `pgvector`, default `qdrant`) |
| `--oss-vector-path` | Local Qdrant storage path |
| `--oss-vector-url` | Qdrant server URL |
| `--oss-vector-host`, `--oss-vector-port` | PGVector or remote Qdrant host and port |
| `--oss-vector-user`, `--oss-vector-password`, `--oss-vector-dbname` | PGVector connection details |
| `--user-id` | Canonical user identifier |
| `--dry-run` | Preview the resolved config without writing it |
## Switching Modes
You can move between the three modes at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
### Manual configuration
```bash
# Platform to OSS
hermes memory setup mem0 --mode oss --oss-llm-key sk-...
# OSS to Platform
hermes memory setup mem0 --mode platform --api-key sk-...
# Platform to a self-hosted server
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888
# Preview without writing anything
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
hermes config set memory.provider mem0
```
A self-hosted `~/.hermes/mem0.json` looks like this:
Add the model key to the active profile's `.env`:
```dotenv
OPENAI_API_KEY=your-model-api-key
```
Set the following in that profile's `mem0.json`. Use your existing storage path when migrating; for a new named profile, choose a path inside that profile's home.
```json
{
"mode": "oss",
"user_id": "my-hermes-user",
"oss": {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini", "is_reasoning_model": true}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
@@ -187,33 +182,65 @@ A self-hosted `~/.hermes/mem0.json` looks like this:
}
```
For an OpenAI-compatible service such as Azure's `/openai/v1` endpoint, add `OPENAI_BASE_URL` to the profile's `.env` and set each `model` to its deployed name. Both the LLM and embedder use this endpoint unless their `config.openai_base_url` overrides it. The main Hermes chat model is configured separately; this JSON configures Mem0's extraction and embedding models.
For a Qdrant server, replace `vector_store.config.path` with `url`, for example `"url": "http://localhost:6333"`. Manual setup does not install optional provider dependencies: install `qdrant-client`, `psycopg2-binary`, or `ollama` in the **Hermes Python environment** as needed for your selected providers. Start a fresh session and verify a memory write and search; `hermes memory status` reports configuration availability, not a full backend health check.
Desktop sessions in the same process and profile share local Qdrant storage when their OSS settings match. Operations are serialized, and storage closes after the last session releases it. Conflicting settings are rejected without changing existing memories; close active sessions before changing models or credentials. For concurrent CLI and Desktop processes, use a Qdrant server or the self-hosted Mem0 HTTP API instead of sharing a local directory.
## Switching Modes
Run `hermes memory setup mem0` in an interactive terminal and choose the new mode, or edit the active profile's `mem0.json` using the examples above. Switching backends does not transfer memories between them. Preserve existing OSS storage paths when editing configuration. When returning to Platform, set `mode` to `platform`, clear `host`, and remove any stale `MEM0_HOST` setting from your environment and profile `.env`.
## Configuration
Behavioral settings live in `~/.hermes/mem0.json` and are written for you by `hermes memory setup`. Only the secret `MEM0_API_KEY` belongs in `~/.hermes/.env`.
Settings live in `$HERMES_HOME/mem0.json` and are written by `hermes memory setup`. API keys normally live in that profile's `.env`; distinct OpenAI LLM/embedder keys and database credentials are stored in the OSS configuration. Setup writes these files atomically with owner-only permissions.
When editing these files manually, restrict both `.env` and `mem0.json` to their owner (`chmod 600` on Unix). Keep configuration and secrets in the same active Hermes profile.
`MEM0_MODE`, `MEM0_HOST`, `MEM0_USER_ID`, and `MEM0_AGENT_ID` supply environment defaults. Non-empty values in `mem0.json` take precedence. `MEM0_API_KEY` supplies the Cloud or server key unless `api_key` is set in the file.
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process). Self-hosted server routing is set via `host` |
| `host` | none | Self-hosted Mem0 server URL. When set, the plugin talks HTTP to your server instead of the cloud |
| `api_key` | none | Mem0 Platform API key, or the admin key of a self-hosted server. Stored in `.env` as `MEM0_API_KEY` |
| `user_id` | `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `user_id` | gateway user ID, then `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `agent_id` | `hermes` | Agent identifier attached to writes |
| `rerank` | `false` | Rerank search results for relevance (Platform mode only) |
| `rerank` | `false` | Platform reranking for recall and tool searches that omit `rerank` |
| `sync_max_chars` | `450` | Per-message character cap for automatic fact extraction in every mode |
| `oss` | `{}` | OSS LLM, embedder, and vector-store configuration |
### Cross-channel memories
Hermes can run from the CLI and from gateways like Telegram, Slack, and Discord. The `user_id` setting controls how memories are scoped across them:
- **Set a `user_id`** and it applies to every gateway, so one person gets a single merged memory store no matter where they talk to the agent.
- **Leave it unset** (or at the default `hermes-user`) and each gateway uses its own native id, keeping per-platform memories separate.
- **Set a `user_id` other than `hermes-user`** and it applies to every gateway, so one person gets a single merged memory store no matter where they talk to the agent.
- **Leave it unset** (or at the default `hermes-user`) and each gateway uses its own native ID when available, falling back to `hermes-user`.
Either way, every write is tagged with `metadata.channel` (for example `telegram` or `cli`), so per-channel views are still possible at query time.
Every write is tagged with `metadata.channel` (for example `telegram` or `cli`). Plugin searches filter by user identity across sessions; they do not restrict recall to the current channel or session.
## Migration for Existing Users
Keep `memory.provider: mem0`, `mem0.json`, `MEM0_*` settings, user identity, and OSS database paths unchanged. Moving from the bundled provider to this standalone plugin does not require rerunning setup or moving stored memories.
Automatic migration depends on Hermes rollout as well as this repository:
1. Users need a Hermes build containing [PR #114569](https://github.com/NousResearch/hermes-agent/pull/114569).
2. Hermes maintainers must approve a catalog entry named `mem0` with `repo: https://github.com/mem0ai/mem0`, `subdir: integrations/hermes-plugin-mem0`, and a reviewed full commit SHA.
3. The bundled Mem0 provider must be removed so the standalone provider can load.
With these in place, Hermes installs a missing configured provider during `hermes update` across profiles or at agent startup. Startup installation respects `security.allow_lazy_installs`. Offline or disabled installation needs manual action; merging the plugin directory alone does not complete automatic migration.
CLI setup and status are supported. This plugin does not ship a Desktop configuration panel or provider-specific CLI commands.
## Reliability
- **Circuit breaker**: if Mem0 fails five times in a row, Hermes pauses calls for two minutes, then retries. The agent keeps working without memory during that window. Expected client errors, like a 404 on a missing memory id, do not count toward tripping the breaker.
- **Non-blocking**: fact extraction runs in a background daemon thread, and current-turn recall waits at most 3 seconds, so a slow or failed call never blocks your conversation.
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and recall threads are guarded so concurrent gateway messages cannot produce duplicate memories.
- **Circuit breaker**: five consecutive backend failures pause calls for two minutes. The agent can continue without memory during that window. Expected update/delete errors such as a missing memory do not trip the breaker.
- **Bounded waits**: recall waits up to three seconds. Capture runs in the background, but an overlapping turn may wait up to five seconds for the previous sync before being skipped.
- **Graceful shutdown**: shutdown and Python process exit wait for active recall and capture workers before closing the backend. Backend network timeouts still apply. Self-hosted HTTP capture uses a 120-second read timeout and a 30-second connection timeout; other self-hosted HTTP operations use 30 seconds.
- **Best-effort capture**: there is no durable queue. Forced termination, including Hermes' 30-second exit watchdog, can interrupt pending writes even while graceful shutdown is waiting.
- **OSS data protection**: an embedding dimension mismatch fails initialization without deleting the existing collection or table.
## Troubleshooting
@@ -248,15 +275,12 @@ curl http://localhost:11434/api/tags
- `mem0_add` stores text verbatim with no extraction. Ordinary conversation turns are extracted automatically by the background sync.
- Search is semantic, so try a broader query.
- Confirm `user_id` is the same across sessions (check `~/.hermes/mem0.json`).
- Confirm `user_id` is the same across sessions (check `$HERMES_HOME/mem0.json`).
- Check `sync_max_chars`: facts beyond the per-message limit are not sent for extraction.
## Key Features
### OSS: embedding dimension mismatch
1. **Three ways to run**: managed Platform, a self-hosted server, or fully local OSS, switchable at any time.
2. **Current-turn recall**: memories for the current question are injected within a 3-second window, with `mem0_search` as the model's own backstop.
3. **Automatic extraction**: Mem0 extracts and deduplicates facts from each exchange for you.
4. **Non-blocking and fault tolerant**: background threads plus a circuit breaker keep the agent responsive even when Mem0 is unreachable.
5. **Additive memory**: works alongside Hermes' built-in file memory (`MEMORY.md`, `USER.md`).
Restore the embedding model and dimensions that created the existing collection, or choose a new collection and migrate data explicitly. The plugin leaves the original collection intact when dimensions differ.
<CardGroup cols={2}>
<Card title="OpenClaw Integration" icon="/images/provider-icons/openclaw.svg" href="/integrations/openclaw">
+1 -1
View File
@@ -45,7 +45,7 @@ memory_from_client = Mem0Memory.from_client(
)
```
Context is used to identify the user, agent or the conversation in the Mem0. It is required to be passed in the at least one of the fields in the `Mem0Memory` constructor. It can be any of the following:
Context is used to identify the user, agent or the conversation in the Mem0. It is required to be passed in at least one of the fields in the `Mem0Memory` constructor. It can be any of the following:
```python
context = {
+3 -1
View File
@@ -481,7 +481,9 @@ Plugin config is stored in `~/.openclaw/openclaw.json` with file permissions `0o
### Telemetry
Anonymous usage telemetry (PostHog) is enabled by default to help improve the plugin. No conversation content or memory values are included, only event counts (recall, capture, tool usage, CLI commands).
Usage telemetry (PostHog) is enabled by default to help improve the plugin. No conversation content or memory values are included, only event counts (recall, capture, tool usage, CLI commands).
These events are **not anonymous**. OpenClaw does not send your account email the way the SDK does, but it does send an unsalted SHA-256 hash of it, falling back to a hash of the API key and then to a random per-machine id. Mem0 holds the email the hash is derived from, so the hash identifies your account rather than concealing it. The first run that resolves an account also emits a PostHog `$identify`, which permanently merges any earlier random id into that identity.
To opt out, set the environment variable:
+9 -2
View File
@@ -186,7 +186,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
## Core Concepts
- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/entity/history stores, and multi-signal retrieval.
- [Memory Types](https://docs.mem0.ai/core-concepts/memory-types) [Both]: Use when checking which `memory_type` values actually work: `procedural_memory` is implemented, `semantic_memory` and `episodic_memory` are defined in the enum but rejected by validation.
- [Memory Operations - Add](https://docs.mem0.ai/core-concepts/memory-operations/add) [Both]: Use when explaining how `add()` extracts facts, resolves conflicts, and writes to both stores.
- [Memory Operations - Search](https://docs.mem0.ai/core-concepts/memory-operations/search) [Both]: Use when explaining how queries are processed and ranked.
- [Memory Operations - Update](https://docs.mem0.ai/core-concepts/memory-operations/update) [Both]: Use when memories need to be edited in place or reconciled against new info.
@@ -198,6 +197,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
### Features - Essential
- [V2 Memory Filters](https://docs.mem0.ai/platform/features/v2-memory-filters) [Platform]: Use when compound filters (AND/OR on metadata, entity, time) are needed at search.
- [Entity-Scoped Memory](https://docs.mem0.ai/platform/features/entity-scoped-memory) [Platform]: Use when partitioning memories by user, agent, app, or run.
- [Profiles](https://docs.mem0.ai/platform/features/user-profiles) [Platform]: Use when a structured always-current summary of a user is needed in one read, instead of searching their memories.
- [Graph Memory](https://docs.mem0.ai/platform/features/graph-memory) [Platform]: Use when connecting facts across memories through shared entities for entity-centric or multi-hop questions.
- [Async Client](https://docs.mem0.ai/platform/features/async-client) [Platform]: Use when the app issues many concurrent Mem0 calls and needs non-blocking I/O.
- [Multimodal Support](https://docs.mem0.ai/platform/features/multimodal-support) [Platform]: Use when storing images or PDFs as memory input.
@@ -256,7 +256,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
- [Agno](https://docs.mem0.ai/integrations/agno) [Platform]: Use when the user is on Agno.
- [Camel AI](https://docs.mem0.ai/integrations/camel-ai) [Both]: Use when the user is on Camel AI.
- [ChatDev](https://docs.mem0.ai/integrations/chatdev) [Platform]: Use when the user is on ChatDev.
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when the user is on Hermes.
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when installing or configuring the standalone Hermes memory plugin, or migrating from the bundled Mem0 provider.
- [Pi Agent](https://docs.mem0.ai/integrations/pi-agent) [Platform]: Use when adding automatic capture, prompt recall, scoped memory, and six memory commands to Pi Agent.
- [DeepSeek Harness](https://docs.mem0.ai/integrations/deepseek-plugin) [Platform]: Use when adding automatic recall, completed-turn capture, and native search/add tools to DeepSeek Harness.
- [OpenAI Agents SDK](https://docs.mem0.ai/integrations/openai-agents-sdk) [Platform]: Use when the user is on the OpenAI Agents SDK.
@@ -268,6 +268,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
- [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore.
### AI Coding Tools
- [Agent Plugins Overview](https://docs.mem0.ai/integrations/agent-plugins) [Platform]: Use when choosing a Mem0 plugin for a coding agent or agent harness, or comparing what each plugin captures and recalls.
- [Claude Code](https://docs.mem0.ai/integrations/claude-code) [Platform]: Use when wiring memory into Claude Code.
- [Claude.ai](https://docs.mem0.ai/integrations/claude-ai) [Platform]: Use when connecting Mem0 to Claude.ai (the hosted web app) via a custom remote MCP connector, or when Claude's native memory seems to be crowding out mem0 tool calls.
- [Cursor](https://docs.mem0.ai/integrations/cursor) [Platform]: Use when adding lifecycle capture, explicit memory recall, and six memory skills to Cursor.
@@ -326,6 +327,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
- [Healthcare Google ADK](https://docs.mem0.ai/cookbooks/integrations/healthcare-google-adk) [Platform]: Use when the domain is medical and the framework is Google ADK.
- [AWS Bedrock](https://docs.mem0.ai/cookbooks/integrations/aws-bedrock) [OSS]: Use when deploying with AWS managed model services.
- [Tavily Search](https://docs.mem0.ai/cookbooks/integrations/tavily-search) [Platform]: Use when the agent layers web search on memory.
- [Company Brain (Mem0 Platform + Supabase)](https://docs.mem0.ai/cookbooks/integrations/supabase) [Platform]: Use to build a shared org brain on Mem0 Platform with Supabase as system of record and the MCP server as the access layer (with a new-hire onboarding demo).
### Framework Examples
- [LlamaIndex React](https://docs.mem0.ai/cookbooks/frameworks/llamaindex-react) [Both]: Use when building a React UI with LlamaIndex and memory.
@@ -363,6 +365,11 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key).
### Entities
- [Get Users](https://docs.mem0.ai/api-reference/entities/get-users) [Platform]: Use when listing users, agents, or apps known to a project.
- [Delete User](https://docs.mem0.ai/api-reference/entities/delete-user) [Platform]: Use when removing an entity and all its memories.
- [Get Profile](https://docs.mem0.ai/api-reference/profiles/get-profile) [Platform]: Use when reading a user's structured profile and branching on its generation status.
- [Get Profile Settings](https://docs.mem0.ai/api-reference/profiles/get-profile-settings) [Platform]: Use when checking the project's profile schema, instructions, or enabled flag.
- [Update Profile Settings](https://docs.mem0.ai/api-reference/profiles/update-profile-settings) [Platform]: Use when defining or changing the JSON Schema that shapes profiles for a project.
- [Generate Profiles](https://docs.mem0.ai/api-reference/profiles/generate-profiles) [Platform]: Use when building profiles now: a sample of ten, or one entity.
- [Get Generation Job](https://docs.mem0.ai/api-reference/profiles/get-profile-job) [Platform]: Use when checking how far a generation has got, and whether it finished.
### Organizations
- [Create Organization](https://docs.mem0.ai/api-reference/organization/create-org) [Platform]: Use when setting up a new org.
+475 -1
View File
@@ -8070,6 +8070,480 @@
}
}
}
},
"/v2/entities/{entity_type}/{entity_id}/profile/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_read",
"summary": "Get an entity's profile",
"description": "Return the memory profile for one user.\n\nGeneration is asynchronous, so a known entity that has no profile yet is a normal 200 carrying a `status`. A 404 means only that no such entity exists.",
"parameters": [
{
"name": "entity_type",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"user"
]
},
"description": "The kind of entity that carries the profile."
},
{
"name": "entity_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The entity's id, as supplied when the memory was added."
}
],
"responses": {
"200": {
"description": "The profile envelope.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"profile": {
"type": "object",
"additionalProperties": true,
"description": "The generated profile, shaped by the project's schema. Empty unless status is succeeded."
},
"status": {
"type": "string",
"enum": [
"succeeded",
"pending",
"failed",
"not_enabled",
"insufficient_data"
],
"description": "Generation state. Branch on this rather than on an empty profile."
},
"entity_type": {
"type": "string",
"enum": [
"user"
]
},
"entity_id": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"generation_count": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "Unsupported entity type."
},
"404": {
"description": "No such entity in this project."
}
}
}
},
"/v2/profiles/settings/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_settings_read",
"summary": "Get profile settings",
"description": "Return the profile settings for the project the API key is scoped to.",
"responses": {
"200": {
"description": "Current settings.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Settings for user profiles, under `user`.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
}
}
},
"capabilities": {
"type": "object",
"properties": {
"jobs": {
"type": "boolean"
},
"estimates": {
"type": "boolean"
},
"samples": {
"type": "boolean"
},
"full_rebuild": {
"type": "boolean",
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
}
}
}
}
}
}
}
}
}
},
"post": {
"tags": [
"profiles"
],
"operationId": "profiles_settings_update",
"summary": "Update profile settings",
"description": "Update the project's profile settings. Only the fields present in the body are written, so one setting can change without re-sending the others.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Settings for user profiles, under `user`.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step. Send null to clear it."
}
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Settings as stored after the update.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether profile generation runs for this project. Project-wide."
},
"entities": {
"type": "object",
"description": "Settings for user profiles, under `user`.",
"properties": {
"user": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"additionalProperties": true,
"nullable": true,
"description": "JSON Schema describing the profile. Every property needs a description."
},
"custom_instructions": {
"type": "string",
"nullable": true,
"description": "Extra guidance for the extraction step."
}
}
}
}
},
"capabilities": {
"type": "object",
"properties": {
"jobs": {
"type": "boolean"
},
"estimates": {
"type": "boolean"
},
"samples": {
"type": "boolean"
},
"full_rebuild": {
"type": "boolean",
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
}
}
}
}
}
}
}
},
"400": {
"description": "The schema is not a valid profile schema."
}
}
}
},
"/v2/profiles/jobs/": {
"post": {
"tags": [
"profiles"
],
"operationId": "profiles_create_job",
"summary": "Generate profiles",
"description": "Start one generation. `operation` says what to build:\n\n- `sample` — up to 10 real entities, so a schema can be judged before it is used widely. These are real profiles: they are saved to those entities and count toward usage.\n- `trigger` — one entity, named by `entity_id`.\n\nSend an `Idempotency-Key` header. Replaying the same key returns the same job instead of charging twice. Poll `status_url` from the response until the status is terminal.",
"parameters": [
{
"in": "header",
"name": "Idempotency-Key",
"required": true,
"schema": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"description": "Makes a retry safe: the same key returns the same job."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"operation",
"entity_type"
],
"properties": {
"operation": {
"type": "string",
"enum": [
"sample",
"trigger"
],
"description": "What to generate. Optional only when `entity_id` is set, which means `trigger`."
},
"entity_type": {
"type": "string",
"enum": [
"user"
]
},
"entity_id": {
"type": "string",
"description": "One entity, for `trigger`."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"description": "How many entities to sample."
}
}
}
}
}
},
"responses": {
"202": {
"description": "Job accepted.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"job_id": {
"type": "string"
},
"status": {
"type": "string"
},
"status_url": {
"type": "string",
"description": "Poll this. Building the path yourself breaks on a route change."
},
"operation": {
"type": "string"
},
"entity_type": {
"type": "string"
},
"entity_count_reserved": {
"type": "integer",
"description": "Entities reserved against usage for this job."
},
"event_id": {
"type": "string",
"nullable": true
},
"replayed": {
"type": "boolean",
"description": "True when an Idempotency-Key returned an existing job."
},
"sampled": {
"type": "integer",
"description": "`sample` only."
},
"entity_ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "`sample` only: the entity ids picked. Read each with `GET /v2/entities/user/{entity_id}/profile/`."
}
}
}
}
}
},
"400": {
"description": "Unknown or missing `operation`, or profiles are not configured."
},
"402": {
"description": "Payment required."
},
"409": {
"description": "A job is already running, or the Idempotency-Key was used for a different request. Branch on `error.code`."
},
"429": {
"description": "Cooldown. `retry_after_seconds` sits inside `error`."
},
"503": {
"description": "`jobs_unavailable` — generation is switched off for this project."
}
}
}
},
"/v2/profiles/jobs/{job_id}/": {
"get": {
"tags": [
"profiles"
],
"operationId": "profiles_get_job",
"summary": "Read a generation job",
"description": "The job nests under `job`. `total` is null until `enumeration_complete`, and `completed` is `succeeded + failed + skipped`.",
"parameters": [
{
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The job.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"job": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"operation": {
"type": "string"
},
"entity_type": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"QUEUED",
"RUNNING",
"SUCCEEDED",
"PARTIALLY_SUCCEEDED",
"FAILED",
"CANCELLED"
]
},
"total": {
"type": "integer",
"nullable": true
},
"enumeration_complete": {
"type": "boolean"
},
"completed": {
"type": "integer"
},
"succeeded": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"skipped": {
"type": "integer"
}
}
}
}
}
}
}
},
"404": {
"description": "No such job in this project."
}
}
}
}
},
"components": {
@@ -8988,4 +9462,4 @@
}
},
"x-original-swagger-version": "2.0"
}
}
+359
View File
@@ -0,0 +1,359 @@
---
title: Profiles
description: "Build a structured, always-current summary of each user from their memories, shaped by a JSON Schema you define."
---
# Profiles
Memories are individual facts. A profile is the summary of all of them for one entity: a single structured object, shaped by a JSON Schema you define, that Mem0 keeps current as new memories arrive.
Search answers "what did this user say about X". A profile answers "who is this user", in one read, with no query to write.
<Info>
**Use profiles when…**
- You want to personalize a first response, before the user says anything in this session.
- You need a compact object to drop into a prompt instead of a list of memories.
- You want the same fields for every user, so your code can rely on their shape.
</Info>
<Note>
User Profiles are in **beta** and available on request. To enable them for your
organization, contact [support@mem0.ai](mailto:support@mem0.ai).
</Note>
## How it works
1. You define a **schema**: the fields a profile should contain, each with a description.
2. Mem0 builds each entity's profile from their memories, and rebuilds it as new memories arrive.
3. You read the profile whenever you need it.
Generation is **asynchronous**. A profile is not ready the instant an entity's first memory lands, so a read tells you where it is with a `status` rather than failing.
## Define the schema
The schema is JSON Schema. Every property needs a `description` — that is what tells the model how to fill the field, so a vague description gives a vague profile.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient()
client.update_profile_settings(
enabled=True,
schema={
"type": "object",
"properties": {
"communication_style": {
"type": "string",
"description": "How the user prefers to be addressed: terse, detailed, formal, casual",
},
"expertise_areas": {
"type": "array",
"items": {"type": "string"},
"description": "Subjects the user demonstrates working knowledge of",
},
"current_goals": {
"type": "array",
"items": {"type": "string"},
"description": "What the user is actively trying to accomplish",
},
},
},
custom_instructions="Prefer durable traits over one-off remarks.",
)
```
```typescript TypeScript
import MemoryClient from "mem0ai";
const client = new MemoryClient({ apiKey: "your-api-key" });
await client.updateProfileSettings({
enabled: true,
schema: {
type: "object",
properties: {
communication_style: {
type: "string",
description:
"How the user prefers to be addressed: terse, detailed, formal, casual",
},
expertise_areas: {
type: "array",
items: { type: "string" },
description: "Subjects the user demonstrates working knowledge of",
},
current_goals: {
type: "array",
items: { type: "string" },
description: "What the user is actively trying to accomplish",
},
},
},
customInstructions: "Prefer durable traits over one-off remarks.",
});
```
</CodeGroup>
<Note>
Your schema's property names reach the API exactly as you write them. The SDKs do not rewrite them, so a profile always comes back with the field names you chose.
</Note>
Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone.
## Read a profile
<CodeGroup>
```python Python
result = client.get_profile("alice")
if result["status"] == "succeeded":
print(result["profile"])
else:
print("not ready:", result["status"])
```
```typescript TypeScript
const result = await client.getProfile({ entityId: "alice" });
if (result.status === "succeeded") {
console.log(result.profile);
} else {
console.log("not ready:", result.status);
}
```
</CodeGroup>
A response looks like this:
```json
{
"profile": {
"communication_style": "terse",
"expertise_areas": ["distributed systems", "postgres"],
"current_goals": ["cut p99 latency", "migrate off the legacy queue"]
},
"status": "succeeded",
"entity_type": "user",
"entity_id": "alice",
"updated_at": "2026-02-08T10:30:00Z",
"generation_count": 3
}
```
`generation_count` is how many times this profile has been (re)generated — `0` before the first generation completes.
### Always branch on `status`
`profile` is empty unless `status` is `succeeded`. Check the status rather than the emptiness of the object, so a profile that is merely still building is not mistaken for a user you know nothing about.
| `status` | Meaning | What to do |
|---|---|---|
| `succeeded` | Profile is built and current | Use it |
| `pending` | Generation is queued or running | Read again shortly |
| `insufficient_data` | Not enough memories to say anything yet | Fall back to defaults |
| `not_enabled` | Profiles are off for this project | Enable them in settings |
| `failed` | The last generation did not complete | Retry, or trigger a new one |
A `404` means only that no such entity exists in your project.
## Generate a profile on demand
Profiles are built once an entity has accumulated enough messages, so a brand-new user has none during their first few interactions. Trigger one directly to close that gap:
<CodeGroup>
```python Python
client.generate_profile("alice")
```
```typescript TypeScript
await client.generateProfile({ entityId: "alice" });
```
</CodeGroup>
The call returns as soon as the work is queued. Poll the read endpoint and branch on `status`.
## Test a schema before applying it
A schema that reads well can still produce disappointing profiles. Sample a few real entities and inspect the output before committing to it.
Sampling is asynchronous: the call returns a job as soon as it is queued. Poll `status_url` until the job is terminal, then read each sampled entity's profile:
<CodeGroup>
```python Python
import time
job = client.sample_profiles(limit=5)
# Poll until the sample job reaches a terminal state (job status is UPPERCASE).
TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"}
deadline = time.time() + 120
while True:
status = client.get_profile_job(job["status_url"])["job"]
if status["status"] in TERMINAL:
break
if time.time() > deadline:
raise TimeoutError("Sample job did not finish in time")
time.sleep(3)
print(status["status"], status["succeeded"], "of", status["total"])
# The create response lists the sampled entities; read each one's saved profile.
for entity_id in job.get("entity_ids", []):
print(client.get_profile(entity_id))
```
```typescript TypeScript
const job = await client.sampleProfiles({ limit: 5 });
// Poll until the sample job reaches a terminal state (job status is UPPERCASE).
const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"];
const deadline = Date.now() + 120_000;
let status;
while (true) {
status = (await client.getProfileJob(job.statusUrl)).job;
if (TERMINAL.includes(status.status)) break;
if (Date.now() > deadline)
throw new Error("Sample job did not finish in time");
await new Promise((resolve) => setTimeout(resolve, 3000));
}
console.log(status.status, status.succeeded, "of", status.total);
// The create response lists the sampled entities; read each one's saved profile.
for (const entityId of job.entityIds ?? []) {
console.log(await client.getProfile({ entityId }));
}
```
</CodeGroup>
These are real generations. The profiles are saved to those entities and count toward your usage, so sampling is not wasted work and not a free dry run. A sample covers up to 10 entities and cannot be repeated immediately.
## Apply a new schema to existing entities
A new schema shapes the next generation. Profiles that already exist keep their values until their entity is generated again.
Each entity picks the new schema up as it sends more memories, and you can generate one now with `generate_profile`.
<Note>
Rebuilding every profile in a project at once is not available yet. Refresh profiles one entity at a time with `generate_profile`, or let each one update on its own as its entity sends more memories.
</Note>
## When profiles update
You never call an "update profile" endpoint — Mem0 keeps each profile current for you. Two things drive it:
- **Automatically, as memories accumulate.** Mem0 refreshes an entity's profile after roughly every **10 messages** it receives, folding the new memories into the existing profile. There is no schedule to wait for and no extra call to make: the same `add` you already do keeps the profile moving.
- **On demand.** Call `generate_profile` to build or refresh a profile immediately — useful for a brand-new entity that has not yet crossed the automatic threshold.
Generation is **asynchronous and incremental**. A refresh runs in the background a short while after its trigger, so a read taken immediately after an `add` may still show the previous profile (or `pending`). Branch on `status` rather than assuming the latest memory is already reflected.
<Note>
Updates are **incremental**, not a full rebuild each time — Mem0 merges what it newly learns into the stored profile and keeps the fields your schema still defines. After a schema change, existing profiles pick it up as their entities send more memories, or when you call `generate_profile` — see [Apply a new schema to existing entities](#apply-a-new-schema-to-existing-entities).
</Note>
## Use a profile in a prompt
The point of the structure is that it drops straight into a prompt:
```python
result = client.get_profile(user_id)
if result["status"] == "succeeded":
profile = result["profile"]
system_prompt = f"""You are helping {user_id}.
Communication style: {profile.get("communication_style", "unknown")}
Areas of expertise: {", ".join(profile.get("expertise_areas", []))}
Current goals: {", ".join(profile.get("current_goals", []))}
Match their style and do not explain what they already know."""
else:
system_prompt = "You are a helpful assistant."
```
## Writing a schema that works
- **Describe every field.** The description is the instruction; without it the model guesses.
- **Prefer durable traits.** "Prefers dark mode" ages well; "is annoyed today" does not.
- **Keep it small.** Ten focused fields beat forty speculative ones, and cost less to generate.
- **Say what the field is not.** A description that rules out the near-miss interpretation is worth more than one that only states the obvious.
- **Sample before you commit.** It is the only way to see what your descriptions actually produce.
<Note>
A schema has a size budget of roughly **10,000 tokens** of serialized JSON — the whole schema is sent to the model on every generation, so a handful of verbose fields can cost more than many terse ones. Oversized schemas are rejected on save.
</Note>
## Availability
The feature is in beta and enabled per organization on request — see the note at the top of this page.
Once it is on, an entity gets a profile when two more things hold:
- profiles are **enabled** with a schema for the project (see [Define the schema](#define-the-schema)), and
- the memory is scoped to an entity — a `user_id`.
On a project where profiles are turned off, a read returns `status: not_enabled` rather than an error, so you can call it unconditionally and branch on the status.
## Settings reference
| Argument | Type | Description |
|---|---|---|
| `enabled` | boolean | Whether profile generation runs for the project |
| `schema` | object | JSON Schema describing the profile. Every property needs a `description` |
| `custom_instructions` | string | Extra guidance applied during extraction |
`enabled` is project-wide. `schema` and `custom_instructions` apply to user
profiles, so the stored settings nest them under `entities`:
```json
{
"enabled": true,
"entities": {
"user": {
"schema": { "type": "object", "properties": { "...": {} } },
"custom_instructions": "Prefer durable traits over one-off remarks."
}
},
"capabilities": { "full_rebuild": false }
}
```
That is what a read returns and what a write accepts. The SDKs take the fields
flat and nest them for you, so a schema you write with
`update_profile_settings` comes back unchanged from `get_profile_settings`.
<Note>
Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
</Note>
## FAQ
**Do I need to change my `add` or `search` calls to use profiles?**
No. Profiles are built from the memories you already add. You define a schema once and read the profile when you need it — your ingestion and retrieval code is unchanged.
**Why is `profile` empty even though the entity has memories?**
Generation is asynchronous and needs enough to work with. Branch on `status`: `pending` means it is still building, and `insufficient_data` means there are not yet enough memories to fill the schema. Read again shortly, or call `generate_profile` to build one now.
**Is sampling free?**
No. `sample_profiles` runs real generations against real memories and **keeps** the profiles it produces, so it counts toward your usage like any other generation. It exists to check a schema on a few entities before you commit to it — not as a zero-cost dry run.
**Does changing the schema rewrite existing profiles?**
No. A schema change applies to the next generation. An existing profile keeps its values until its entity is generated again, which happens as that entity sends more memories, or when you call `generate_profile` for it.
**What happens to a field I remove from the schema?**
It stops being maintained. On an entity's next generation, fields your schema no longer defines are pruned from the stored profile — so keep a field in the schema for as long as you want its value kept.
**How current is a profile?**
It refreshes automatically as memories accumulate (about every 10 messages for an entity), plus any on-demand `generate_profile` calls. Because refreshes run in the background, expect a short delay after the triggering `add` rather than an instant update.
## Related
<CardGroup cols={2}>
<Card title="Entity-Scoped Memory" icon="users" href="/platform/features/entity-scoped-memory">
How users, agents, apps and runs partition memories.
</Card>
<Card title="Custom Instructions" icon="pen" href="/platform/features/custom-instructions">
Steer what Mem0 extracts in the first place.
</Card>
</CardGroup>
+2 -2
View File
@@ -50,8 +50,8 @@ For the full pipeline, see [How Mem0 works](/core-concepts/how-it-works).
<Card title="Run the quickstart" icon="rocket" href="/platform/quickstart">
Get an API key and save your first memory.
</Card>
<Card title="Understand memory types" icon="brain" href="/core-concepts/memory-types">
How user, agent, app, and run memory differ.
<Card title="Scope your memories" icon="brain" href="/platform/features/entity-scoped-memory">
Organize memories by user, agent, app, and run.
</Card>
<Card title="Add, search, and update" icon="layer-group" href="/core-concepts/memory-operations/add">
The core memory operations, end to end.
+1 -1
View File
@@ -39,7 +39,7 @@ The core memory loop is identical on both: `add`, `search`, `get`, `get_all`, `u
- **Entity scoping** by `user_id`, `agent_id`, and `run_id`
- **Filter grouping**: both accept `AND`/`OR`/`NOT` wrappers, both implicitly AND a flat multi-key filter like `{"user_id": "alice", "agent_id": "a1"}`, and both accept `*` as a wildcard value. Which fields you may filter on, and which operators each field accepts, differ (see below)
- **Entity-aware ranking**: both extract entities from memory text and use shared entities to boost related results at search time
- **Multimodal input**, **memory expiration** (`expiration_date`), **reranking**, **procedural memory** (Python), and **custom extraction instructions** (`custom_instructions`)
- **Multimodal input**, **memory expiration** (`expiration_date`), **reranking**, and **custom extraction instructions** (`custom_instructions`)
- Python and JavaScript SDKs, plus a REST API (self-hosted via `server/`, or hosted)
## What's actually different
+1 -1
View File
@@ -4,7 +4,7 @@ description: "Standard layout for documenting Mem0 API endpoints."
icon: "code"
---
# Api Reference Template
# API Reference Template
API reference pages document a single endpoint contract. Present metadata, request/response examples, and recovery guidance without narrative detours.
+1 -1
View File
@@ -124,7 +124,7 @@ Walk through a real request/response. Include sample payloads and highlight nota
{/* DEBUG: verify CTA targets */}
<CardGroup cols={2}>
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/memory-types">
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/how-it-works">
Understand how Mem0 ranks memories under the hood.
</Card>
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/operations/deep-research">
+2 -4
View File
@@ -38,7 +38,6 @@ client.memories.add(
user_id: str,
memory: str,
metadata: Optional[dict] = None,
memory_type: Literal["session", "long_term"] = "session",
)
```
@@ -47,13 +46,13 @@ await mem0.memories.add({
userId: string;
memory: string;
metadata?: Record<string, string>;
memoryType?: "session" | "long_term";
});
```
</CodeGroup>
<Info>
Defaults to session memories. Override `memory_type` for long-term storage.
[Describe defaults supported by this operation and SDK. Do not infer a
memory type or retention policy from a scoping identifier.]
</Info>
<Warning>
@@ -67,7 +66,6 @@ await mem0.memories.add({
| `user_id` | string | Yes | Unique identifier for the end user. | Must match follow-up operations. |
| `memory` | string | Yes | Content to persist. | Managed & OSS. Markdown allowed. |
| `metadata` | object | No | Key-value pairs for filters. | OSS stores as JSONB; limit to 2KB. |
| `memory_type` | string | No | Retention bucket | Platform supports `shared`. |
<Tip>
Set `ttl_seconds` when you need memories to expire automatically (OSS only).
+2 -2
View File
@@ -96,8 +96,8 @@ time. Storage: vector embeddings.
**Architecture Overview:**
- Memory is scoped by user_id, agent_id, or run_id
- Core operations: add, search, update, delete
- Memory types: factual (preferences, facts), episodic (past interactions),
semantic (concept relationships), working (session state)
- Store preferences, facts, and past interactions; use run_id to scope a
session. Platform does not expose a memory_type selector.
- Integration pattern: retrieve relevant memories → generate response → store
new memories
+3 -3
View File
@@ -45,11 +45,11 @@ grok_client = OpenAI(
def recommend_movie_with_memory(user_id: str, user_query: str):
# Retrieve prior memory about movies
past_memories = memory.search("movie preferences", user_id=user_id)
past_memories = memory.search("movie preferences", filters={"user_id": user_id})
prompt = user_query
if past_memories:
prompt += f"\nPreviously, the user mentioned: {past_memories}"
if past_memories["results"]:
prompt += f"\nPreviously, the user mentioned: {[m['memory'] for m in past_memories['results']]}"
# Generate movie recommendation using Grok 3
response = grok_client.chat.completions.create(model="grok-3-beta", messages=[{"role": "user", "content": prompt}])
@@ -198,7 +198,7 @@ def search_memory_tool(query: str, user_id: str = "user") -> str:
Relevant vector memories found or message if none found
"""
try:
results = m.search(query, user_id=user_id)
results = m.search(query, filters={"user_id": user_id})
if isinstance(results, dict) and 'results' in results:
memory_list = results['results']
@@ -245,7 +245,7 @@ def search_graph_memory_tool(query: str, user_id: str = "user") -> str:
"""
try:
graph_query = f"relationships connections {query}"
results = m.search(graph_query, user_id=user_id)
results = m.search(graph_query, filters={"user_id": user_id})
if isinstance(results, dict) and 'results' in results:
memory_list = results['results']
@@ -290,7 +290,7 @@ def get_all_memories_tool(user_id: str = "user") -> str:
All memories for the user or message if none found
"""
try:
all_memories = m.get_all(user_id=user_id)
all_memories = m.get_all(filters={"user_id": user_id})
if isinstance(all_memories, dict) and 'results' in all_memories:
memory_list = all_memories['results']
+5 -5
View File
@@ -107,16 +107,16 @@ def main():
for query in search_queries:
print(f"\nQuery: {query}")
memories = memory.search(query=query, user_id="user_123")
memories = memory.search(query=query, filters={"user_id": "user_123"})
for memory_item in memories:
for memory_item in memories["results"]:
print(f" - {memory_item['memory']}")
print("\n--> Getting all memories for user...")
all_memories = memory.get_all(user_id="user_123")
print(f"Total memories stored: {len(all_memories)}")
all_memories = memory.get_all(filters={"user_id": "user_123"})
print(f"Total memories stored: {len(all_memories['results'])}")
for memory_item in all_memories:
for memory_item in all_memories["results"]:
print(f" - {memory_item['memory']}")
print("\n--> vLLM integration demo completed successfully!")
+764
View File
@@ -0,0 +1,764 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# User Profiles — live demo\n",
"\n",
"A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n",
"user's memories, shaped by a JSON Schema you supply.\n",
"\n",
"Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n",
"user\"*, in one read, with no query to write — and it is available on the first turn of a\n",
"session, before the user has said anything.\n",
"\n",
"**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n",
"generated from them, add 6 more turns that contradict the first set, and watch the\n",
"profile rewrite itself. Then it shows every way the API says no.\n",
"\n",
"**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n",
"\n",
"> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n",
"> production. The cells below read both from the environment.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"> **Use a disposable project.** This notebook overwrites the project's profile settings\n",
"> (enabled, schema, custom instructions). The last cell restores the values saved at the\n",
"> start, but only if you reach it: if a cell fails midway, the project keeps the demo\n",
"> schema until you run the cleanup cell or reset it yourself. Do not point it at a\n",
"> project other people or production traffic depend on.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# This notebook drives the SDK from this worktree, not the published mem0ai:\n",
"# the profile fixes below are not released yet.\n",
"%pip install -q -e ../..\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Define the schema\n",
"\n",
"The schema is handed to the model as a **tool definition**, and each field's\n",
"`description` is the only instruction the model gets about what belongs there. An\n",
"undescribed field is a field the model guesses at.\n",
"\n",
"Rules worth knowing:\n",
"\n",
"- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n",
" it would bill you to extract nothing\n",
"- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n",
" they name the storage envelope, so a schema using them could not be read back\n",
" unambiguously\n",
"- keep it small. The whole schema is sent to the model on every generation\n",
"\n",
"Descriptions are **not** enforced on write in this build — a property without one is\n",
"accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n",
"Treat descriptions as your job, not the validator's.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"SCHEMA = {\n",
" \"type\": \"object\",\n",
" \"properties\": {\n",
" \"occupation\": {\n",
" \"type\": \"string\",\n",
" \"description\": \"The person's current job title, in one short phrase.\",\n",
" },\n",
" \"location\": {\n",
" \"type\": \"string\",\n",
" \"description\": \"The city or region the person currently lives in.\",\n",
" },\n",
" \"interests\": {\n",
" \"type\": \"array\",\n",
" \"items\": {\"type\": \"string\"},\n",
" \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n",
" },\n",
" \"dietary_restrictions\": {\n",
" \"type\": \"array\",\n",
" \"items\": {\"type\": \"string\"},\n",
" \"description\": \"Foods the person avoids, and why, if they said.\",\n",
" },\n",
" \"communication_style\": {\n",
" \"type\": \"string\",\n",
" \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n",
" \"description\": \"How this person prefers to be answered.\",\n",
" },\n",
" \"expertise_level\": {\n",
" \"type\": \"string\",\n",
" \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n",
" \"description\": \"Their technical depth, judged from how they discuss their work.\",\n",
" },\n",
" },\n",
"}\n",
"\n",
"settings = client.update_profile_settings(\n",
" enabled=True,\n",
" schema=SCHEMA,\n",
" custom_instructions=(\n",
" \"Prefer facts the person stated outright over anything inferred. \"\n",
" \"Leave a field empty rather than guessing.\"\n",
" ),\n",
")\n",
"\n",
"# Sorted, because JSONB storage does not preserve the key order you sent.\n",
"# Compare a stored schema by SET, never by string or by key order.\n",
"stored = settings[\"entities\"][\"user\"][\"schema\"]\n",
"print(\"schema fields:\", sorted(stored[\"properties\"]))\n",
"print(\"enabled :\", settings[\"enabled\"])\n",
"print(\"capabilities :\", settings[\"capabilities\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"`enabled` is project-wide; `schema` and `custom_instructions` apply to user profiles\n",
"and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n",
"you write comes back unchanged from `get_profile_settings()`.\n",
"\n",
"Only the arguments you pass are written. To turn the feature off without touching your\n",
"schema, send `enabled` alone.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. The turns\n",
"\n",
"Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n",
"side effect of the normal pipeline.\n",
"\n",
"Twelve, not five, because generation fires when an entity crosses a **10-message\n",
"boundary**. Below that it waits for a flush window measured in hours, and this notebook\n",
"would sit there.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"TURNS = [\n",
" (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n",
" (\"assistant\", \"Congratulations! What's the new role?\"),\n",
" (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n",
" (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n",
" (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n",
" (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n",
" (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n",
" (\"assistant\", \"Understood — short answers from here.\"),\n",
" (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n",
" (\"assistant\", \"Bouldering or ropes?\"),\n",
" (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n",
" (\"assistant\", \"Noted, vegetarian only.\"),\n",
"]\n",
"\n",
"response = client.add(\n",
" [{\"role\": r, \"content\": c} for r, c in TURNS],\n",
" user_id=USER_ID,\n",
")\n",
"print(json.dumps(response, indent=2)[:300])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n",
"`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n",
"you know the add finished. Then let the extracted memories settle.\n",
"\n",
"Under load this can take a minute or more, so the cell says plainly whether it ran out of\n",
"time rather than printing `0 memories` as though that were the answer.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"event_id = response[\"event_id\"]\n",
"deadline = time.time() + 300\n",
"\n",
"# 1. The add itself. Terminal status, not a sleep.\n",
"event_status = None\n",
"while time.time() < deadline:\n",
" event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n",
" if event_status in (\"SUCCEEDED\", \"FAILED\"):\n",
" break\n",
" print(f\" add {event_status}\")\n",
" time.sleep(5)\n",
"print(f\"add finished: {event_status}\")\n",
"# Stop here unless the add SUCCEEDED. A failed or unfinished add would otherwise let the\n",
"# generation below bill for a profile built without these memories.\n",
"if event_status != \"SUCCEEDED\":\n",
" raise RuntimeError(f\"add did not succeed (status={event_status}); not generating a profile\")\n",
"\n",
"# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n",
"# Wait for the count to stop growing instead of breaking on the first result.\n",
"memories, stable = [], 0\n",
"while time.time() < deadline:\n",
" page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n",
" found = page.get(\"results\", []) if isinstance(page, dict) else page\n",
" stable = stable + 1 if found and len(found) == len(memories) else 0\n",
" memories = found\n",
" if stable >= 2: # two identical polls in a row\n",
" break\n",
" print(f\" ... {len(memories)} so far\")\n",
" time.sleep(5)\n",
"\n",
"if memories:\n",
" print(f\"\\n{len(memories)} memories extracted:\\n\")\n",
" for m in memories:\n",
" print(\" \\u2022\", m.get(\"memory\"))\n",
"else:\n",
" # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n",
" # and makes the profile below look like it came from nothing.\n",
" print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n",
" print(\"worker is down. Everything below will report insufficient_data.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Read the profile\n",
"\n",
"Crossing the 10-message boundary should already have queued a generation. Read first —\n",
"and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n",
"distinction is the whole point of the envelope.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"envelope = client.get_profile(USER_ID)\n",
"print(json.dumps(envelope, indent=2))\n",
"\n",
"print(\"\\nstatus vocabulary:\")\n",
"print(\" succeeded terminal — a generation ran AND the profile has content\")\n",
"print(\" pending queued or running\")\n",
"print(\" failed terminal — the last generation did not complete\")\n",
"print(\" not_enabled feature off, or plan below Pro\")\n",
"print(\" insufficient_data no content to show: no row yet, queued, or a\")\n",
"print(\" generation that legitimately found nothing\")\n",
"print()\n",
"print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n",
"print(\"empty extraction still increments the counter, so counting generations\")\n",
"print(\"reports 'done' for a profile with nothing in it.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Force it, rather than waiting\n",
"\n",
"`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n",
"until their tenth message. One entity, a few seconds.\n",
"\n",
"Each call sends a new `Idempotency-Key` unless you pass one, and a new key starts a new job.\n",
"To retry a dropped request safely, generate the key yourself and pass the same\n",
"`idempotency_key` on every attempt: the server then returns the original job instead of\n",
"billing a second one.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n",
"\n",
"\n",
"def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n",
" \"\"\"Poll until terminal.\n",
"\n",
" `since` waits for a generation_count ABOVE that value, which is how you wait\n",
" for an UPDATE rather than accepting the profile you already had.\n",
"\n",
" `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n",
" poll through it and give up on the timeout instead.\n",
" \"\"\"\n",
" deadline = time.time() + timeout\n",
" body = None\n",
" while time.time() < deadline:\n",
" body = client.get_profile(entity_id)\n",
" status = (body.get(\"status\") or \"\").lower()\n",
" count = body.get(\"generation_count\") or 0\n",
" fresh = count > since if since is not None else True\n",
" if status == \"succeeded\" and fresh:\n",
" return body\n",
" if status in (\"failed\", \"not_enabled\"):\n",
" raise RuntimeError(f\"generation stopped: {status}\")\n",
" print(f\" ... {status} (generation_count={count})\")\n",
" time.sleep(interval)\n",
" raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n",
"\n",
"\n",
"print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
"print(\"\\npolling...\")\n",
"\n",
"try:\n",
" body = wait_for_profile(USER_ID)\n",
" print(\"\\n=== PROFILE ===\")\n",
" print(json.dumps(body[\"profile\"], indent=2))\n",
" print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n",
"except TimeoutError as e:\n",
" # Say so plainly and let the rest of the notebook skip, rather than raising\n",
" # a NameError in every cell below and burying the real cause.\n",
" body = None\n",
" print(f\"\\nNO PROFILE: {e}\")\n",
" print(\"Generation never finished. Usually the ingestion worker is down, or\")\n",
" print(\"this project has no memories for the user yet.\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# The model must not invent fields outside your schema — the forced tool call is\n",
"# what makes that structural rather than a request.\n",
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n",
" print(\"fields outside the schema:\", extra or \"none\")\n",
"\n",
" # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n",
" # so 'I found nothing' arrives as a type default: 0, \"\", [].\n",
" filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n",
" print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Now watch it update\n",
"\n",
"Six more turns that contradict and extend what we already know: a promotion, a move, a\n",
"dropped hobby. A profile is a living document, not an append-only log — the model gets the\n",
"memories and rewrites the whole thing.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" before = body[\"generation_count\"]\n",
"\n",
" MORE_TURNS = [\n",
" (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n",
" (\"assistant\", \"Congratulations. Same team?\"),\n",
" (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n",
" (\"assistant\", \"Big move. How do you feel about it?\"),\n",
" (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n",
" (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n",
" ]\n",
"\n",
" followup = client.add(\n",
" [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n",
" user_id=USER_ID,\n",
" )\n",
"\n",
" # Wait for the add to land before triggering: a generation queued before the new\n",
" # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n",
" deadline = time.time() + 300\n",
" status = None\n",
" while time.time() < deadline:\n",
" status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n",
" if status in (\"SUCCEEDED\", \"FAILED\"):\n",
" break\n",
" time.sleep(5)\n",
" print(\"follow-up add:\", status)\n",
" # Generating after a FAILED or unfinished add bills for a profile built from the OLD\n",
" # memories only, so stop instead.\n",
" if status != \"SUCCEEDED\":\n",
" raise RuntimeError(f\"follow-up add did not succeed (status={status}); not regenerating\")\n",
" time.sleep(15) # let extraction settle\n",
"\n",
" print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
" print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n",
" updated = wait_for_profile(USER_ID, since=before)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if body is None:\n",
" print(\"skipped — no profile was generated above\")\n",
"else:\n",
" print(f\"{'field':<22} {'before':<34} after\")\n",
" print(\"-\" * 92)\n",
" for field in SCHEMA[\"properties\"]:\n",
" b = json.dumps(body[\"profile\"].get(field))\n",
" a = json.dumps(updated[\"profile\"].get(field))\n",
" mark = \" \" if a == b else \"->\"\n",
" print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Use it in a prompt\n",
"\n",
"The point of the structure is that it drops straight into a prompt — no list of memories\n",
"to summarize, no query to write.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def build_system_prompt(entity_id):\n",
" result = client.get_profile(entity_id)\n",
" if result[\"status\"] != \"succeeded\":\n",
" # Branch on status, never on an empty profile: a user whose profile is\n",
" # still building is not a user you know nothing about.\n",
" return \"You are a helpful assistant.\"\n",
"\n",
" p = result[\"profile\"]\n",
" return f\"\"\"You are helping {entity_id}.\n",
"Occupation: {p.get(\"occupation\", \"unknown\")}\n",
"Location: {p.get(\"location\", \"unknown\")}\n",
"Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n",
"Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n",
"Preferred style: {p.get(\"communication_style\", \"unknown\")}\n",
"\n",
"Match their style and do not explain what they already know.\"\"\"\n",
"\n",
"\n",
"print(build_system_prompt(USER_ID))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Judge a schema before committing to it\n",
"\n",
"`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n",
"\n",
"These are real generations and the results are **kept** — a dry run would cost exactly the\n",
"same and leave those users no better off. It is not a free preview.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 202, not 200: the sample generations are queued, not finished.\n",
"#\n",
"# A 409 `already_running` means a sample from an earlier run is still going.\n",
"# That is the cooldown working, not an error — reuse that job rather than\n",
"# failing the notebook.\n",
"try:\n",
" job = client.sample_profiles(limit=3)\n",
" print(json.dumps(job, indent=2)[:400])\n",
" print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n",
"except Exception as e:\n",
" detail = str(e)\n",
" print(\"sample refused:\", detail[:200])\n",
" running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n",
" job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n",
" print(\"reusing the running job:\", running)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n",
"so format it defensively rather than assuming a number.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Apply a new schema to existing users\n",
"\n",
"A new schema shapes the **next** generation. Profiles that already exist keep their values\n",
"until their user is generated again — which happens as that user sends more memories, or\n",
"when you call `generate_profile()` for them.\n",
"\n",
"A field you **remove** stops being maintained: on the next generation, fields your schema\n",
"no longer defines are pruned. Keep a field for as long as you want its value kept."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"# Failure scenarios\n",
"\n",
"Everything above is the path that works. These are the ways it says no, and what each one\n",
"means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n",
"\n",
"> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n",
"> ERROR level before raising, so they appear even for the failures these cells\n",
"> deliberately catch. Read the line printed *after* each one — that is the cell's\n",
"> own verdict. Nothing here is unhandled.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F1. Schemas that get rejected\n",
"\n",
"Rejections happen on **write**, where you can see and fix them — not silently at\n",
"generation time, where you would only notice as an empty profile weeks later.\n",
"\n",
"The last case matters for storage: the user schema lives in one JSONB column alongside\n",
"the envelope that separates it, so a schema using the envelope's own reserved keys could\n",
"not be read back unambiguously. It is refused rather than stored.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"BAD_SCHEMAS = [\n",
" ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n",
" ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n",
" (\n",
" {\n",
" \"type\": \"object\",\n",
" \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n",
" # At the schema ROOT, which is where the envelope's own keys live.\n",
" \"_profile_config_version\": 1,\n",
" \"entities\": {\"user\": {}},\n",
" },\n",
" \"reserved settings keys at the schema root\",\n",
" ),\n",
"]\n",
"\n",
"for bad, why in BAD_SCHEMAS:\n",
" try:\n",
" client.update_profile_settings(schema=bad)\n",
" print(f\"ACCEPTED (unexpected): {why}\")\n",
" except Exception as e:\n",
" print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n",
"\n",
"# NOT rejected: a property with no description. The validator allows it and the\n",
"# model then has nothing to go on, so the field comes back empty. Descriptions are\n",
"# your job, not the validator's.\n",
"try:\n",
" client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n",
" print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n",
"finally:\n",
" client.update_profile_settings(schema=SCHEMA) # put the good one back\n",
"\n",
"restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n",
"assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n",
"print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F2. A user that does not exist\n",
"\n",
"404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n",
"`insufficient_data`, so an ordinary empty state never looks like an error.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from mem0.exceptions import MemoryNotFoundError\n",
"\n",
"try:\n",
" client.get_profile(\"user_who_never_existed\")\n",
" print(\"ACCEPTED (unexpected)\")\n",
"except MemoryNotFoundError as e:\n",
" print(\"404 as intended:\", str(e)[:120])\n",
"\n",
"# ...versus a real user who simply has no profile row yet.\n",
"fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n",
"client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n",
"time.sleep(5)\n",
"print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## F3. Profiles turned off\n",
"\n",
"`enabled` is the one project-wide switch. Every generation path then refuses.\n",
"\n",
"Nothing is deleted. Your schema and every profile you already built are kept, so turning\n",
"it back on resumes rather than restarts.\n",
"\n",
"Note what a read does **not** do — a profile that already exists keeps reporting\n",
"`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n",
"with no profile yet. Turning the feature off stops new work; it does not hide what has\n",
"already been built.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"client.update_profile_settings(enabled=False)\n",
"\n",
"print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n",
"print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n",
"try:\n",
" client.generate_profile(USER_ID)\n",
" print(\"trigger: ACCEPTED (unexpected)\")\n",
"except Exception as e:\n",
" print(\"trigger:\", str(e)[:160])\n",
"\n",
"back = client.update_profile_settings(enabled=True) # put it back\n",
"print(\"\\nrestored:\", back[\"enabled\"])\n",
"print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Cleanup\n",
"\n",
"Removes the demo users. The profile row cascades with the entity.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "# Restore the project's profile settings to the start-of-run snapshot in `finally`, so a\n# failed delete still leaves a shared project as we found it. Passing the original values\n# (including None) clears anything this notebook set: the SDK treats an explicit None as\n# \"clear\" and an omitted argument as \"unchanged\".\ntry:\n # `fresh` only exists if the error-handling section ran.\n for entity_id in (USER_ID, globals().get(\"fresh\")):\n if entity_id is None:\n continue\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\nfinally:\n _user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\n client.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n )\n print(\"profile settings restored to the pre-notebook snapshot\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Cheat sheet\n",
"\n",
"| Want | Call | Cost |\n",
"| --- | --- | --- |\n",
"| configure | `update_profile_settings(...)` | free |\n",
"| read | `get_profile(user_id)` | free |\n",
"| one user now | `generate_profile(user_id)` | 1 LLM call |\n",
"| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n",
"| poll a job | `get_profile_job(status_url)` | free |\n",
"\n",
"**Settings apply to user profiles.** The stored shape is:\n",
"\n",
"```json\n",
"{\"enabled\": true,\n",
" \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n",
" \"capabilities\": {\"full_rebuild\": false}}\n",
"```\n",
"\n",
"The SDK takes these flat and nests them for you. Only the fields you pass are written;\n",
"`enabled` is the one project-wide switch.\n",
"\n",
"**Left alone, generation fires** on a 10-message boundary, or after a flush window\n",
"measured in hours. `generate_profile()` is how you skip the wait for one user.\n",
"\n",
"**Three traps:**\n",
"\n",
"1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n",
" through it and give up on a timeout instead.\n",
"2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n",
" extraction still increments the counter.\n",
"3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n",
" arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
+44
View File
@@ -6,6 +6,7 @@ Agent and editor integrations. Most packages are self-contained; coding-agent pl
|-----------|---------|-------|------|------|
| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) |
| `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest |
| `hermes-plugin-mem0/` | Standalone Hermes memory provider | none | ruff + isort | pytest (host-stubbed offline); live CLI/Desktop validation |
| `agent-plugin-core/` | Shared Python/TypeScript behavior, skill templates, builds, and conformance | Python build script | ruff + tsc | pytest + node:test |
| `mem0-agent-plugin/` | One portable Agent Plugins v1 package | Python | ruff | shared conformance |
| `claude-code-plugin/`, `cursor-plugin/`, `codex-plugin/`, `kimi-plugin/`, `antigravity-plugin/` | Self-contained native plugins generated from the shared Python core | Python | ruff | pytest |
@@ -49,6 +50,48 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
- **`mem0-strands/`** is a native Strands `MemoryStore` (Python, published to PyPI as `mem0-strands`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `mem0-strands/python/`.
## Surface attribution
Every integration tells the Mem0 platform which surface it is. Three headers,
and the rules on them are what keep one layer from erasing another:
| Header | Carries | Rule |
|--------|---------|------|
| `X-Mem0-Source` | one canonical source value | **set-once** — write only if absent |
| `X-Application` | the host app it runs inside | **set-once** — write only if absent |
| `X-Mem0-Client` | `name/version`, outermost first | **append-only** — add yourself, never replace |
Set-once means check-then-set, never assignment. An integration that wraps the
SDK is the outermost layer and sets the source; the SDK underneath defers to it.
Assignment is exactly how every agent plugin came to be indistinguishable from
every other one at the platform.
How to declare it from an integration, in order of preference:
1. Send the headers yourself, if you make the HTTP call directly.
2. Pass `source` in the call options, if you go through an SDK.
3. Set `MEM0_SOURCE` / `MEM0_APPLICATION` / `MEM0_CLIENT_STACK` in the
environment before constructing the client. The SDKs read these and defer to
anything already present.
Append-only applies where a stack can actually form: an SDK handed a client that
already carries `X-Mem0-Client` appends itself rather than replacing. An SDK
constructed with no outer context simply reports itself, which is correct — it
is the outermost layer in that process.
The backend recognizes a fixed list of source values and buckets everything else
into `OTHERS`. A new value has to land in the platform's `EventSource` enum, so
do not invent one without that change going in too.
`X-Application` is allowlisted the same way, and this one has a rule of its own:
**omit the header when you do not know the host.** A value outside the allowlist
is discarded server-side, so guessing produces an event that claims an
attribution we do not actually have. The portable bundle is the case that
matters. It runs in whatever editor a user drops it into, so its build leaves
`PLATFORM_APPLICATION` empty and `memory_core` sends no header at all, while the
native bundles each name the host they were generated for. If you add a build
target, decide which of those two it is.
## Adding an integration
1. For a native coding-agent host, add `integrations/<name>-plugin/` with `plugin-build.json`, its manifest, and a thin adapter, then generate its shared runtime. Portable clients use the single `mem0-agent-plugin/` package. Independent TypeScript integrations stay self-contained and import shared lifecycle behavior from `agent-plugin-core/typescript/`.
@@ -59,3 +102,4 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
5. If it is a Claude Code or editor marketplace plugin, register the generated native bundle path in the applicable marketplace files. Preserve the existing public plugin name.
6. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`.
7. Add rows to the table above and to the CI/CD tables in [`../.github/AGENTS.md`](../.github/AGENTS.md).
8. Send the three headers in [Surface attribution](#surface-attribution), and land the matching `EventSource` value on the platform in the same week. Until it exists, your traffic reports as `OTHERS`.
+2 -2
View File
@@ -8,7 +8,7 @@ This directory is the single source of shared memory behavior for Mem0 coding-ag
integrations/
├── agent-plugin-core/ # Shared source; never installed as a plugin
│ ├── python/ # Claude-derived capture, recall, MCP, scoping, and telemetry
│ ├── typescript/ # Shared lifecycle, formatting, identity, scoping, and telemetry
│ ├── typescript/ # Shared lifecycle, search prompts, formatting, identity, scoping, and telemetry
│ ├── skills/ # The only source for the six generated memory skills
│ ├── build/ # Bundle builder, schemas, and validation
│ ├── conformance/ # One offline/live verification entry point
@@ -45,7 +45,7 @@ New Git repository writes use a hashed remote identity for shared `agent_id`. Se
Captured prompts and responses preserve their full text after secret redaction. Python extraction splits oversized input across requests without dropping message text. The session-end worker flushes the conversation already collected by hooks without adding the final answer again. Search queries, retrieved context, and tool evidence have separate limits.
TypeScript hosts reuse redaction and lifecycle utilities but retain their own tools, scopes, and capture events. They do not inherit the Python `repo`/`dir`/`mine` contract or its background batching. OpenCode captures selected user prompts; Pi and DeepSeek capture completed conversation turns; OpenClaw selects recent messages and earlier summaries, then filters noise. Removing message-length truncation does not turn these integrations into complete transcript archives.
TypeScript hosts reuse redaction, lifecycle utilities, and the search prompts in `typescript/src/prompts.ts`, which a test keeps identical to the Python core. They retain their own tools, scopes, and capture events. They do not inherit the Python `repo`/`dir`/`mine` contract or its background batching. OpenCode captures selected user prompts; Pi and DeepSeek capture completed conversation turns; OpenClaw selects recent messages and earlier summaries, then filters noise. Removing message-length truncation does not turn these integrations into complete transcript archives.
For installation, follow the host guides: [Claude Code](../../docs/integrations/claude-code.mdx), [Cursor](../../docs/integrations/cursor.mdx), [Codex](../../docs/integrations/codex.mdx), [Kimi](../../docs/integrations/kimi.mdx), and [Antigravity](../../docs/integrations/antigravity.mdx).
@@ -81,6 +81,38 @@ def replace_output(staged: Path, output: Path) -> Path:
return output
def _render_harness_id(host: str, *, portable: bool = False) -> str:
"""Emit core/_harness_id.py for one host.
Carries both vocabularies from a single definition: the PostHog `source` tag
and the platform's X-Mem0-Source / X-Application pair. Keeping them together
is what stops the two from drifting into separate vocabularies for the same
thing.
The portable bundle runs in whatever editor a user drops it into, so it does
not know its host and must not guess one. HARNESS_ID stays "coding-agent",
which is true and useful for grouping in PostHog, but PLATFORM_APPLICATION is
left empty: X-Application names a real host app, is checked against an
allowlist server-side, and a value that is always discarded is worse than no
value -- it reads like an attribution we have and do not.
"""
tag = host.upper().replace("-", "_") + "_PLUGIN"
application = "" if portable else host
return (
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
"\n"
f'HARNESS_ID = "{host}"\n'
f'SOURCE_TAG = "{tag}"\n'
"\n"
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
"# plugin family is one source; which editor it runs in is the application.\n"
"# An empty application means the host is unknown, and memory_core omits\n"
"# the header entirely rather than sending a placeholder.\n"
'PLATFORM_SOURCE = "MEM0_PLUGIN"\n'
f'PLATFORM_APPLICATION = "{application}"\n'
)
def _bundle_python(
staged: Path,
host: str,
@@ -96,6 +128,12 @@ def _bundle_python(
continue
shutil.copy2(source, core / source.name)
# Generated per host so identity does not depend on an entrypoint remembering
# to call telemetry.init(). mcp_server.py and the detached telemetry.py sender
# never did, which is how MCP searches reported harness=generic and every
# batch they drained was labelled MEM0_PLUGIN regardless of the real host.
(core / "_harness_id.py").write_text(_render_harness_id(host, portable=portable), encoding="utf-8")
values = {
"PLUGIN_ROOT": plugin_root,
"PLUGIN_DATA": "${PLUGIN_DATA}",
@@ -290,6 +290,11 @@ def run(
if args.plugin_data_dir:
os.environ[data_dir_env] = args.plugin_data_dir
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
# after them always saw content and every fresh install reported an upgrade.
data_dir_was_empty = telemetry.data_dir_was_empty()
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
@@ -305,8 +310,19 @@ def run(
return 0
if args.action == "session-start":
if telemetry.is_first_run():
# Claims the marker atomically and says which event to record, so a
# second session starting alongside this one cannot record it too.
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
if first_event == "install":
telemetry.record("install")
elif first_event == "upgrade":
# First run after a build that never wrote the marker; the
# predecessor version was never recorded anywhere.
telemetry.record("upgrade", from_version="pre-0.3")
else:
previous = telemetry.claim_version_change()
if previous:
telemetry.record("upgrade", from_version=previous)
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
@@ -21,16 +21,9 @@ from memory_core import (
PROTOCOL_VERSION = "2024-11-05"
TOOL_NAME = "search_memories"
TOOL_DESCRIPTION = (
"Search memories from earlier work in this repository. ALWAYS call this "
"tool before answering anything that could depend on prior context: the "
"user's preferences, facts about this codebase, history, people, projects, "
"or earlier decisions. Do not rely on the chat window alone. The "
"repository's memory is shared by everyone who works in it and includes "
"what it took to run, test, or build here, so search before assuming an "
"invocation works. The scope argument changes what is searched: 'repo' "
"(default) is the whole repository's shared memory plus your own "
"preferences, 'dir' narrows the shared part to the directory you are "
"working in, and 'mine' is your preferences alone."
"Search memories from earlier work in this repository. Use it before "
"repeating investigation or when earlier decisions, fixes, commands, or "
"results may help."
)
TOOL_SCHEMA = {
"type": "object",
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.1"
PLUGIN_VERSION = "0.3.3"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
@@ -71,15 +71,13 @@ MAX_FLUSH_ATTEMPTS = 5
FORGET_PAGE_SIZE = 100
FORGET_MAX_PAGES = 50
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help anyone with future coding work in this repository.
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help with future coding work.
A completed change should produce one memory explaining the resulting behavior, where it is implemented when useful, and any important constraints or reasoning. Exploration or accepted decisions may produce separate memories only when they are independently useful.
A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own.
Use the coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Use the current coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state.
Write about the repository, not the user, assistant, session, or task. Do not include test results, documentation updates, release notes, or temporary state.
If nothing useful was established, return no memories."""
@@ -1800,6 +1798,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1833,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1860,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2006,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2556,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""Anonymous usage telemetry for Mem0 agent plugins.
"""Usage telemetry for Mem0 agent plugins.
Events are linked to your Mem0 account email when an API key is configured, and
to a random per-machine id otherwise. Not anonymous — the Python SDK and CLI
attribute the same way.
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
touches the network: `record` appends one JSON line to a local spool and returns.
@@ -9,7 +13,8 @@ started once per session and again from the flush worker that is already detache
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
Never sends prompts, memory text, queries, file paths, repository names, or API
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
keys: only event names, durations, counts, coarse outcomes, and repo/session
identifiers hashed with a random per-install salt.
"""
from __future__ import annotations
@@ -29,8 +34,24 @@ from typing import Any
import memory_core
_harness: str = "generic"
_source_tag: str = "MEM0_PLUGIN"
# Seeded from the per-host module the build generates into core/. Two processes
# in this pipeline never call init() — mcp_server.py, and the detached
# `python3 telemetry.py` sender that spawn_flush() starts — so a module default
# was what every one of their events got labelled with.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import HARNESS_ID as _DEFAULT_HARNESS
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG
except ImportError:
_DEFAULT_HARNESS = "generic"
_DEFAULT_SOURCE_TAG = "MEM0_PLUGIN"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
_salt_cache: str = ""
_harness: str = _DEFAULT_HARNESS
_source_tag: str = _DEFAULT_SOURCE_TAG
_PRIVATE_KEYS = {
"apikey",
"authorization",
@@ -56,10 +77,19 @@ _PRIVATE_KEYS = {
}
def init(harness: str = "generic", source_tag: str = "") -> None:
def init(harness: str = "", source_tag: str = "") -> None:
"""Override the generated identity. Optional — core/_harness_id.py is the default.
The fallback shape matches memory_core.configure_harness's (``<HOST>_PLUGIN``).
It used to be ``MEM0_<HOST>_PLUGIN`` here and ``<host>_plugin`` there, which
meant one plugin could emit three different source values depending on which
process happened to send the batch.
"""
global _harness, _source_tag
_harness = harness
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
_harness = harness or _DEFAULT_HARNESS
_source_tag = source_tag or (
f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG
)
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
@@ -70,6 +100,16 @@ BATCH_SIZE = 100
SEND_TIMEOUT = 5
CLAIM_STALE_SECONDS = 120
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
# A batch is only discarded once it has genuinely been retried this many times.
MAX_CLAIM_ATTEMPTS = 3
# Parked claims drained per run, after the live spool. Bounded so a long backlog
# cannot turn one flush into an unbounded send loop.
MAX_PARKED_PER_RUN = 3
# Added to the wait before a released claim becomes reclaimable, per attempt
# already spent. Releasing straight to "reclaimable now" let two senders burn the
# whole budget within seconds of one another on a single momentary failure, and
# discard a batch a retry a minute later would have delivered.
RETRY_COOLDOWN_SECONDS = 60
def is_enabled() -> bool:
@@ -83,9 +123,126 @@ def is_enabled() -> bool:
def _digest(value: str, length: int = 16) -> str:
"""Unsalted digest. Only for values that are already secrets (API keys)."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
def _salt_path() -> Path:
return memory_core.data_dir() / "telemetry-salt"
def _install_salt() -> str:
"""Random per-install salt, created once and memoized for the process.
Deliberately its own file, claimed with O_CREAT|O_EXCL, rather than a key in
the identity file. Three reasons, all of which produced wrong data when this
lived in the identity dict:
- Hooks are short-lived separate processes firing on every tool call, and
people run more than one agent window. A read-modify-write would let each
process mint its own salt, so one repository would hash several ways in the
window before a writer won.
- resolve_distinct_id holds a copy of the identity dict across a network call
to /v1/ping/, so whichever write landed second erased the other's key —
losing either the salt (repo_hash changes mid-stream) or the email (a
second $identify, splitting the person).
- Touching the identity file from record() would create it, and is_first_run
keys off that file, so recording an event would silently suppress the
install event.
Published atomically, and there is deliberately no derived fallback. Creating
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
the file exists and is empty, and a concurrent hook that reads it in that
window gets nothing. Falling back to a digest of the path would hand that
process a salt an attacker can compute, memoized for its whole run, which is
the privacy control this function exists to provide silently turning itself
off under load. The salt is written to a private temp file first and linked
into place, so the name either does not exist or already has the full value.
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
rather than emit an unsalted one.
"""
global _salt_cache
if _salt_cache:
return _salt_cache
path = _salt_path()
# Read before writing. Hooks are separate processes firing on every tool
# call, so all but the first find the salt already published; going straight
# to create-fsync-link-unlink meant every one of them paid an fsync to
# discover that, on a path whose whole promise is appending a line and
# returning.
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
if _salt_cache:
return _salt_cache
except OSError:
pass
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(uuid.uuid4().hex)
stream.flush()
os.fsync(stream.fileno())
try:
# Atomic claim: fails if another process already published one.
# os.link rather than replace, which would clobber theirs.
os.link(temporary, path)
except FileExistsError:
pass
except OSError:
# No hardlinks here (some network mounts, some container volumes).
# Claim the name directly instead. That reopens the empty-file
# window, but the window is now benign: a reader that lands in it
# gets "" and omits the hash for that process rather than caching a
# guessable one. Losing the hashes on every run of an entire
# filesystem is the worse failure.
try:
fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fallback, "w", encoding="utf-8") as stream:
stream.write(temporary.read_text(encoding="utf-8"))
except OSError:
pass
except OSError:
pass
finally:
try:
temporary.unlink()
except OSError:
pass
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
except OSError:
_salt_cache = ""
return _salt_cache
def _scoped_digest(value: str, length: int = 16) -> str:
"""Salted digest for values drawn from a guessable space.
repo.identity is a git remote URL, or ``local:<absolute path>`` when there is
no remote — which normally contains the account username. Sixteen unsalted
hex characters over that input space is enumerable, so this is not a
privacy control without the salt. Salting per install keeps every
within-account join the analytics actually use and gives up only
cross-machine joins on the same repository, which nothing computes.
Returns "" when there is no salt, so record() omits the property. An
unsalted digest over this input space is close to plaintext, and emitting one
under a name that implies it is hashed is worse than sending nothing.
"""
if not value:
return ""
salt = _install_salt()
if not salt:
return ""
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
def _safe_value(value: Any) -> Any:
if isinstance(value, str):
return memory_core.redact(value)
@@ -145,9 +302,176 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str:
return created
def _rotate_anonymous_id(identity: dict[str, str]) -> str:
"""Mint a fresh anonymous id because the account context is gone.
The previous id may already have been merged into a person profile by an
$identify, and that merge is permanent. Reusing it after a logout or a key
change attributes everything that follows to the account that just went
away, which is the same misattribution the key fingerprint exists to stop,
only arriving through the anonymous path instead.
`aliased` is cleared with it: the new id has never been merged, so it is
eligible to be aliased into whatever account comes next.
"""
created = f"code-anon-{uuid.uuid4().hex}"
identity["anonymous_id"] = created
identity.pop("aliased", None)
_write_identity(identity)
return created
def _install_state_path() -> Path:
return memory_core.data_dir() / "install-state.json"
def is_first_run() -> bool:
"""Whether this machine has never recorded a plugin event before."""
return not _identity_path().exists()
"""Whether install has never been recorded on this machine.
Deliberately NOT the identity file. That file is only written by a
successful flush, so an offline or firewalled user recorded code.install on
every single session, forever — and every 0.2.x user recorded one on their
first 0.3.x session because 0.2.x never wrote it at all.
"""
return not _install_state_path().exists()
def data_dir_was_empty() -> bool:
"""Whether the data directory is untouched. Call BEFORE anything writes to it.
hook_runner reaches claim_install() only after cache_plugin_api_key() has
written `api-key` and EvidenceStore() has created `evidence.sqlite3`, so
asking at claim time always saw content and every fresh install reported an
upgrade. The caller snapshots this at the top of the run instead.
"""
return not _data_dir_has_content()
def claim_install(was_empty: bool | None = None) -> str | None:
"""Claim the one install/upgrade record for this machine, atomically.
Returns the event to record ("install" or "upgrade"), or None if another
session already claimed it. O_CREAT|O_EXCL so two sessions starting together
cannot both win.
`was_empty` must come from data_dir_was_empty() called before this process
wrote anything. Omitting it falls back to checking now, which is only
correct for a caller that has touched nothing.
"""
if not is_enabled():
# Never consume the one-shot claim while the user is opted out, or they
# would silently lose their install event if they later opt in.
return None
path = _install_state_path()
upgrading = not (data_dir_was_empty() if was_empty is None else was_empty)
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
return None
except OSError:
return None
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump(
{
"plugin_version": memory_core.PLUGIN_VERSION,
"installed_at": memory_core.utc_now(),
"upgraded": upgrading,
},
stream,
)
# Durable before this returns. The O_EXCL open is what makes the
# claim exclusive, so it cannot be replaced by a temp-and-rename
# without losing that, which leaves the content as the thing to make
# safe. A kill between the open and this fsync used to leave a marker
# that exists but parses to nothing: is_first_run reads it as claimed
# and claim_version_change cannot read a version out of it.
stream.flush()
os.fsync(stream.fileno())
except OSError:
pass
return "upgrade" if upgrading else "install"
def _data_dir_has_content() -> bool:
"""Whether anything predates this session in the plugin data directory."""
try:
for entry in memory_core.data_dir().iterdir():
if entry.name != "install-state.json":
return True
except OSError:
pass
return False
def _repair_install_state(path: Path) -> None:
"""Rewrite an unparseable marker so version tracking can resume."""
try:
temporary = path.with_suffix(f".{os.getpid()}.tmp")
temporary.write_text(
json.dumps({"plugin_version": memory_core.PLUGIN_VERSION, "repaired_at": memory_core.utc_now()}),
encoding="utf-8",
)
temporary.replace(path)
except OSError:
pass
def claim_version_change() -> str | None:
"""Return the previously recorded version if it differs, updating the marker.
Only meaningful once the marker exists — the first transition into 0.3.x has
no recorded predecessor and reports "pre-0.3" instead. Claiming by rewriting
the marker means the next session sees no change and records nothing.
"""
path = _install_state_path()
try:
state = json.loads(path.read_text(encoding="utf-8"))
except OSError:
return None
except json.JSONDecodeError:
# A crash between O_EXCL and the write leaves an empty marker. Left
# alone it disables every future upgrade event on this machine, because
# claim_install sees the file and this function cannot parse it.
state = None
if not isinstance(state, dict):
_repair_install_state(path)
return None
previous = str(state.get("plugin_version") or "")
if not previous or previous == memory_core.PLUGIN_VERSION:
return None
# Claim the transition with an exclusive sentinel before rewriting the
# marker. A plain read-modify-write let every concurrently starting session
# observe the old version and each record its own upgrade — and the first
# session after a version bump is exactly when several agent windows restart
# together.
sentinel = path.with_name(f"upgraded-{memory_core.PLUGIN_VERSION}")
try:
os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
except FileExistsError:
return None
except OSError:
return None
state["plugin_version"] = memory_core.PLUGIN_VERSION
state["upgraded_at"] = memory_core.utc_now()
temporary = path.with_suffix(f".{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(state), encoding="utf-8")
temporary.replace(path)
except OSError:
# Release the claim. The marker still records the old version, so
# without this the sentinel makes claim_version_change return early on
# every later run and this version's upgrade is never recorded again.
for leftover in (sentinel, temporary):
try:
leftover.unlink()
except OSError:
pass
return None
return previous
def record(
@@ -168,19 +492,32 @@ def record(
except OSError:
pass
properties = _safe_value(properties)
# Stamped in the RECORDING process, beside harness. `source` used to be
# read in the sending process from a module global, so whichever process
# drained the spool named every event in it. flush() spreads per-event
# properties last, so this now wins over any sender's default.
properties.update(
harness=_harness,
source=_source_tag,
plugin_version=memory_core.PLUGIN_VERSION,
os=sys.platform,
python_version=platform.python_version(),
)
# Assigned only when the digest is real. _scoped_digest returns "" when
# the salt could not be persisted, and an empty property is worse than an
# absent one: it survives the None filter below and reads as a value.
if repo is not None:
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
if repo_hash:
properties["repo_hash"] = repo_hash
if session_id:
properties["session_hash"] = _digest(session_id)
session_hash = _scoped_digest(session_id)
if session_hash:
properties["session_hash"] = session_hash
line = json.dumps(
{
"event": f"{EVENT_PREFIX}.{event}",
"uuid": str(uuid.uuid4()),
"timestamp": memory_core.utc_now(),
"properties": {
key: value for key, value in properties.items() if value is not None
@@ -239,38 +576,201 @@ def spawn_flush() -> bool:
return False
def _claim_name(attempt: int = 0) -> str:
"""Claim filename. The attempt count rides in the name so the 7-day expiry
only ever discards a batch that was actually retried and failed."""
return f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}-a{attempt}.sending"
def _claim_attempt(claim: Path) -> int:
"""Attempts recorded in a claim filename; 0 for the pre-attempt-count shape.
Anchored on field position, not on a leading "a": the legacy shape is
``telemetry-<pid>-<hex>.sending`` and a hex id such as ``a1234567`` would
otherwise parse as attempt 1234567 and be discarded unsent on the first
flush after an upgrade.
"""
stem = claim.name[: -len(".sending")] if claim.name.endswith(".sending") else claim.name
parts = stem.split("-")
if len(parts) != 4:
return 0
tail = parts[3]
if tail.startswith("a") and tail[1:].isdigit():
return int(tail[1:])
return 0
def _touch(path: Path) -> None:
"""Refresh mtime so a claim's age measures time since it was claimed.
``Path.replace`` is ``os.rename``, which preserves mtime — so a claim created
after a quiet minute inherited the spool's last-write time and looked
abandoned the instant it was made. A second sender would then take it over
while the first was still posting, and both would deliver the batch.
"""
try:
os.utime(path, None)
except OSError:
pass
def _claim_spool() -> Path | None:
"""Rename the spool aside so exactly one sender owns each batch."""
directory = memory_core.data_dir()
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
claim = directory / _claim_name()
spool = _spool_path()
try:
spool.replace(claim)
_touch(claim)
return claim
except OSError:
pass
return _claim_parked(directory)
def _sweep_debris(directory: Path) -> None:
"""Remove files nothing else will ever pick up again.
*.partial is a temp file orphaned by a crash between write and rename.
*.corrupt is a batch quarantined for undecodable content. No glob in this
module matches either, so without this they accumulate on disk for the life
of the install.
Quarantined batches are kept far longer than debris: they are the only
evidence left of events that could not be delivered, and someone diagnosing
a report of missing telemetry has to be able to find one.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending")):
for debris in directory.glob("telemetry-*.partial"):
try:
if now - debris.stat().st_mtime > CLAIM_STALE_SECONDS:
debris.unlink()
except OSError:
continue
for quarantined in directory.glob("telemetry-*.corrupt"):
try:
if now - quarantined.stat().st_mtime > CLAIM_EXPIRY_SECONDS:
quarantined.unlink()
except OSError:
continue
# The same reasoning covers *.tmp. _write_identity and _install_salt both
# create one and unlink it in a finally, which a SIGKILL skips, and no glob
# in this module matches the leftovers either.
for temporary in directory.glob("telemetry-*.tmp"):
try:
if now - temporary.stat().st_mtime > CLAIM_STALE_SECONDS:
temporary.unlink()
except OSError:
continue
def _claim_parked(directory: Path) -> Path | None:
"""Take the oldest abandoned claim, if any lease has actually expired.
Kept separate from the live spool so flush() can drain both in one run.
Previously parked batches were only reachable when no spool existed at all,
and because sessions keep recording there usually was one — so a batch
parked by a failed send waited until the 7-day expiry deleted it unsent,
even though its own presence is what started the sender.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending"), key=_safe_mtime):
try:
age = now - orphan.stat().st_mtime
except OSError:
continue
if age > CLAIM_EXPIRY_SECONDS:
if age < CLAIM_STALE_SECONDS:
# Someone else holds a live lease on it. This check has to come
# first. Claiming a file bumps its attempt count and refreshes its
# mtime, so a sender that has just taken the final attempt looks
# exhausted to everyone else while it is actively draining. Judging
# exhaustion before liveness let a second sender unlink a batch out
# from under its owner, losing every event in it.
continue
# Attempts, not age. Every re-claim touches the mtime and every release
# backdates it by a fixed amount, so age is pinned near the stale
# threshold and never reaches the expiry. Age stays only as a backstop
# for files that never carried an attempt marker.
if _claim_attempt(orphan) >= MAX_CLAIM_ATTEMPTS or age > CLAIM_EXPIRY_SECONDS:
try:
orphan.unlink()
except OSError:
pass
continue
if age < CLAIM_STALE_SECONDS:
continue
claim = orphan.parent / _claim_name(_claim_attempt(orphan) + 1)
try:
orphan.replace(claim)
_touch(claim)
return claim
except OSError:
continue
return None
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _rewrite_claim(claim: Path, remaining: list[dict[str, Any]]) -> bool:
"""Persist the unsent remainder, atomically, and refresh the lease.
Called after every successful batch. Two jobs: a retry resumes where the
send stopped instead of re-posting from the top, and the rewrite doubles as
the lease heartbeat, so a slow sender does not have its claim stolen
mid-flight. Interval is one batch, well inside CLAIM_STALE_SECONDS.
"""
if not remaining:
try:
claim.unlink()
except OSError:
pass
return True
temporary = claim.with_suffix(f".{os.getpid()}.partial")
try:
payload = "".join(json.dumps(event, separators=(",", ":"), default=str) + "\n" for event in remaining)
# fsync before the rename: without it the rename can land while the
# bytes have not, and the claim comes back empty or truncated after a
# crash. _drain then reads zero events and unlinks it.
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(claim)
_touch(claim)
return True
except OSError:
try:
temporary.unlink()
except OSError:
pass
return False
def _release_claim(claim: Path, remaining: list[dict[str, Any]]) -> None:
"""Persist the remainder and drop the lease, because this sender has given up.
Distinct from the per-batch heartbeat: heartbeating on the way out would
make an abandoned batch look actively owned for a further
CLAIM_STALE_SECONDS, delaying the retry for no reason. Ageing it past the
threshold lets the next flush pick it up immediately, while the attempt
count in the filename still bounds how many times that can happen.
"""
if not _rewrite_claim(claim, remaining):
return
try:
# Backdate past the stale threshold so the next flush can pick it up,
# minus a cooldown that grows with the attempts already spent. Clamped so
# the mtime never lands in the future, which would read as a live lease.
cooldown = min(_claim_attempt(claim) * RETRY_COOLDOWN_SECONDS, CLAIM_STALE_SECONDS)
released = time.time() - CLAIM_STALE_SECONDS - 1 + cooldown
os.utime(claim, (released, released))
except OSError:
pass
def _resolve_email(key: str) -> str:
"""Trade the API key for the account email so events join other Mem0 surfaces."""
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
@@ -300,34 +800,130 @@ def _post(payload: dict[str, Any], url: str) -> bool:
def resolve_distinct_id() -> tuple[str, str]:
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
"""Return the PostHog distinct id and the anonymous id it replaced, if any.
The second value becomes a PostHog $identify alias. It is ONLY ever an
anonymous id: aliasing one account email to another merges two real person
profiles and cannot be undone, so a key that now belongs to a different
account re-resolves with no alias.
"""
identity = _read_identity()
email = identity.get("email", "")
if email:
return email, ""
key = memory_core.api_key()
fingerprint = _digest(key) if key else ""
email = identity.get("email", "")
if email and fingerprint:
recorded = identity.get("key_fingerprint", "")
if recorded == fingerprint:
return email, ""
if not recorded:
# Rows written before fingerprints existed. Verify rather than
# adopt: a key changed before the upgrade would otherwise bind the
# new key to the previous account's email, permanently, and the
# fingerprint would then agree with itself forever after.
verified = _resolve_email(key)
if not verified:
# Offline, firewalled, or the API is down. Keep the previous
# behaviour and retry on the next flush rather than dropping a
# real account attribution. Safe because the same network that
# failed /v1/ping/ is about to fail the PostHog POST, so nothing
# is delivered under the unverified identity in the meantime.
return email, ""
identity["email"] = verified
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return verified, ""
if not key:
# No key to verify the account with; do not keep attributing to it.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
email = _resolve_email(key)
if not email:
resolved = _resolve_email(key)
if not resolved:
# The key changed and will not resolve (revoked, offline, API down).
# Reaching here with an email means the recorded fingerprint disagreed,
# so the key really did change. Drop the account and rotate: the stored
# anonymous id may already be merged into that account's person, and
# reusing it would keep the events on the profile we are trying to
# leave.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
previous = identity.get("anonymous_id", "")
identity["email"] = email
# Alias only when going anonymous -> email for the first time. Once an anon
# id has been merged into an account it must never be offered again: an
# alias naming an already-identified id is what could link two real people.
previous = "" if (email or identity.get("aliased")) else identity.get("anonymous_id", "")
if previous:
identity["aliased"] = True
identity["email"] = resolved
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return email, previous
return resolved, previous
def flush() -> int:
"""Drain claimed spools to PostHog and return the number of events sent."""
"""Drain the live spool, then any parked claims, and return events sent."""
if not is_enabled():
return 0
claim = _claim_spool()
sent, delivered = _drain(_claim_spool())
if not delivered:
# The network is failing. Retrying other batches now would only burn
# their attempt budget against the same broken connection.
return sent
# Parked batches used to starve behind the live spool indefinitely. Bounded
# per run so a long backlog cannot turn one flush into an unbounded loop.
directory = memory_core.data_dir()
_sweep_debris(directory)
for _ in range(MAX_PARKED_PER_RUN):
parked = _claim_parked(directory)
if parked is None:
break
count, delivered = _drain(parked)
sent += count
if not delivered:
break
return sent
def _drain(claim: Path | None) -> tuple[int, bool]:
"""Post one claimed batch file, recording progress after every batch.
Returns (events sent, whether everything was delivered).
"""
if claim is None:
return 0
return 0, True
try:
lines = claim.read_text(encoding="utf-8").splitlines()
except ValueError:
# UnicodeDecodeError from a torn write: the content is unrecoverable, so
# quarantine rather than retry. flush() runs from a bare `finally:` in
# flush_worker, so raising here also skips the handoff cleanup, and an
# undecodable file would otherwise be re-read on every flush forever.
# Reported as delivered because there is nothing left to deliver and the
# rest of the run should continue.
try:
claim.replace(claim.with_suffix(".corrupt"))
except OSError:
try:
claim.unlink()
except OSError:
pass
return 0, True
except OSError:
return 0
# Could not read it, which is not the same as having nothing to send.
# The file is left exactly where it is: a vanished or briefly unreadable
# claim is retryable, and quarantining it here would discard events over
# a transient filesystem error. Reported as undelivered so the run stops
# instead of counting a batch nothing was posted from as delivered.
return 0, False
events = []
for line in lines:
try:
@@ -337,11 +933,18 @@ def flush() -> int:
if isinstance(value, dict) and value.get("event"):
events.append(value)
if not events:
# Only delete when the file really is empty. A non-empty file that
# parses to nothing is a torn write, and its contents are the unsent
# remainder — deleting it is the data loss this PR exists to prevent.
try:
claim.unlink()
empty = claim.stat().st_size == 0
except OSError:
empty = True
try:
claim.replace(claim.with_suffix(".corrupt")) if not empty else claim.unlink()
except OSError:
pass
return 0
return 0, True
distinct_id, aliased_anonymous_id = resolve_distinct_id()
if aliased_anonymous_id:
@@ -360,12 +963,17 @@ def flush() -> int:
sent = 0
for start in range(0, len(events), BATCH_SIZE):
chunk = events[start : start + BATCH_SIZE]
batch = [
{
"event": event["event"],
"distinct_id": distinct_id,
# Carried through from record() so a resend can be collapsed.
"uuid": event.get("uuid"),
"timestamp": event.get("timestamp"),
"properties": {
# Fallback only: events recorded by a build before source
# moved into record() have none of their own.
"source": _source_tag,
"language": "python",
"$process_person_profile": False,
@@ -373,16 +981,24 @@ def flush() -> int:
**(event.get("properties") or {}),
},
}
for event in events[start : start + BATCH_SIZE]
for event in chunk
]
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
return sent
sent += len(batch)
try:
claim.unlink()
except OSError:
pass
return sent
# Keep only what has not been delivered, and release the lease.
# Previously the whole file was kept and the retry re-posted every
# batch, including the ones that had already arrived.
_release_claim(claim, events[start:])
return sent, False
sent += len(chunk)
# Record progress and refresh the lease after each successful batch, so
# a crash repeats at most one batch instead of the entire file. If the
# rewrite fails the claim still holds delivered events, so stop rather
# than carry on as though progress were recorded — continuing is how the
# duplicate delivery this PR fixes would come back.
if not _rewrite_claim(claim, events[start + len(chunk) :]):
_release_claim(claim, events[start + len(chunk) :])
return sent, False
return sent, True
def main() -> int:
@@ -7,8 +7,8 @@ disable-model-invocation: true
# Pause memory capture
To pause (hooks stop capturing and sending session content; a minimal
anonymous telemetry ping still fires at session start unless
`MEM0_TELEMETRY=false`):
telemetry ping still fires at session start, under your Mem0 account email,
unless `MEM0_TELEMETRY=false`):
```bash
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} pause
@@ -12,11 +12,9 @@ Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
query.
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
category; a category is a best-effort label Mem0 assigned when it saved the
memory, so if a category search misses, repeat it without the category. Omit
`scope` to use the configured default, normally `repo`: this repository's
shared memory, which everyone who works in it contributes to, plus your own
preferences.
category. Omit `scope` to use the configured default, normally `repo`: this
repository's shared memory, which everyone who works in it contributes to,
plus your own preferences.
Pass `scope` when the question needs something else: `dir` to narrow the
shared memory to the directory you are working in (a package inside a
@@ -70,6 +70,38 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non
assert not any(path.is_symlink() for path in root.rglob("*"))
def _harness_identity(root: Path) -> dict[str, str]:
"""Read the generated core/_harness_id.py without importing it."""
values: dict[str, str] = {}
for line in (root / "core" / "_harness_id.py").read_text(encoding="utf-8").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
name, _, raw = line.partition("=")
values[name.strip()] = raw.strip().strip('"')
return values
def test_the_portable_bundle_declares_no_host_application(tmp_path: Path) -> None:
"""It runs in whatever editor a user drops it into, so it cannot know the host.
X-Application is allowlisted server-side. A guessed value is silently dropped
there, which is the worst outcome: the wire says we know the host and the
stored event says we do not.
"""
identity = _harness_identity(build("mem0-agent-plugin", "portable", tmp_path / "portable"))
assert identity["PLATFORM_APPLICATION"] == ""
# The PostHog-side label is still useful for grouping and stays populated.
assert identity["HARNESS_ID"] == "coding-agent"
assert identity["PLATFORM_SOURCE"] == "MEM0_PLUGIN"
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
def test_a_native_bundle_names_the_host_it_was_built_for(host: str, tmp_path: Path) -> None:
identity = _harness_identity(build(host, "native", tmp_path / host))
assert identity["PLATFORM_APPLICATION"] == host
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
root = build(host, "native", tmp_path / host)
@@ -0,0 +1,446 @@
"""Delivery semantics of the telemetry spool: no duplicates, no starvation.
These run against a built host's core in-process (not a subprocess) because they
need to inject failures into ``_post``. The identity tests next door cover the
uninitialised-process case that needs a real interpreter.
"""
from __future__ import annotations
import importlib
import json
import os
import sys
import time
from pathlib import Path
import pytest
CORE_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = CORE_ROOT.parents[1]
HOST_CORE = REPOSITORY_ROOT / "integrations" / "claude-code-plugin" / "core"
pytestmark = pytest.mark.skipif(not HOST_CORE.exists(), reason="claude-code-plugin is not built")
@pytest.fixture()
def telemetry(tmp_path, monkeypatch):
# CI runs this directory and claude-code-plugin/tests in ONE pytest process,
# and that suite's conftest sets MEM0_TELEMETRY=false at import, process-wide.
# Without this the whole file silently no-ops: record() returns early and
# every assertion sees an empty spool. Do not rely on ambient env.
monkeypatch.setenv("MEM0_TELEMETRY", "true")
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
monkeypatch.syspath_prepend(str(HOST_CORE))
# Save and RESTORE rather than delete. claude-code-plugin/tests/conftest.py
# imports memory_core once at collection and calls configure_harness() on it;
# dropping the module left a later re-import with default harness config, so
# tests in that suite failed depending on collection order.
names = ("telemetry", "memory_core", "_harness_id")
saved = {name: sys.modules.get(name) for name in names}
for name in names:
sys.modules.pop(name, None)
module = importlib.import_module("telemetry")
monkeypatch.setattr(module, "resolve_distinct_id", lambda: ("tester@example.com", ""))
try:
yield module
finally:
for name in names:
sys.modules.pop(name, None)
if saved[name] is not None:
sys.modules[name] = saved[name]
def _delivered(payloads):
return [event for payload in payloads if "batch" in payload for event in payload["batch"]]
def test_a_partial_failure_does_not_redeliver_what_already_arrived(telemetry):
"""Defect 2a: flush kept the whole claim on failure and retried from the top.
150 events across two batches, the second failing, previously delivered 250.
"""
for index in range(150):
telemetry.record("search", index=index)
sent: list[dict] = []
calls = {"n": 0}
def flaky(payload, url):
calls["n"] += 1
if calls["n"] == 2: # second batch fails
return False
sent.append(payload)
return True
telemetry._post = flaky
telemetry.flush()
telemetry._post = lambda payload, url: sent.append(payload) or True
telemetry.flush()
events = _delivered(sent)
assert len(events) == 150
assert len({event["uuid"] for event in events}) == 150
def test_a_fresh_claim_is_not_immediately_stealable(telemetry):
"""Defect 2b: rename preserves mtime, so a claim inherited the spool's age.
With the last write older than the stale threshold, a claim made now looked
abandoned the instant it existed and a second sender took it over.
"""
telemetry.record("search")
spool = telemetry._spool_path()
old = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(spool, (old, old))
first = telemetry._claim_spool()
assert first is not None
# A second sender starting right now must find nothing to take.
assert telemetry._claim_parked(first.parent) is None
def test_a_live_final_attempt_is_not_deleted_by_another_sender(telemetry):
"""Review finding: exhaustion was judged before liveness, so owners lost batches.
Claiming a parked file bumps its attempt count and refreshes its mtime. Once
the count reaches the budget, the owner draining it looked exhausted to every
other sender, which unlinked the file out from under it. Everything in that
batch was gone, which is precisely the loss this PR exists to stop.
"""
telemetry.record("search", reason="owned-by-the-first-sender")
spool = telemetry._spool_path()
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(spool, (stale, stale))
claim = telemetry._claim_spool()
assert claim is not None
# Walk it to the final attempt, ageing it each round so it can be re-claimed.
# _claim_spool hands back a0 and _release_claim keeps the name, so it takes
# one full round per attempt to reach the budget.
for _ in range(telemetry.MAX_CLAIM_ATTEMPTS):
# Carry the marker through each rewrite so the final assertion proves the
# events survived, not merely that some file with the right name did.
telemetry._release_claim(claim, [{"event": "code.search", "uuid": "owned-by-the-first-sender"}])
parked = sorted(claim.parent.glob("telemetry-*.sending"))
assert parked, "the batch was dropped while still inside its budget"
os.utime(parked[0], (stale, stale))
claim = telemetry._claim_parked(claim.parent)
assert claim is not None
assert telemetry._claim_attempt(claim) >= telemetry.MAX_CLAIM_ATTEMPTS
assert claim.exists()
# The owner is draining it right now: fresh mtime, live lease.
second_sender = telemetry._claim_parked(claim.parent)
assert second_sender is None, "a second sender took a batch under a live lease"
assert claim.exists(), "a second sender deleted a batch its owner was draining"
assert "owned-by-the-first-sender" in claim.read_text(encoding="utf-8")
def test_an_exhausted_batch_is_still_discarded_once_its_lease_lapses(telemetry):
"""The liveness check must defer the cleanup, not cancel it.
Guards the obvious over-correction: skipping live claims is only safe if an
abandoned one at the same attempt count is still reaped on a later run.
"""
telemetry.record("search")
spool = telemetry._spool_path()
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(spool, (stale, stale))
claim = telemetry._claim_spool()
assert claim is not None
exhausted = claim.parent / telemetry._claim_name(telemetry.MAX_CLAIM_ATTEMPTS)
claim.replace(exhausted)
os.utime(exhausted, (stale, stale))
assert telemetry._claim_parked(exhausted.parent) is None
assert not exhausted.exists(), "an abandoned exhausted batch was left behind forever"
def test_a_parked_batch_is_drained_behind_the_live_spool(telemetry):
"""Defect 6: parked claims were only reachable when no spool existed.
Because sessions keep recording there usually was one, so a batch parked by
a failed send waited until the 7-day expiry deleted it unsent — even though
its own presence is what starts the sender.
"""
telemetry.record("parked")
telemetry._post = lambda payload, url: False
telemetry.flush()
parked = list(telemetry.memory_core.data_dir().glob("telemetry-*.sending"))
assert len(parked) == 1
old = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(parked[0], (old, old))
telemetry.record("fresh")
sent: list[dict] = []
telemetry._post = lambda payload, url: sent.append(payload) or True
telemetry.flush()
names = {event["event"] for event in _delivered(sent)}
assert names == {"code.parked", "code.fresh"}
def test_a_batch_is_retried_until_the_budget_is_spent_not_discarded(telemetry):
"""Expiry discards what failed repeatedly, not what merely sat for a while.
The budget is the attempt count, because age cannot be one: every re-claim
touches the mtime and every release backdates it, so age never accumulates.
"""
telemetry.record("parked")
telemetry._post = lambda payload, url: False
telemetry.flush()
parked = list(telemetry.memory_core.data_dir().glob("telemetry-*.sending"))
assert len(parked) == 1
assert telemetry._claim_attempt(parked[0]) < telemetry.MAX_CLAIM_ATTEMPTS
sent: list[dict] = []
telemetry._post = lambda payload, url: sent.append(payload) or True
telemetry.flush()
assert [event["event"] for event in _delivered(sent)] == ["code.parked"]
def test_progress_is_recorded_after_every_batch(telemetry):
"""A crash repeats at most one batch, not the whole file."""
for index in range(250):
telemetry.record("search", index=index)
calls = {"n": 0}
def die_after_two(payload, url):
calls["n"] += 1
if calls["n"] > 2:
return False
return True
telemetry._post = die_after_two
telemetry.flush()
parked = list(telemetry.memory_core.data_dir().glob("telemetry-*.sending"))
assert len(parked) == 1
remaining = parked[0].read_text(encoding="utf-8").strip().splitlines()
# Two batches of 100 landed; only the last 50 should still be pending.
assert len(remaining) == 50
assert json.loads(remaining[0])["properties"]["index"] == 200
def test_the_heartbeat_actually_refreshes_the_lease(telemetry):
"""The claim rewrite doubles as the lease heartbeat.
Previously asserted `SEND_TIMEOUT * 4 < CLAIM_STALE_SECONDS`, which compares
two constants and executes none of the code under test. Drive the real
rewrite and watch the mtime move instead.
"""
for index in range(150):
telemetry.record("search", index=index)
claim = telemetry._claim_spool()
assert claim is not None
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(claim, (stale, stale))
assert time.time() - claim.stat().st_mtime > telemetry.CLAIM_STALE_SECONDS
telemetry._rewrite_claim(claim, [{"event": "code.x", "properties": {}}])
assert time.time() - claim.stat().st_mtime < telemetry.CLAIM_STALE_SECONDS
def test_an_undeliverable_batch_is_eventually_given_up_on(telemetry):
"""Expiry has to be reachable from a state the state machine can produce.
It was not: every re-claim touched the mtime and every release backdated it
by a fixed amount, so age hovered near the stale threshold and the 7-day
expiry never fired. An undeliverable batch lived on disk forever, and
spawn_flush saw it and started a sender on every hook.
"""
telemetry.record("doomed")
telemetry._post = lambda payload, url: False
directory = telemetry.memory_core.data_dir()
for _ in range(telemetry.MAX_CLAIM_ATTEMPTS + 3):
telemetry.flush()
# Attempts now carry a cooldown, so a released claim is not instantly
# reclaimable. Age it to stand in for the wall time a real retry waits;
# without this the loop spins inside one cooldown and proves nothing.
for parked in directory.glob("telemetry-*.sending"):
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(parked, (stale, stale))
leftover = list(directory.glob("telemetry-*.sending"))
assert leftover == [], f"batch never given up on: {[p.name for p in leftover]}"
def test_a_batch_that_cannot_be_read_is_not_counted_as_delivered(telemetry):
"""Review finding: a read failure reported 'everything delivered'.
Nothing was posted, so calling it delivered lets flush() carry on to other
claims as though this batch had arrived, and hides the failure from the one
signal that says the run went badly. It also must not quarantine: a briefly
unreadable file is retryable, and moving it to .corrupt discards the events
over a transient filesystem error, because nothing ever re-globs .corrupt.
"""
telemetry.record("search")
spool = telemetry._spool_path()
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(spool, (stale, stale))
claim = telemetry._claim_spool()
assert claim is not None
original = Path.read_text
def unreadable(self, *args, **kwargs):
if self == claim:
raise OSError(5, "I/O error")
return original(self, *args, **kwargs)
Path.read_text = unreadable
try:
sent, delivered = telemetry._drain(claim)
finally:
Path.read_text = original
assert sent == 0
assert delivered is False, "an unread batch was reported as delivered"
assert claim.exists(), "a transient read error discarded the batch"
assert not list(claim.parent.glob("*.corrupt")), "quarantined over a transient error"
def test_undecodable_content_is_still_quarantined_and_the_run_continues(telemetry):
"""The other half: genuinely unrecoverable content must not block the run.
Guards the over-correction. If every read problem returned undelivered, one
torn file would stop every later claim on every flush, forever.
"""
telemetry.record("search")
spool = telemetry._spool_path()
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(spool, (stale, stale))
claim = telemetry._claim_spool()
assert claim is not None
claim.write_bytes(b"\xff\xfe torn \x00 write")
sent, delivered = telemetry._drain(claim)
assert (sent, delivered) == (0, True)
assert not claim.exists()
assert list(claim.parent.glob("*.corrupt")), "unrecoverable content was not quarantined"
def test_retries_are_spread_over_real_time_not_burned_at_once(telemetry):
"""Review finding: releasing straight to reclaimable spent the budget instantly.
Two senders hitting one momentary failure could walk a batch from attempt 0
to the limit within seconds and discard it, when a retry a minute later would
have delivered. Each release now has to age past a cooldown that grows with
the attempts already spent.
"""
telemetry.record("doomed")
telemetry._post = lambda payload, url: False
directory = telemetry.memory_core.data_dir()
telemetry.flush()
parked = list(directory.glob("telemetry-*.sending"))
assert parked, "the batch was discarded on its first failure"
assert telemetry._claim_attempt(parked[0]) == 0
# Second sender, immediately: the cooldown has not elapsed, so it must not
# be able to spend another attempt.
telemetry.flush()
still = list(directory.glob("telemetry-*.sending"))
assert len(still) == 1
assert telemetry._claim_attempt(still[0]) <= 1, "burned attempts without waiting"
def test_a_legacy_claim_filename_is_not_mistaken_for_a_huge_attempt_count(telemetry):
"""The old shape is telemetry-<pid>-<hex>.sending, and hex can start with 'a'."""
assert telemetry._claim_attempt(Path("telemetry-999-deadbeef.sending")) == 0
assert telemetry._claim_attempt(Path("telemetry-999-a1234567.sending")) == 0
assert telemetry._claim_attempt(Path("telemetry-999-deadbeef-a2.sending")) == 2
def test_a_torn_claim_is_quarantined_not_deleted(telemetry):
"""A non-empty file that parses to nothing is the remainder, not garbage."""
telemetry.record("search")
claim = telemetry._claim_spool()
claim.write_bytes(b"\xff\xfe not utf-8 at all")
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(claim, (stale, stale))
sent = telemetry.flush()
assert sent == 0
assert not claim.exists()
quarantined = list(telemetry.memory_core.data_dir().glob("*.corrupt"))
assert len(quarantined) == 1, "torn claim was destroyed instead of kept"
def test_a_failed_rewrite_stops_instead_of_redelivering(telemetry):
"""Ignoring the rewrite result reintroduced the duplicates this PR fixes."""
for index in range(250):
telemetry.record("search", index=index)
telemetry._rewrite_claim = lambda claim, remaining: False
delivered = []
telemetry._post = lambda payload, url: delivered.extend(payload.get("batch", [])) or True
telemetry.flush()
assert len(delivered) == 100, f"kept going after a failed rewrite: {len(delivered)}"
def test_partial_files_are_swept(telemetry):
"""Nothing else globs *.partial, so a crash mid-rename orphans one forever."""
data_dir = telemetry.memory_core.data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
debris = data_dir / "telemetry-1-abc-a0.1.partial"
debris.write_text("x", encoding="utf-8")
old = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(debris, (old, old))
telemetry.flush()
assert not debris.exists()
def test_quarantined_batches_are_eventually_collected(telemetry):
"""Nothing re-globs .corrupt, so without a sweep they live on disk forever.
Kept much longer than .partial debris on purpose: a quarantined batch is the
only remaining evidence of events that could not be delivered.
"""
directory = telemetry.memory_core.data_dir()
directory.mkdir(parents=True, exist_ok=True)
fresh = directory / "telemetry-1-aaaaaaaa-a0.corrupt"
old = directory / "telemetry-2-bbbbbbbb-a0.corrupt"
for path in (fresh, old):
path.write_text("torn", encoding="utf-8")
expired = time.time() - (telemetry.CLAIM_EXPIRY_SECONDS + 60)
os.utime(old, (expired, expired))
telemetry._sweep_debris(directory)
assert fresh.exists(), "a recent quarantine was discarded before anyone could look at it"
assert not old.exists(), "an expired quarantine was left on disk forever"
def test_temp_files_orphaned_by_a_kill_are_collected(telemetry):
"""_write_identity and _install_salt unlink in a finally, which SIGKILL skips."""
directory = telemetry.memory_core.data_dir()
directory.mkdir(parents=True, exist_ok=True)
orphan = directory / "telemetry-salt.999.tmp"
orphan.write_text("abandoned", encoding="utf-8")
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
os.utime(orphan, (stale, stale))
telemetry._sweep_debris(directory)
assert not orphan.exists(), "a killed process left a temp file on disk forever"
@@ -0,0 +1,274 @@
"""Core telemetry behaviour with NO telemetry.init(), in a real subprocess.
Why this file exists
--------------------
``telemetry.py`` lives in ``agent-plugin-core/python/`` but its only tests lived
under ``claude-code-plugin/tests/``, behind a ``conftest.py`` that calls
``configure_harness()`` and ``telemetry.init()`` at import. Core behaviour was
therefore only ever exercised inside an already-configured module.
Two processes in the real pipeline never call ``init()``:
- ``mcp_server.py``, which records every manual search;
- the detached ``python3 telemetry.py`` sender that ``spawn_flush()`` starts at
session start, after every skill command, and when the MCP server exits.
Both fell back to module defaults, so MCP searches reported ``harness=generic``
and everything that sender delivered was labelled ``MEM0_PLUGIN`` regardless of
which of the six plugins produced it. The suite stayed green throughout.
These tests run in a fresh interpreter with no conftest, against a built host
bundle, which is the only arrangement that can catch that class of bug.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
CORE_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = CORE_ROOT.parents[1]
HOSTS = {
"claude-code": ("claude-code-plugin", "CLAUDE_CODE_PLUGIN"),
"cursor": ("cursor-plugin", "CURSOR_PLUGIN"),
"codex": ("codex-plugin", "CODEX_PLUGIN"),
"kimi": ("kimi-plugin", "KIMI_PLUGIN"),
"antigravity": ("antigravity-plugin", "ANTIGRAVITY_PLUGIN"),
# Portable: no flush_worker and no hook_runner, so its ONLY sender is the
# uninitialised telemetry.py. A native-only test passes here vacuously.
"coding-agent": ("mem0-agent-plugin", "CODING_AGENT_PLUGIN"),
}
def _core_dir(directory: str) -> Path:
return REPOSITORY_ROOT / "integrations" / directory / "core"
def _run(core: Path, data_dir: Path, body: str) -> str:
"""Execute `body` in a fresh interpreter with only the host's core on sys.path."""
script = f"import sys; sys.path.insert(0, {str(core)!r})\n{body}"
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
env={
"MEM0_CODE_DATA_DIR": str(data_dir),
"PATH": "/usr/bin:/bin",
"HOME": str(data_dir),
},
)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
@pytest.mark.parametrize("harness,spec", sorted(HOSTS.items()))
def test_identity_resolves_without_init(harness, spec):
"""Every built host knows what it is with no configuration call at all."""
directory, source_tag = spec
core = _core_dir(directory)
if not core.exists():
pytest.skip(f"{directory} is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
out = _run(
core,
Path(tmp),
"import telemetry; print(telemetry._harness, telemetry._source_tag)",
)
assert out == f"{harness} {source_tag}"
def test_mcp_server_records_the_real_harness():
"""mcp_server imports telemetry and never initialises it (server.py has no init).
Its recorded events used to carry harness=generic for every plugin.
"""
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
_run(
core,
data_dir,
"import mcp_server, telemetry; telemetry.record('search', trigger='mcp-search')",
)
spooled = (data_dir / "telemetry.jsonl").read_text(encoding="utf-8").strip()
event = json.loads(spooled)
assert event["properties"]["harness"] == "claude-code"
assert event["properties"]["source"] == "CLAUDE_CODE_PLUGIN"
def test_the_detached_sender_does_not_relabel_events():
"""`python3 telemetry.py` is the sender spawn_flush() starts, and never inits.
source is stamped at record time now, so which process sends is irrelevant.
"""
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
_run(core, data_dir, "import telemetry; telemetry.record('search')")
captured = data_dir / "captured.json"
# Drain with a fresh, unconfigured interpreter, capturing the payload
# instead of posting it.
_run(
core,
data_dir,
"import json, telemetry\n"
"sent = []\n"
"telemetry._post = lambda payload, url: sent.append(payload) or True\n"
"telemetry.flush()\n"
f"open({str(captured)!r}, 'w').write(json.dumps(sent))",
)
payloads = json.loads(captured.read_text(encoding="utf-8"))
batches = [p for p in payloads if "batch" in p]
assert batches, "nothing was sent"
properties = batches[0]["batch"][0]["properties"]
assert properties["source"] == "CLAUDE_CODE_PLUGIN"
assert properties["harness"] == "claude-code"
def test_every_event_carries_a_uuid_for_dedupe():
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
_run(core, data_dir, "import telemetry; telemetry.record('search'); telemetry.record('flush')")
lines = (data_dir / "telemetry.jsonl").read_text(encoding="utf-8").strip().splitlines()
ids = [json.loads(line)["uuid"] for line in lines]
assert len(ids) == 2
assert len(set(ids)) == 2
def test_source_tag_defaults_agree_between_the_two_modules():
"""configure_harness and telemetry.init must derive the same tag.
They disagreed: `<host>_plugin` in one and `MEM0_<HOST>_PLUGIN` in the other,
so one plugin could emit three different source values depending on which
process sent the batch.
"""
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
out = _run(
core,
Path(tmp),
"import memory_core, telemetry\n"
"memory_core.configure_harness('kimi')\n"
"telemetry.init(harness='kimi')\n"
"print(memory_core.harness_config()['source_tag'].upper(), telemetry._source_tag)",
)
left, right = out.split()
assert left == right == "KIMI_PLUGIN"
def test_the_plugin_declares_its_surface_in_the_body_and_the_headers():
"""Body and headers both, because only the body works on every backend."""
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
out = _run(
core,
Path(tmp),
"import json, memory_core\n"
"h = memory_core.platform_headers('k')\n"
"print(json.dumps({'source': h.get('X-Mem0-Source'),"
" 'app': h.get('X-Application'),"
" 'client': h.get('X-Mem0-Client'),"
" 'auth': h.get('Authorization'),"
" 'ctype': h.get('Content-Type')}))",
)
headers = json.loads(out)
assert headers["source"] == "MEM0_PLUGIN"
assert headers["app"] == "claude-code"
assert headers["client"].startswith("mem0-plugin/")
# The transport headers the three call sites relied on must survive.
assert headers["auth"] == "Token k"
assert headers["ctype"] == "application/json"
def _session_start(core: Path, data_dir: Path) -> list[str]:
"""Drive the real hook_runner session-start path and return lifecycle events."""
recorded = "\n".join(
[
"import io, json, sys",
f"sys.path.insert(0, {str(core)!r})",
"import telemetry, hook_runner",
"seen = []",
"telemetry.record = lambda event, **kw: seen.append(event) or None",
"telemetry.spawn_flush = lambda: False",
# run() reads sys.argv through argparse; it takes no positional args.
"sys.argv = ['hook_runner', 'session-start']",
"sys.stdin = io.StringIO('{}')",
"hook_runner.run()",
"print(json.dumps([e for e in seen if e in ('install', 'upgrade')]))",
]
)
import json as _json
return _json.loads(_run(core, data_dir, recorded) or "[]")
def test_a_fresh_install_reports_install_not_upgrade():
"""The decision must survive the writes hook_runner does before asking.
claim_install() is reached only after cache_plugin_api_key() has written
`api-key` and EvidenceStore() has created `evidence.sqlite3`. Asking "is the
data dir empty" at that point always saw content, so code.install could
never fire and every new user was counted as an upgrade.
"""
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp) / "data"
assert _session_start(core, data_dir) == ["install"]
def test_the_lifecycle_event_fires_exactly_once():
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp) / "data"
first = _session_start(core, data_dir)
second = _session_start(core, data_dir)
third = _session_start(core, data_dir)
assert first == ["install"]
assert second == []
assert third == []
def test_an_existing_data_dir_reports_upgrade():
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp) / "data"
data_dir.mkdir(parents=True)
# A 0.2.x leftover: the data dir survives the upgrade.
(data_dir / "requirements.txt").write_text("mem0ai\n", encoding="utf-8")
assert _session_start(core, data_dir) == ["upgrade"]
@@ -1,5 +1,6 @@
import type { MemoryLike } from "./formatting.ts";
import { formatMemoryCompact } from "./formatting.ts";
import { RECALL_HEADING } from "./prompts.ts";
const MAX_RECALL_QUERY_CHARS = 6_000;
export const DEFAULT_MAX_CONTEXT_CHARS = 4_000;
@@ -70,12 +71,14 @@ export function extractConversation(
}
interface RecallOptions {
heading?: string;
maxChars?: number;
seenIds?: Set<string>;
timeoutMs?: number;
}
interface MemoryLifecycleOptions {
recallHeading?: string;
maxContextChars?: number;
recallTimeoutMs?: number;
}
@@ -109,6 +112,7 @@ class MemoryLifecycle {
search: (query: string) => Promise<{ results?: unknown[] }>,
): Promise<string> {
return buildRecallContext(prompt, enabled, search, {
heading: this.#options.recallHeading,
maxChars: this.#options.maxContextChars,
seenIds: this.#seenMemoryIds,
timeoutMs: this.#options.recallTimeoutMs,
@@ -148,8 +152,7 @@ export async function buildRecallContext(
const unseen = memories.filter((memory) => !options.seenIds?.has(memory.id));
if (!unseen.length) return "";
const prefix =
"<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — search mem0_memory for more if you need it.\n";
const prefix = `<mem0-relevant-memories>\n${options.heading ?? RECALL_HEADING}\n`;
const suffix = "\n</mem0-relevant-memories>";
const maxChars = options.maxChars ?? DEFAULT_MAX_CONTEXT_CHARS;
const lines: string[] = [];
@@ -0,0 +1,10 @@
export const SEARCH_WHEN =
"before repeating investigation or when earlier decisions, fixes, commands, or results may help";
export const SEARCH_TOOL_DESCRIPTION = `Search memories from earlier work in this repository. Use it ${SEARCH_WHEN}.`;
export const SEARCH_QUERY_DESCRIPTION = "A direct question about earlier work in this repository.";
export const RECALL_HEADING = "Mem0 found these relevant memories from earlier work in this repository:";
export const USER_SEARCH_TOOL_DESCRIPTION = `Search memories from earlier work. Use it ${SEARCH_WHEN}.`;
export const USER_SEARCH_QUERY_DESCRIPTION = "A direct question about earlier work.";
export const USER_RECALL_HEADING = "Mem0 found these relevant memories from earlier work:";
@@ -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);
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildRecallContext } from "../src/lifecycle.ts";
import {
RECALL_HEADING,
SEARCH_QUERY_DESCRIPTION,
SEARCH_TOOL_DESCRIPTION,
USER_RECALL_HEADING,
} from "../src/prompts.ts";
const pythonSource = (name: string) =>
readFileSync(new URL(`../../python/${name}`, import.meta.url), "utf8").replace(/"\s*\n\s*"/g, "");
test("search prompts match the Python core", () => {
assert.ok(pythonSource("mcp_server.py").includes(SEARCH_TOOL_DESCRIPTION));
assert.ok(pythonSource("mcp_server.py").includes(SEARCH_QUERY_DESCRIPTION));
assert.ok(pythonSource("hook_runner.py").includes(RECALL_HEADING));
});
test("recall context uses the repository heading unless the host overrides it", async () => {
const search = async () => ({ results: [{ id: "m1", memory: "Use pnpm" }] });
assert.ok((await buildRecallContext("package manager", true, search)).includes(RECALL_HEADING));
assert.ok(
(await buildRecallContext("package manager", true, search, { heading: USER_RECALL_HEADING })).includes(
USER_RECALL_HEADING,
),
);
});
@@ -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();
});
@@ -0,0 +1,11 @@
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
HARNESS_ID = "antigravity"
SOURCE_TAG = "ANTIGRAVITY_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "antigravity"
@@ -290,6 +290,11 @@ def run(
if args.plugin_data_dir:
os.environ[data_dir_env] = args.plugin_data_dir
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
# after them always saw content and every fresh install reported an upgrade.
data_dir_was_empty = telemetry.data_dir_was_empty()
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
@@ -305,8 +310,19 @@ def run(
return 0
if args.action == "session-start":
if telemetry.is_first_run():
# Claims the marker atomically and says which event to record, so a
# second session starting alongside this one cannot record it too.
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
if first_event == "install":
telemetry.record("install")
elif first_event == "upgrade":
# First run after a build that never wrote the marker; the
# predecessor version was never recorded anywhere.
telemetry.record("upgrade", from_version="pre-0.3")
else:
previous = telemetry.claim_version_change()
if previous:
telemetry.record("upgrade", from_version=previous)
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
@@ -21,16 +21,9 @@ from memory_core import (
PROTOCOL_VERSION = "2024-11-05"
TOOL_NAME = "search_memories"
TOOL_DESCRIPTION = (
"Search memories from earlier work in this repository. ALWAYS call this "
"tool before answering anything that could depend on prior context: the "
"user's preferences, facts about this codebase, history, people, projects, "
"or earlier decisions. Do not rely on the chat window alone. The "
"repository's memory is shared by everyone who works in it and includes "
"what it took to run, test, or build here, so search before assuming an "
"invocation works. The scope argument changes what is searched: 'repo' "
"(default) is the whole repository's shared memory plus your own "
"preferences, 'dir' narrows the shared part to the directory you are "
"working in, and 'mine' is your preferences alone."
"Search memories from earlier work in this repository. Use it before "
"repeating investigation or when earlier decisions, fixes, commands, or "
"results may help."
)
TOOL_SCHEMA = {
"type": "object",
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.1"
PLUGIN_VERSION = "0.3.3"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
@@ -71,15 +71,13 @@ MAX_FLUSH_ATTEMPTS = 5
FORGET_PAGE_SIZE = 100
FORGET_MAX_PAGES = 50
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help anyone with future coding work in this repository.
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help with future coding work.
A completed change should produce one memory explaining the resulting behavior, where it is implemented when useful, and any important constraints or reasoning. Exploration or accepted decisions may produce separate memories only when they are independently useful.
A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own.
Use the coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Use the current coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state.
Write about the repository, not the user, assistant, session, or task. Do not include test results, documentation updates, release notes, or temporary state.
If nothing useful was established, return no memories."""
@@ -1800,6 +1798,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1833,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1860,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2006,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2556,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
+655 -39
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""Anonymous usage telemetry for Mem0 agent plugins.
"""Usage telemetry for Mem0 agent plugins.
Events are linked to your Mem0 account email when an API key is configured, and
to a random per-machine id otherwise. Not anonymous — the Python SDK and CLI
attribute the same way.
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
touches the network: `record` appends one JSON line to a local spool and returns.
@@ -9,7 +13,8 @@ started once per session and again from the flush worker that is already detache
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
Never sends prompts, memory text, queries, file paths, repository names, or API
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
keys: only event names, durations, counts, coarse outcomes, and repo/session
identifiers hashed with a random per-install salt.
"""
from __future__ import annotations
@@ -29,8 +34,24 @@ from typing import Any
import memory_core
_harness: str = "generic"
_source_tag: str = "MEM0_PLUGIN"
# Seeded from the per-host module the build generates into core/. Two processes
# in this pipeline never call init() — mcp_server.py, and the detached
# `python3 telemetry.py` sender that spawn_flush() starts — so a module default
# was what every one of their events got labelled with.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import HARNESS_ID as _DEFAULT_HARNESS
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG
except ImportError:
_DEFAULT_HARNESS = "generic"
_DEFAULT_SOURCE_TAG = "MEM0_PLUGIN"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
_salt_cache: str = ""
_harness: str = _DEFAULT_HARNESS
_source_tag: str = _DEFAULT_SOURCE_TAG
_PRIVATE_KEYS = {
"apikey",
"authorization",
@@ -56,10 +77,19 @@ _PRIVATE_KEYS = {
}
def init(harness: str = "generic", source_tag: str = "") -> None:
def init(harness: str = "", source_tag: str = "") -> None:
"""Override the generated identity. Optional — core/_harness_id.py is the default.
The fallback shape matches memory_core.configure_harness's (``<HOST>_PLUGIN``).
It used to be ``MEM0_<HOST>_PLUGIN`` here and ``<host>_plugin`` there, which
meant one plugin could emit three different source values depending on which
process happened to send the batch.
"""
global _harness, _source_tag
_harness = harness
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
_harness = harness or _DEFAULT_HARNESS
_source_tag = source_tag or (
f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG
)
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
@@ -70,6 +100,16 @@ BATCH_SIZE = 100
SEND_TIMEOUT = 5
CLAIM_STALE_SECONDS = 120
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
# A batch is only discarded once it has genuinely been retried this many times.
MAX_CLAIM_ATTEMPTS = 3
# Parked claims drained per run, after the live spool. Bounded so a long backlog
# cannot turn one flush into an unbounded send loop.
MAX_PARKED_PER_RUN = 3
# Added to the wait before a released claim becomes reclaimable, per attempt
# already spent. Releasing straight to "reclaimable now" let two senders burn the
# whole budget within seconds of one another on a single momentary failure, and
# discard a batch a retry a minute later would have delivered.
RETRY_COOLDOWN_SECONDS = 60
def is_enabled() -> bool:
@@ -83,9 +123,126 @@ def is_enabled() -> bool:
def _digest(value: str, length: int = 16) -> str:
"""Unsalted digest. Only for values that are already secrets (API keys)."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
def _salt_path() -> Path:
return memory_core.data_dir() / "telemetry-salt"
def _install_salt() -> str:
"""Random per-install salt, created once and memoized for the process.
Deliberately its own file, claimed with O_CREAT|O_EXCL, rather than a key in
the identity file. Three reasons, all of which produced wrong data when this
lived in the identity dict:
- Hooks are short-lived separate processes firing on every tool call, and
people run more than one agent window. A read-modify-write would let each
process mint its own salt, so one repository would hash several ways in the
window before a writer won.
- resolve_distinct_id holds a copy of the identity dict across a network call
to /v1/ping/, so whichever write landed second erased the other's key —
losing either the salt (repo_hash changes mid-stream) or the email (a
second $identify, splitting the person).
- Touching the identity file from record() would create it, and is_first_run
keys off that file, so recording an event would silently suppress the
install event.
Published atomically, and there is deliberately no derived fallback. Creating
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
the file exists and is empty, and a concurrent hook that reads it in that
window gets nothing. Falling back to a digest of the path would hand that
process a salt an attacker can compute, memoized for its whole run, which is
the privacy control this function exists to provide silently turning itself
off under load. The salt is written to a private temp file first and linked
into place, so the name either does not exist or already has the full value.
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
rather than emit an unsalted one.
"""
global _salt_cache
if _salt_cache:
return _salt_cache
path = _salt_path()
# Read before writing. Hooks are separate processes firing on every tool
# call, so all but the first find the salt already published; going straight
# to create-fsync-link-unlink meant every one of them paid an fsync to
# discover that, on a path whose whole promise is appending a line and
# returning.
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
if _salt_cache:
return _salt_cache
except OSError:
pass
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(uuid.uuid4().hex)
stream.flush()
os.fsync(stream.fileno())
try:
# Atomic claim: fails if another process already published one.
# os.link rather than replace, which would clobber theirs.
os.link(temporary, path)
except FileExistsError:
pass
except OSError:
# No hardlinks here (some network mounts, some container volumes).
# Claim the name directly instead. That reopens the empty-file
# window, but the window is now benign: a reader that lands in it
# gets "" and omits the hash for that process rather than caching a
# guessable one. Losing the hashes on every run of an entire
# filesystem is the worse failure.
try:
fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fallback, "w", encoding="utf-8") as stream:
stream.write(temporary.read_text(encoding="utf-8"))
except OSError:
pass
except OSError:
pass
finally:
try:
temporary.unlink()
except OSError:
pass
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
except OSError:
_salt_cache = ""
return _salt_cache
def _scoped_digest(value: str, length: int = 16) -> str:
"""Salted digest for values drawn from a guessable space.
repo.identity is a git remote URL, or ``local:<absolute path>`` when there is
no remote — which normally contains the account username. Sixteen unsalted
hex characters over that input space is enumerable, so this is not a
privacy control without the salt. Salting per install keeps every
within-account join the analytics actually use and gives up only
cross-machine joins on the same repository, which nothing computes.
Returns "" when there is no salt, so record() omits the property. An
unsalted digest over this input space is close to plaintext, and emitting one
under a name that implies it is hashed is worse than sending nothing.
"""
if not value:
return ""
salt = _install_salt()
if not salt:
return ""
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
def _safe_value(value: Any) -> Any:
if isinstance(value, str):
return memory_core.redact(value)
@@ -145,9 +302,176 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str:
return created
def _rotate_anonymous_id(identity: dict[str, str]) -> str:
"""Mint a fresh anonymous id because the account context is gone.
The previous id may already have been merged into a person profile by an
$identify, and that merge is permanent. Reusing it after a logout or a key
change attributes everything that follows to the account that just went
away, which is the same misattribution the key fingerprint exists to stop,
only arriving through the anonymous path instead.
`aliased` is cleared with it: the new id has never been merged, so it is
eligible to be aliased into whatever account comes next.
"""
created = f"code-anon-{uuid.uuid4().hex}"
identity["anonymous_id"] = created
identity.pop("aliased", None)
_write_identity(identity)
return created
def _install_state_path() -> Path:
return memory_core.data_dir() / "install-state.json"
def is_first_run() -> bool:
"""Whether this machine has never recorded a plugin event before."""
return not _identity_path().exists()
"""Whether install has never been recorded on this machine.
Deliberately NOT the identity file. That file is only written by a
successful flush, so an offline or firewalled user recorded code.install on
every single session, forever — and every 0.2.x user recorded one on their
first 0.3.x session because 0.2.x never wrote it at all.
"""
return not _install_state_path().exists()
def data_dir_was_empty() -> bool:
"""Whether the data directory is untouched. Call BEFORE anything writes to it.
hook_runner reaches claim_install() only after cache_plugin_api_key() has
written `api-key` and EvidenceStore() has created `evidence.sqlite3`, so
asking at claim time always saw content and every fresh install reported an
upgrade. The caller snapshots this at the top of the run instead.
"""
return not _data_dir_has_content()
def claim_install(was_empty: bool | None = None) -> str | None:
"""Claim the one install/upgrade record for this machine, atomically.
Returns the event to record ("install" or "upgrade"), or None if another
session already claimed it. O_CREAT|O_EXCL so two sessions starting together
cannot both win.
`was_empty` must come from data_dir_was_empty() called before this process
wrote anything. Omitting it falls back to checking now, which is only
correct for a caller that has touched nothing.
"""
if not is_enabled():
# Never consume the one-shot claim while the user is opted out, or they
# would silently lose their install event if they later opt in.
return None
path = _install_state_path()
upgrading = not (data_dir_was_empty() if was_empty is None else was_empty)
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
return None
except OSError:
return None
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump(
{
"plugin_version": memory_core.PLUGIN_VERSION,
"installed_at": memory_core.utc_now(),
"upgraded": upgrading,
},
stream,
)
# Durable before this returns. The O_EXCL open is what makes the
# claim exclusive, so it cannot be replaced by a temp-and-rename
# without losing that, which leaves the content as the thing to make
# safe. A kill between the open and this fsync used to leave a marker
# that exists but parses to nothing: is_first_run reads it as claimed
# and claim_version_change cannot read a version out of it.
stream.flush()
os.fsync(stream.fileno())
except OSError:
pass
return "upgrade" if upgrading else "install"
def _data_dir_has_content() -> bool:
"""Whether anything predates this session in the plugin data directory."""
try:
for entry in memory_core.data_dir().iterdir():
if entry.name != "install-state.json":
return True
except OSError:
pass
return False
def _repair_install_state(path: Path) -> None:
"""Rewrite an unparseable marker so version tracking can resume."""
try:
temporary = path.with_suffix(f".{os.getpid()}.tmp")
temporary.write_text(
json.dumps({"plugin_version": memory_core.PLUGIN_VERSION, "repaired_at": memory_core.utc_now()}),
encoding="utf-8",
)
temporary.replace(path)
except OSError:
pass
def claim_version_change() -> str | None:
"""Return the previously recorded version if it differs, updating the marker.
Only meaningful once the marker exists — the first transition into 0.3.x has
no recorded predecessor and reports "pre-0.3" instead. Claiming by rewriting
the marker means the next session sees no change and records nothing.
"""
path = _install_state_path()
try:
state = json.loads(path.read_text(encoding="utf-8"))
except OSError:
return None
except json.JSONDecodeError:
# A crash between O_EXCL and the write leaves an empty marker. Left
# alone it disables every future upgrade event on this machine, because
# claim_install sees the file and this function cannot parse it.
state = None
if not isinstance(state, dict):
_repair_install_state(path)
return None
previous = str(state.get("plugin_version") or "")
if not previous or previous == memory_core.PLUGIN_VERSION:
return None
# Claim the transition with an exclusive sentinel before rewriting the
# marker. A plain read-modify-write let every concurrently starting session
# observe the old version and each record its own upgrade — and the first
# session after a version bump is exactly when several agent windows restart
# together.
sentinel = path.with_name(f"upgraded-{memory_core.PLUGIN_VERSION}")
try:
os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
except FileExistsError:
return None
except OSError:
return None
state["plugin_version"] = memory_core.PLUGIN_VERSION
state["upgraded_at"] = memory_core.utc_now()
temporary = path.with_suffix(f".{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(state), encoding="utf-8")
temporary.replace(path)
except OSError:
# Release the claim. The marker still records the old version, so
# without this the sentinel makes claim_version_change return early on
# every later run and this version's upgrade is never recorded again.
for leftover in (sentinel, temporary):
try:
leftover.unlink()
except OSError:
pass
return None
return previous
def record(
@@ -168,19 +492,32 @@ def record(
except OSError:
pass
properties = _safe_value(properties)
# Stamped in the RECORDING process, beside harness. `source` used to be
# read in the sending process from a module global, so whichever process
# drained the spool named every event in it. flush() spreads per-event
# properties last, so this now wins over any sender's default.
properties.update(
harness=_harness,
source=_source_tag,
plugin_version=memory_core.PLUGIN_VERSION,
os=sys.platform,
python_version=platform.python_version(),
)
# Assigned only when the digest is real. _scoped_digest returns "" when
# the salt could not be persisted, and an empty property is worse than an
# absent one: it survives the None filter below and reads as a value.
if repo is not None:
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
if repo_hash:
properties["repo_hash"] = repo_hash
if session_id:
properties["session_hash"] = _digest(session_id)
session_hash = _scoped_digest(session_id)
if session_hash:
properties["session_hash"] = session_hash
line = json.dumps(
{
"event": f"{EVENT_PREFIX}.{event}",
"uuid": str(uuid.uuid4()),
"timestamp": memory_core.utc_now(),
"properties": {
key: value for key, value in properties.items() if value is not None
@@ -239,38 +576,201 @@ def spawn_flush() -> bool:
return False
def _claim_name(attempt: int = 0) -> str:
"""Claim filename. The attempt count rides in the name so the 7-day expiry
only ever discards a batch that was actually retried and failed."""
return f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}-a{attempt}.sending"
def _claim_attempt(claim: Path) -> int:
"""Attempts recorded in a claim filename; 0 for the pre-attempt-count shape.
Anchored on field position, not on a leading "a": the legacy shape is
``telemetry-<pid>-<hex>.sending`` and a hex id such as ``a1234567`` would
otherwise parse as attempt 1234567 and be discarded unsent on the first
flush after an upgrade.
"""
stem = claim.name[: -len(".sending")] if claim.name.endswith(".sending") else claim.name
parts = stem.split("-")
if len(parts) != 4:
return 0
tail = parts[3]
if tail.startswith("a") and tail[1:].isdigit():
return int(tail[1:])
return 0
def _touch(path: Path) -> None:
"""Refresh mtime so a claim's age measures time since it was claimed.
``Path.replace`` is ``os.rename``, which preserves mtime — so a claim created
after a quiet minute inherited the spool's last-write time and looked
abandoned the instant it was made. A second sender would then take it over
while the first was still posting, and both would deliver the batch.
"""
try:
os.utime(path, None)
except OSError:
pass
def _claim_spool() -> Path | None:
"""Rename the spool aside so exactly one sender owns each batch."""
directory = memory_core.data_dir()
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
claim = directory / _claim_name()
spool = _spool_path()
try:
spool.replace(claim)
_touch(claim)
return claim
except OSError:
pass
return _claim_parked(directory)
def _sweep_debris(directory: Path) -> None:
"""Remove files nothing else will ever pick up again.
*.partial is a temp file orphaned by a crash between write and rename.
*.corrupt is a batch quarantined for undecodable content. No glob in this
module matches either, so without this they accumulate on disk for the life
of the install.
Quarantined batches are kept far longer than debris: they are the only
evidence left of events that could not be delivered, and someone diagnosing
a report of missing telemetry has to be able to find one.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending")):
for debris in directory.glob("telemetry-*.partial"):
try:
if now - debris.stat().st_mtime > CLAIM_STALE_SECONDS:
debris.unlink()
except OSError:
continue
for quarantined in directory.glob("telemetry-*.corrupt"):
try:
if now - quarantined.stat().st_mtime > CLAIM_EXPIRY_SECONDS:
quarantined.unlink()
except OSError:
continue
# The same reasoning covers *.tmp. _write_identity and _install_salt both
# create one and unlink it in a finally, which a SIGKILL skips, and no glob
# in this module matches the leftovers either.
for temporary in directory.glob("telemetry-*.tmp"):
try:
if now - temporary.stat().st_mtime > CLAIM_STALE_SECONDS:
temporary.unlink()
except OSError:
continue
def _claim_parked(directory: Path) -> Path | None:
"""Take the oldest abandoned claim, if any lease has actually expired.
Kept separate from the live spool so flush() can drain both in one run.
Previously parked batches were only reachable when no spool existed at all,
and because sessions keep recording there usually was one — so a batch
parked by a failed send waited until the 7-day expiry deleted it unsent,
even though its own presence is what started the sender.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending"), key=_safe_mtime):
try:
age = now - orphan.stat().st_mtime
except OSError:
continue
if age > CLAIM_EXPIRY_SECONDS:
if age < CLAIM_STALE_SECONDS:
# Someone else holds a live lease on it. This check has to come
# first. Claiming a file bumps its attempt count and refreshes its
# mtime, so a sender that has just taken the final attempt looks
# exhausted to everyone else while it is actively draining. Judging
# exhaustion before liveness let a second sender unlink a batch out
# from under its owner, losing every event in it.
continue
# Attempts, not age. Every re-claim touches the mtime and every release
# backdates it by a fixed amount, so age is pinned near the stale
# threshold and never reaches the expiry. Age stays only as a backstop
# for files that never carried an attempt marker.
if _claim_attempt(orphan) >= MAX_CLAIM_ATTEMPTS or age > CLAIM_EXPIRY_SECONDS:
try:
orphan.unlink()
except OSError:
pass
continue
if age < CLAIM_STALE_SECONDS:
continue
claim = orphan.parent / _claim_name(_claim_attempt(orphan) + 1)
try:
orphan.replace(claim)
_touch(claim)
return claim
except OSError:
continue
return None
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _rewrite_claim(claim: Path, remaining: list[dict[str, Any]]) -> bool:
"""Persist the unsent remainder, atomically, and refresh the lease.
Called after every successful batch. Two jobs: a retry resumes where the
send stopped instead of re-posting from the top, and the rewrite doubles as
the lease heartbeat, so a slow sender does not have its claim stolen
mid-flight. Interval is one batch, well inside CLAIM_STALE_SECONDS.
"""
if not remaining:
try:
claim.unlink()
except OSError:
pass
return True
temporary = claim.with_suffix(f".{os.getpid()}.partial")
try:
payload = "".join(json.dumps(event, separators=(",", ":"), default=str) + "\n" for event in remaining)
# fsync before the rename: without it the rename can land while the
# bytes have not, and the claim comes back empty or truncated after a
# crash. _drain then reads zero events and unlinks it.
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(claim)
_touch(claim)
return True
except OSError:
try:
temporary.unlink()
except OSError:
pass
return False
def _release_claim(claim: Path, remaining: list[dict[str, Any]]) -> None:
"""Persist the remainder and drop the lease, because this sender has given up.
Distinct from the per-batch heartbeat: heartbeating on the way out would
make an abandoned batch look actively owned for a further
CLAIM_STALE_SECONDS, delaying the retry for no reason. Ageing it past the
threshold lets the next flush pick it up immediately, while the attempt
count in the filename still bounds how many times that can happen.
"""
if not _rewrite_claim(claim, remaining):
return
try:
# Backdate past the stale threshold so the next flush can pick it up,
# minus a cooldown that grows with the attempts already spent. Clamped so
# the mtime never lands in the future, which would read as a live lease.
cooldown = min(_claim_attempt(claim) * RETRY_COOLDOWN_SECONDS, CLAIM_STALE_SECONDS)
released = time.time() - CLAIM_STALE_SECONDS - 1 + cooldown
os.utime(claim, (released, released))
except OSError:
pass
def _resolve_email(key: str) -> str:
"""Trade the API key for the account email so events join other Mem0 surfaces."""
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
@@ -300,34 +800,130 @@ def _post(payload: dict[str, Any], url: str) -> bool:
def resolve_distinct_id() -> tuple[str, str]:
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
"""Return the PostHog distinct id and the anonymous id it replaced, if any.
The second value becomes a PostHog $identify alias. It is ONLY ever an
anonymous id: aliasing one account email to another merges two real person
profiles and cannot be undone, so a key that now belongs to a different
account re-resolves with no alias.
"""
identity = _read_identity()
email = identity.get("email", "")
if email:
return email, ""
key = memory_core.api_key()
fingerprint = _digest(key) if key else ""
email = identity.get("email", "")
if email and fingerprint:
recorded = identity.get("key_fingerprint", "")
if recorded == fingerprint:
return email, ""
if not recorded:
# Rows written before fingerprints existed. Verify rather than
# adopt: a key changed before the upgrade would otherwise bind the
# new key to the previous account's email, permanently, and the
# fingerprint would then agree with itself forever after.
verified = _resolve_email(key)
if not verified:
# Offline, firewalled, or the API is down. Keep the previous
# behaviour and retry on the next flush rather than dropping a
# real account attribution. Safe because the same network that
# failed /v1/ping/ is about to fail the PostHog POST, so nothing
# is delivered under the unverified identity in the meantime.
return email, ""
identity["email"] = verified
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return verified, ""
if not key:
# No key to verify the account with; do not keep attributing to it.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
email = _resolve_email(key)
if not email:
resolved = _resolve_email(key)
if not resolved:
# The key changed and will not resolve (revoked, offline, API down).
# Reaching here with an email means the recorded fingerprint disagreed,
# so the key really did change. Drop the account and rotate: the stored
# anonymous id may already be merged into that account's person, and
# reusing it would keep the events on the profile we are trying to
# leave.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
previous = identity.get("anonymous_id", "")
identity["email"] = email
# Alias only when going anonymous -> email for the first time. Once an anon
# id has been merged into an account it must never be offered again: an
# alias naming an already-identified id is what could link two real people.
previous = "" if (email or identity.get("aliased")) else identity.get("anonymous_id", "")
if previous:
identity["aliased"] = True
identity["email"] = resolved
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return email, previous
return resolved, previous
def flush() -> int:
"""Drain claimed spools to PostHog and return the number of events sent."""
"""Drain the live spool, then any parked claims, and return events sent."""
if not is_enabled():
return 0
claim = _claim_spool()
sent, delivered = _drain(_claim_spool())
if not delivered:
# The network is failing. Retrying other batches now would only burn
# their attempt budget against the same broken connection.
return sent
# Parked batches used to starve behind the live spool indefinitely. Bounded
# per run so a long backlog cannot turn one flush into an unbounded loop.
directory = memory_core.data_dir()
_sweep_debris(directory)
for _ in range(MAX_PARKED_PER_RUN):
parked = _claim_parked(directory)
if parked is None:
break
count, delivered = _drain(parked)
sent += count
if not delivered:
break
return sent
def _drain(claim: Path | None) -> tuple[int, bool]:
"""Post one claimed batch file, recording progress after every batch.
Returns (events sent, whether everything was delivered).
"""
if claim is None:
return 0
return 0, True
try:
lines = claim.read_text(encoding="utf-8").splitlines()
except ValueError:
# UnicodeDecodeError from a torn write: the content is unrecoverable, so
# quarantine rather than retry. flush() runs from a bare `finally:` in
# flush_worker, so raising here also skips the handoff cleanup, and an
# undecodable file would otherwise be re-read on every flush forever.
# Reported as delivered because there is nothing left to deliver and the
# rest of the run should continue.
try:
claim.replace(claim.with_suffix(".corrupt"))
except OSError:
try:
claim.unlink()
except OSError:
pass
return 0, True
except OSError:
return 0
# Could not read it, which is not the same as having nothing to send.
# The file is left exactly where it is: a vanished or briefly unreadable
# claim is retryable, and quarantining it here would discard events over
# a transient filesystem error. Reported as undelivered so the run stops
# instead of counting a batch nothing was posted from as delivered.
return 0, False
events = []
for line in lines:
try:
@@ -337,11 +933,18 @@ def flush() -> int:
if isinstance(value, dict) and value.get("event"):
events.append(value)
if not events:
# Only delete when the file really is empty. A non-empty file that
# parses to nothing is a torn write, and its contents are the unsent
# remainder — deleting it is the data loss this PR exists to prevent.
try:
claim.unlink()
empty = claim.stat().st_size == 0
except OSError:
empty = True
try:
claim.replace(claim.with_suffix(".corrupt")) if not empty else claim.unlink()
except OSError:
pass
return 0
return 0, True
distinct_id, aliased_anonymous_id = resolve_distinct_id()
if aliased_anonymous_id:
@@ -360,12 +963,17 @@ def flush() -> int:
sent = 0
for start in range(0, len(events), BATCH_SIZE):
chunk = events[start : start + BATCH_SIZE]
batch = [
{
"event": event["event"],
"distinct_id": distinct_id,
# Carried through from record() so a resend can be collapsed.
"uuid": event.get("uuid"),
"timestamp": event.get("timestamp"),
"properties": {
# Fallback only: events recorded by a build before source
# moved into record() have none of their own.
"source": _source_tag,
"language": "python",
"$process_person_profile": False,
@@ -373,16 +981,24 @@ def flush() -> int:
**(event.get("properties") or {}),
},
}
for event in events[start : start + BATCH_SIZE]
for event in chunk
]
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
return sent
sent += len(batch)
try:
claim.unlink()
except OSError:
pass
return sent
# Keep only what has not been delivered, and release the lease.
# Previously the whole file was kept and the retry re-posted every
# batch, including the ones that had already arrived.
_release_claim(claim, events[start:])
return sent, False
sent += len(chunk)
# Record progress and refresh the lease after each successful batch, so
# a crash repeats at most one batch instead of the entire file. If the
# rewrite fails the claim still holds delivered events, so stop rather
# than carry on as though progress were recorded — continuing is how the
# duplicate delivery this PR fixes would come back.
if not _rewrite_claim(claim, events[start + len(chunk) :]):
_release_claim(claim, events[start + len(chunk) :])
return sent, False
return sent, True
def main() -> int:
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.1",
"version": "0.3.3",
"homepage": "https://docs.mem0.ai/integrations/antigravity",
"native": {
"pluginRoot": "${ANTIGRAVITY_PLUGIN_ROOT}",
@@ -7,8 +7,8 @@ disable-model-invocation: true
# Pause memory capture
To pause (hooks stop capturing and sending session content; a minimal
anonymous telemetry ping still fires at session start unless
`MEM0_TELEMETRY=false`):
telemetry ping still fires at session start, under your Mem0 account email,
unless `MEM0_TELEMETRY=false`):
```bash
python3 "${ANTIGRAVITY_PLUGIN_ROOT}/core/memory_cli.py" --harness "antigravity" pause
@@ -12,11 +12,9 @@ Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
query.
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
category; a category is a best-effort label Mem0 assigned when it saved the
memory, so if a category search misses, repeat it without the category. Omit
`scope` to use the configured default, normally `repo`: this repository's
shared memory, which everyone who works in it contributes to, plus your own
preferences.
category. Omit `scope` to use the configured default, normally `repo`: this
repository's shared memory, which everyone who works in it contributes to,
plus your own preferences.
Pass `scope` when the question needs something else: `dir` to narrow the
shared memory to the directory you are working in (a package inside a
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.1",
"version": "0.3.3",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0"
+12 -2
View File
@@ -136,13 +136,23 @@ Local data lives in `${CLAUDE_PLUGIN_DATA}`:
- `pending/`: sessions waiting to be sent to Mem0 (retried after interruption)
- `flush-worker.log`: whether memory creation succeeded
- `plugin-errors.log`: hook errors (no credentials)
- `telemetry.jsonl` / `telemetry-identity.json`: anonymous usage events
- `telemetry.jsonl` / `telemetry-identity.json`: usage events and the id they are sent under
- `telemetry-salt`: random per-install salt for the repo and session hashes
- `install-state.json`: records that install has been counted once on this machine
Mem0 receives captured user messages, Claude's answers, sidekick assignments and completed responses, and changed file paths. When a failed command is recorded, extraction can also include bounded command details and results. Complete files and general tool output stay on your machine. Values that look like credentials are redacted before anything is sent.
## Telemetry
Anonymous usage events (which hook ran, timing, result counts, failure types) so Mem0 can identify what's used and what's breaking. Repo and session IDs are hashed before leaving your machine. Prompts, memory text, file paths, tool output, and API keys are never sent.
Usage events (which hook ran, timing, result counts, failure types) so Mem0 can identify what's used and what's breaking.
**These events are not anonymous.** When an API key is configured — which installing the plugin requires — events are sent under your Mem0 account email, the same way the Python SDK and the CLI attribute theirs. Without a key they are sent under a random per-machine id.
What each event carries: the event name, the plugin version, the harness it ran in, your OS and Python version, and per-event properties describing what happened — timings, counts, coarse outcome and failure labels, and which model was configured. Repository and session identifiers are hashed with a random salt generated on your machine, so they cannot be linked back to a repository name or path.
Rather than restate a list that drifts, the exact set is enforced in code: `telemetry.record` filters every property through a denylist of sensitive keys and redacts credential-shaped values. See `_PRIVATE_KEYS` in `core/telemetry.py`.
Prompts, memory text, queries, file paths, repository names, and API keys are never sent.
Turn it off:
@@ -12,11 +12,8 @@ You are Mem0's Sonnet coding agent. Complete the work the main agent gives you.
Work in the separate Git worktree Claude Code created for you. Return a tested
result that the main agent can review without doing the same work again.
ALWAYS call `search_memories` before answering anything that could depend on
prior context (the user's preferences, facts about this codebase, history,
people, projects, or earlier decisions). Do not rely on the chat window or
assume you know enough from the current conversation. Search with a focused
question before investigating the repository.
When memories from earlier sessions could help, call `search_memories` with a
focused question before searching the repository again.
Inspect the relevant code and repository rules. Reproduce the problem when that
helps. Decide the implementation details, edit files when asked, and test the
@@ -0,0 +1,11 @@
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
HARNESS_ID = "claude-code"
SOURCE_TAG = "CLAUDE_CODE_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "claude-code"
@@ -290,6 +290,11 @@ def run(
if args.plugin_data_dir:
os.environ[data_dir_env] = args.plugin_data_dir
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
# after them always saw content and every fresh install reported an upgrade.
data_dir_was_empty = telemetry.data_dir_was_empty()
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
@@ -305,8 +310,19 @@ def run(
return 0
if args.action == "session-start":
if telemetry.is_first_run():
# Claims the marker atomically and says which event to record, so a
# second session starting alongside this one cannot record it too.
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
if first_event == "install":
telemetry.record("install")
elif first_event == "upgrade":
# First run after a build that never wrote the marker; the
# predecessor version was never recorded anywhere.
telemetry.record("upgrade", from_version="pre-0.3")
else:
previous = telemetry.claim_version_change()
if previous:
telemetry.record("upgrade", from_version=previous)
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
@@ -21,16 +21,9 @@ from memory_core import (
PROTOCOL_VERSION = "2024-11-05"
TOOL_NAME = "search_memories"
TOOL_DESCRIPTION = (
"Search memories from earlier work in this repository. ALWAYS call this "
"tool before answering anything that could depend on prior context: the "
"user's preferences, facts about this codebase, history, people, projects, "
"or earlier decisions. Do not rely on the chat window alone. The "
"repository's memory is shared by everyone who works in it and includes "
"what it took to run, test, or build here, so search before assuming an "
"invocation works. The scope argument changes what is searched: 'repo' "
"(default) is the whole repository's shared memory plus your own "
"preferences, 'dir' narrows the shared part to the directory you are "
"working in, and 'mine' is your preferences alone."
"Search memories from earlier work in this repository. Use it before "
"repeating investigation or when earlier decisions, fixes, commands, or "
"results may help."
)
TOOL_SCHEMA = {
"type": "object",
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.1"
PLUGIN_VERSION = "0.3.3"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
@@ -71,15 +71,13 @@ MAX_FLUSH_ATTEMPTS = 5
FORGET_PAGE_SIZE = 100
FORGET_MAX_PAGES = 50
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help anyone with future coding work in this repository.
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help with future coding work.
A completed change should produce one memory explaining the resulting behavior, where it is implemented when useful, and any important constraints or reasoning. Exploration or accepted decisions may produce separate memories only when they are independently useful.
A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own.
Use the coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Use the current coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state.
Write about the repository, not the user, assistant, session, or task. Do not include test results, documentation updates, release notes, or temporary state.
If nothing useful was established, return no memories."""
@@ -1800,6 +1798,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1833,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1860,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2006,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2556,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
+655 -39
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""Anonymous usage telemetry for Mem0 agent plugins.
"""Usage telemetry for Mem0 agent plugins.
Events are linked to your Mem0 account email when an API key is configured, and
to a random per-machine id otherwise. Not anonymous — the Python SDK and CLI
attribute the same way.
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
touches the network: `record` appends one JSON line to a local spool and returns.
@@ -9,7 +13,8 @@ started once per session and again from the flush worker that is already detache
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
Never sends prompts, memory text, queries, file paths, repository names, or API
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
keys: only event names, durations, counts, coarse outcomes, and repo/session
identifiers hashed with a random per-install salt.
"""
from __future__ import annotations
@@ -29,8 +34,24 @@ from typing import Any
import memory_core
_harness: str = "generic"
_source_tag: str = "MEM0_PLUGIN"
# Seeded from the per-host module the build generates into core/. Two processes
# in this pipeline never call init() — mcp_server.py, and the detached
# `python3 telemetry.py` sender that spawn_flush() starts — so a module default
# was what every one of their events got labelled with.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import HARNESS_ID as _DEFAULT_HARNESS
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG
except ImportError:
_DEFAULT_HARNESS = "generic"
_DEFAULT_SOURCE_TAG = "MEM0_PLUGIN"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
_salt_cache: str = ""
_harness: str = _DEFAULT_HARNESS
_source_tag: str = _DEFAULT_SOURCE_TAG
_PRIVATE_KEYS = {
"apikey",
"authorization",
@@ -56,10 +77,19 @@ _PRIVATE_KEYS = {
}
def init(harness: str = "generic", source_tag: str = "") -> None:
def init(harness: str = "", source_tag: str = "") -> None:
"""Override the generated identity. Optional — core/_harness_id.py is the default.
The fallback shape matches memory_core.configure_harness's (``<HOST>_PLUGIN``).
It used to be ``MEM0_<HOST>_PLUGIN`` here and ``<host>_plugin`` there, which
meant one plugin could emit three different source values depending on which
process happened to send the batch.
"""
global _harness, _source_tag
_harness = harness
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
_harness = harness or _DEFAULT_HARNESS
_source_tag = source_tag or (
f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG
)
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
@@ -70,6 +100,16 @@ BATCH_SIZE = 100
SEND_TIMEOUT = 5
CLAIM_STALE_SECONDS = 120
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
# A batch is only discarded once it has genuinely been retried this many times.
MAX_CLAIM_ATTEMPTS = 3
# Parked claims drained per run, after the live spool. Bounded so a long backlog
# cannot turn one flush into an unbounded send loop.
MAX_PARKED_PER_RUN = 3
# Added to the wait before a released claim becomes reclaimable, per attempt
# already spent. Releasing straight to "reclaimable now" let two senders burn the
# whole budget within seconds of one another on a single momentary failure, and
# discard a batch a retry a minute later would have delivered.
RETRY_COOLDOWN_SECONDS = 60
def is_enabled() -> bool:
@@ -83,9 +123,126 @@ def is_enabled() -> bool:
def _digest(value: str, length: int = 16) -> str:
"""Unsalted digest. Only for values that are already secrets (API keys)."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
def _salt_path() -> Path:
return memory_core.data_dir() / "telemetry-salt"
def _install_salt() -> str:
"""Random per-install salt, created once and memoized for the process.
Deliberately its own file, claimed with O_CREAT|O_EXCL, rather than a key in
the identity file. Three reasons, all of which produced wrong data when this
lived in the identity dict:
- Hooks are short-lived separate processes firing on every tool call, and
people run more than one agent window. A read-modify-write would let each
process mint its own salt, so one repository would hash several ways in the
window before a writer won.
- resolve_distinct_id holds a copy of the identity dict across a network call
to /v1/ping/, so whichever write landed second erased the other's key —
losing either the salt (repo_hash changes mid-stream) or the email (a
second $identify, splitting the person).
- Touching the identity file from record() would create it, and is_first_run
keys off that file, so recording an event would silently suppress the
install event.
Published atomically, and there is deliberately no derived fallback. Creating
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
the file exists and is empty, and a concurrent hook that reads it in that
window gets nothing. Falling back to a digest of the path would hand that
process a salt an attacker can compute, memoized for its whole run, which is
the privacy control this function exists to provide silently turning itself
off under load. The salt is written to a private temp file first and linked
into place, so the name either does not exist or already has the full value.
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
rather than emit an unsalted one.
"""
global _salt_cache
if _salt_cache:
return _salt_cache
path = _salt_path()
# Read before writing. Hooks are separate processes firing on every tool
# call, so all but the first find the salt already published; going straight
# to create-fsync-link-unlink meant every one of them paid an fsync to
# discover that, on a path whose whole promise is appending a line and
# returning.
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
if _salt_cache:
return _salt_cache
except OSError:
pass
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(uuid.uuid4().hex)
stream.flush()
os.fsync(stream.fileno())
try:
# Atomic claim: fails if another process already published one.
# os.link rather than replace, which would clobber theirs.
os.link(temporary, path)
except FileExistsError:
pass
except OSError:
# No hardlinks here (some network mounts, some container volumes).
# Claim the name directly instead. That reopens the empty-file
# window, but the window is now benign: a reader that lands in it
# gets "" and omits the hash for that process rather than caching a
# guessable one. Losing the hashes on every run of an entire
# filesystem is the worse failure.
try:
fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fallback, "w", encoding="utf-8") as stream:
stream.write(temporary.read_text(encoding="utf-8"))
except OSError:
pass
except OSError:
pass
finally:
try:
temporary.unlink()
except OSError:
pass
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
except OSError:
_salt_cache = ""
return _salt_cache
def _scoped_digest(value: str, length: int = 16) -> str:
"""Salted digest for values drawn from a guessable space.
repo.identity is a git remote URL, or ``local:<absolute path>`` when there is
no remote — which normally contains the account username. Sixteen unsalted
hex characters over that input space is enumerable, so this is not a
privacy control without the salt. Salting per install keeps every
within-account join the analytics actually use and gives up only
cross-machine joins on the same repository, which nothing computes.
Returns "" when there is no salt, so record() omits the property. An
unsalted digest over this input space is close to plaintext, and emitting one
under a name that implies it is hashed is worse than sending nothing.
"""
if not value:
return ""
salt = _install_salt()
if not salt:
return ""
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
def _safe_value(value: Any) -> Any:
if isinstance(value, str):
return memory_core.redact(value)
@@ -145,9 +302,176 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str:
return created
def _rotate_anonymous_id(identity: dict[str, str]) -> str:
"""Mint a fresh anonymous id because the account context is gone.
The previous id may already have been merged into a person profile by an
$identify, and that merge is permanent. Reusing it after a logout or a key
change attributes everything that follows to the account that just went
away, which is the same misattribution the key fingerprint exists to stop,
only arriving through the anonymous path instead.
`aliased` is cleared with it: the new id has never been merged, so it is
eligible to be aliased into whatever account comes next.
"""
created = f"code-anon-{uuid.uuid4().hex}"
identity["anonymous_id"] = created
identity.pop("aliased", None)
_write_identity(identity)
return created
def _install_state_path() -> Path:
return memory_core.data_dir() / "install-state.json"
def is_first_run() -> bool:
"""Whether this machine has never recorded a plugin event before."""
return not _identity_path().exists()
"""Whether install has never been recorded on this machine.
Deliberately NOT the identity file. That file is only written by a
successful flush, so an offline or firewalled user recorded code.install on
every single session, forever — and every 0.2.x user recorded one on their
first 0.3.x session because 0.2.x never wrote it at all.
"""
return not _install_state_path().exists()
def data_dir_was_empty() -> bool:
"""Whether the data directory is untouched. Call BEFORE anything writes to it.
hook_runner reaches claim_install() only after cache_plugin_api_key() has
written `api-key` and EvidenceStore() has created `evidence.sqlite3`, so
asking at claim time always saw content and every fresh install reported an
upgrade. The caller snapshots this at the top of the run instead.
"""
return not _data_dir_has_content()
def claim_install(was_empty: bool | None = None) -> str | None:
"""Claim the one install/upgrade record for this machine, atomically.
Returns the event to record ("install" or "upgrade"), or None if another
session already claimed it. O_CREAT|O_EXCL so two sessions starting together
cannot both win.
`was_empty` must come from data_dir_was_empty() called before this process
wrote anything. Omitting it falls back to checking now, which is only
correct for a caller that has touched nothing.
"""
if not is_enabled():
# Never consume the one-shot claim while the user is opted out, or they
# would silently lose their install event if they later opt in.
return None
path = _install_state_path()
upgrading = not (data_dir_was_empty() if was_empty is None else was_empty)
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
return None
except OSError:
return None
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump(
{
"plugin_version": memory_core.PLUGIN_VERSION,
"installed_at": memory_core.utc_now(),
"upgraded": upgrading,
},
stream,
)
# Durable before this returns. The O_EXCL open is what makes the
# claim exclusive, so it cannot be replaced by a temp-and-rename
# without losing that, which leaves the content as the thing to make
# safe. A kill between the open and this fsync used to leave a marker
# that exists but parses to nothing: is_first_run reads it as claimed
# and claim_version_change cannot read a version out of it.
stream.flush()
os.fsync(stream.fileno())
except OSError:
pass
return "upgrade" if upgrading else "install"
def _data_dir_has_content() -> bool:
"""Whether anything predates this session in the plugin data directory."""
try:
for entry in memory_core.data_dir().iterdir():
if entry.name != "install-state.json":
return True
except OSError:
pass
return False
def _repair_install_state(path: Path) -> None:
"""Rewrite an unparseable marker so version tracking can resume."""
try:
temporary = path.with_suffix(f".{os.getpid()}.tmp")
temporary.write_text(
json.dumps({"plugin_version": memory_core.PLUGIN_VERSION, "repaired_at": memory_core.utc_now()}),
encoding="utf-8",
)
temporary.replace(path)
except OSError:
pass
def claim_version_change() -> str | None:
"""Return the previously recorded version if it differs, updating the marker.
Only meaningful once the marker exists — the first transition into 0.3.x has
no recorded predecessor and reports "pre-0.3" instead. Claiming by rewriting
the marker means the next session sees no change and records nothing.
"""
path = _install_state_path()
try:
state = json.loads(path.read_text(encoding="utf-8"))
except OSError:
return None
except json.JSONDecodeError:
# A crash between O_EXCL and the write leaves an empty marker. Left
# alone it disables every future upgrade event on this machine, because
# claim_install sees the file and this function cannot parse it.
state = None
if not isinstance(state, dict):
_repair_install_state(path)
return None
previous = str(state.get("plugin_version") or "")
if not previous or previous == memory_core.PLUGIN_VERSION:
return None
# Claim the transition with an exclusive sentinel before rewriting the
# marker. A plain read-modify-write let every concurrently starting session
# observe the old version and each record its own upgrade — and the first
# session after a version bump is exactly when several agent windows restart
# together.
sentinel = path.with_name(f"upgraded-{memory_core.PLUGIN_VERSION}")
try:
os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
except FileExistsError:
return None
except OSError:
return None
state["plugin_version"] = memory_core.PLUGIN_VERSION
state["upgraded_at"] = memory_core.utc_now()
temporary = path.with_suffix(f".{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(state), encoding="utf-8")
temporary.replace(path)
except OSError:
# Release the claim. The marker still records the old version, so
# without this the sentinel makes claim_version_change return early on
# every later run and this version's upgrade is never recorded again.
for leftover in (sentinel, temporary):
try:
leftover.unlink()
except OSError:
pass
return None
return previous
def record(
@@ -168,19 +492,32 @@ def record(
except OSError:
pass
properties = _safe_value(properties)
# Stamped in the RECORDING process, beside harness. `source` used to be
# read in the sending process from a module global, so whichever process
# drained the spool named every event in it. flush() spreads per-event
# properties last, so this now wins over any sender's default.
properties.update(
harness=_harness,
source=_source_tag,
plugin_version=memory_core.PLUGIN_VERSION,
os=sys.platform,
python_version=platform.python_version(),
)
# Assigned only when the digest is real. _scoped_digest returns "" when
# the salt could not be persisted, and an empty property is worse than an
# absent one: it survives the None filter below and reads as a value.
if repo is not None:
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
if repo_hash:
properties["repo_hash"] = repo_hash
if session_id:
properties["session_hash"] = _digest(session_id)
session_hash = _scoped_digest(session_id)
if session_hash:
properties["session_hash"] = session_hash
line = json.dumps(
{
"event": f"{EVENT_PREFIX}.{event}",
"uuid": str(uuid.uuid4()),
"timestamp": memory_core.utc_now(),
"properties": {
key: value for key, value in properties.items() if value is not None
@@ -239,38 +576,201 @@ def spawn_flush() -> bool:
return False
def _claim_name(attempt: int = 0) -> str:
"""Claim filename. The attempt count rides in the name so the 7-day expiry
only ever discards a batch that was actually retried and failed."""
return f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}-a{attempt}.sending"
def _claim_attempt(claim: Path) -> int:
"""Attempts recorded in a claim filename; 0 for the pre-attempt-count shape.
Anchored on field position, not on a leading "a": the legacy shape is
``telemetry-<pid>-<hex>.sending`` and a hex id such as ``a1234567`` would
otherwise parse as attempt 1234567 and be discarded unsent on the first
flush after an upgrade.
"""
stem = claim.name[: -len(".sending")] if claim.name.endswith(".sending") else claim.name
parts = stem.split("-")
if len(parts) != 4:
return 0
tail = parts[3]
if tail.startswith("a") and tail[1:].isdigit():
return int(tail[1:])
return 0
def _touch(path: Path) -> None:
"""Refresh mtime so a claim's age measures time since it was claimed.
``Path.replace`` is ``os.rename``, which preserves mtime — so a claim created
after a quiet minute inherited the spool's last-write time and looked
abandoned the instant it was made. A second sender would then take it over
while the first was still posting, and both would deliver the batch.
"""
try:
os.utime(path, None)
except OSError:
pass
def _claim_spool() -> Path | None:
"""Rename the spool aside so exactly one sender owns each batch."""
directory = memory_core.data_dir()
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
claim = directory / _claim_name()
spool = _spool_path()
try:
spool.replace(claim)
_touch(claim)
return claim
except OSError:
pass
return _claim_parked(directory)
def _sweep_debris(directory: Path) -> None:
"""Remove files nothing else will ever pick up again.
*.partial is a temp file orphaned by a crash between write and rename.
*.corrupt is a batch quarantined for undecodable content. No glob in this
module matches either, so without this they accumulate on disk for the life
of the install.
Quarantined batches are kept far longer than debris: they are the only
evidence left of events that could not be delivered, and someone diagnosing
a report of missing telemetry has to be able to find one.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending")):
for debris in directory.glob("telemetry-*.partial"):
try:
if now - debris.stat().st_mtime > CLAIM_STALE_SECONDS:
debris.unlink()
except OSError:
continue
for quarantined in directory.glob("telemetry-*.corrupt"):
try:
if now - quarantined.stat().st_mtime > CLAIM_EXPIRY_SECONDS:
quarantined.unlink()
except OSError:
continue
# The same reasoning covers *.tmp. _write_identity and _install_salt both
# create one and unlink it in a finally, which a SIGKILL skips, and no glob
# in this module matches the leftovers either.
for temporary in directory.glob("telemetry-*.tmp"):
try:
if now - temporary.stat().st_mtime > CLAIM_STALE_SECONDS:
temporary.unlink()
except OSError:
continue
def _claim_parked(directory: Path) -> Path | None:
"""Take the oldest abandoned claim, if any lease has actually expired.
Kept separate from the live spool so flush() can drain both in one run.
Previously parked batches were only reachable when no spool existed at all,
and because sessions keep recording there usually was one — so a batch
parked by a failed send waited until the 7-day expiry deleted it unsent,
even though its own presence is what started the sender.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending"), key=_safe_mtime):
try:
age = now - orphan.stat().st_mtime
except OSError:
continue
if age > CLAIM_EXPIRY_SECONDS:
if age < CLAIM_STALE_SECONDS:
# Someone else holds a live lease on it. This check has to come
# first. Claiming a file bumps its attempt count and refreshes its
# mtime, so a sender that has just taken the final attempt looks
# exhausted to everyone else while it is actively draining. Judging
# exhaustion before liveness let a second sender unlink a batch out
# from under its owner, losing every event in it.
continue
# Attempts, not age. Every re-claim touches the mtime and every release
# backdates it by a fixed amount, so age is pinned near the stale
# threshold and never reaches the expiry. Age stays only as a backstop
# for files that never carried an attempt marker.
if _claim_attempt(orphan) >= MAX_CLAIM_ATTEMPTS or age > CLAIM_EXPIRY_SECONDS:
try:
orphan.unlink()
except OSError:
pass
continue
if age < CLAIM_STALE_SECONDS:
continue
claim = orphan.parent / _claim_name(_claim_attempt(orphan) + 1)
try:
orphan.replace(claim)
_touch(claim)
return claim
except OSError:
continue
return None
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _rewrite_claim(claim: Path, remaining: list[dict[str, Any]]) -> bool:
"""Persist the unsent remainder, atomically, and refresh the lease.
Called after every successful batch. Two jobs: a retry resumes where the
send stopped instead of re-posting from the top, and the rewrite doubles as
the lease heartbeat, so a slow sender does not have its claim stolen
mid-flight. Interval is one batch, well inside CLAIM_STALE_SECONDS.
"""
if not remaining:
try:
claim.unlink()
except OSError:
pass
return True
temporary = claim.with_suffix(f".{os.getpid()}.partial")
try:
payload = "".join(json.dumps(event, separators=(",", ":"), default=str) + "\n" for event in remaining)
# fsync before the rename: without it the rename can land while the
# bytes have not, and the claim comes back empty or truncated after a
# crash. _drain then reads zero events and unlinks it.
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(claim)
_touch(claim)
return True
except OSError:
try:
temporary.unlink()
except OSError:
pass
return False
def _release_claim(claim: Path, remaining: list[dict[str, Any]]) -> None:
"""Persist the remainder and drop the lease, because this sender has given up.
Distinct from the per-batch heartbeat: heartbeating on the way out would
make an abandoned batch look actively owned for a further
CLAIM_STALE_SECONDS, delaying the retry for no reason. Ageing it past the
threshold lets the next flush pick it up immediately, while the attempt
count in the filename still bounds how many times that can happen.
"""
if not _rewrite_claim(claim, remaining):
return
try:
# Backdate past the stale threshold so the next flush can pick it up,
# minus a cooldown that grows with the attempts already spent. Clamped so
# the mtime never lands in the future, which would read as a live lease.
cooldown = min(_claim_attempt(claim) * RETRY_COOLDOWN_SECONDS, CLAIM_STALE_SECONDS)
released = time.time() - CLAIM_STALE_SECONDS - 1 + cooldown
os.utime(claim, (released, released))
except OSError:
pass
def _resolve_email(key: str) -> str:
"""Trade the API key for the account email so events join other Mem0 surfaces."""
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
@@ -300,34 +800,130 @@ def _post(payload: dict[str, Any], url: str) -> bool:
def resolve_distinct_id() -> tuple[str, str]:
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
"""Return the PostHog distinct id and the anonymous id it replaced, if any.
The second value becomes a PostHog $identify alias. It is ONLY ever an
anonymous id: aliasing one account email to another merges two real person
profiles and cannot be undone, so a key that now belongs to a different
account re-resolves with no alias.
"""
identity = _read_identity()
email = identity.get("email", "")
if email:
return email, ""
key = memory_core.api_key()
fingerprint = _digest(key) if key else ""
email = identity.get("email", "")
if email and fingerprint:
recorded = identity.get("key_fingerprint", "")
if recorded == fingerprint:
return email, ""
if not recorded:
# Rows written before fingerprints existed. Verify rather than
# adopt: a key changed before the upgrade would otherwise bind the
# new key to the previous account's email, permanently, and the
# fingerprint would then agree with itself forever after.
verified = _resolve_email(key)
if not verified:
# Offline, firewalled, or the API is down. Keep the previous
# behaviour and retry on the next flush rather than dropping a
# real account attribution. Safe because the same network that
# failed /v1/ping/ is about to fail the PostHog POST, so nothing
# is delivered under the unverified identity in the meantime.
return email, ""
identity["email"] = verified
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return verified, ""
if not key:
# No key to verify the account with; do not keep attributing to it.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
email = _resolve_email(key)
if not email:
resolved = _resolve_email(key)
if not resolved:
# The key changed and will not resolve (revoked, offline, API down).
# Reaching here with an email means the recorded fingerprint disagreed,
# so the key really did change. Drop the account and rotate: the stored
# anonymous id may already be merged into that account's person, and
# reusing it would keep the events on the profile we are trying to
# leave.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
previous = identity.get("anonymous_id", "")
identity["email"] = email
# Alias only when going anonymous -> email for the first time. Once an anon
# id has been merged into an account it must never be offered again: an
# alias naming an already-identified id is what could link two real people.
previous = "" if (email or identity.get("aliased")) else identity.get("anonymous_id", "")
if previous:
identity["aliased"] = True
identity["email"] = resolved
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return email, previous
return resolved, previous
def flush() -> int:
"""Drain claimed spools to PostHog and return the number of events sent."""
"""Drain the live spool, then any parked claims, and return events sent."""
if not is_enabled():
return 0
claim = _claim_spool()
sent, delivered = _drain(_claim_spool())
if not delivered:
# The network is failing. Retrying other batches now would only burn
# their attempt budget against the same broken connection.
return sent
# Parked batches used to starve behind the live spool indefinitely. Bounded
# per run so a long backlog cannot turn one flush into an unbounded loop.
directory = memory_core.data_dir()
_sweep_debris(directory)
for _ in range(MAX_PARKED_PER_RUN):
parked = _claim_parked(directory)
if parked is None:
break
count, delivered = _drain(parked)
sent += count
if not delivered:
break
return sent
def _drain(claim: Path | None) -> tuple[int, bool]:
"""Post one claimed batch file, recording progress after every batch.
Returns (events sent, whether everything was delivered).
"""
if claim is None:
return 0
return 0, True
try:
lines = claim.read_text(encoding="utf-8").splitlines()
except ValueError:
# UnicodeDecodeError from a torn write: the content is unrecoverable, so
# quarantine rather than retry. flush() runs from a bare `finally:` in
# flush_worker, so raising here also skips the handoff cleanup, and an
# undecodable file would otherwise be re-read on every flush forever.
# Reported as delivered because there is nothing left to deliver and the
# rest of the run should continue.
try:
claim.replace(claim.with_suffix(".corrupt"))
except OSError:
try:
claim.unlink()
except OSError:
pass
return 0, True
except OSError:
return 0
# Could not read it, which is not the same as having nothing to send.
# The file is left exactly where it is: a vanished or briefly unreadable
# claim is retryable, and quarantining it here would discard events over
# a transient filesystem error. Reported as undelivered so the run stops
# instead of counting a batch nothing was posted from as delivered.
return 0, False
events = []
for line in lines:
try:
@@ -337,11 +933,18 @@ def flush() -> int:
if isinstance(value, dict) and value.get("event"):
events.append(value)
if not events:
# Only delete when the file really is empty. A non-empty file that
# parses to nothing is a torn write, and its contents are the unsent
# remainder — deleting it is the data loss this PR exists to prevent.
try:
claim.unlink()
empty = claim.stat().st_size == 0
except OSError:
empty = True
try:
claim.replace(claim.with_suffix(".corrupt")) if not empty else claim.unlink()
except OSError:
pass
return 0
return 0, True
distinct_id, aliased_anonymous_id = resolve_distinct_id()
if aliased_anonymous_id:
@@ -360,12 +963,17 @@ def flush() -> int:
sent = 0
for start in range(0, len(events), BATCH_SIZE):
chunk = events[start : start + BATCH_SIZE]
batch = [
{
"event": event["event"],
"distinct_id": distinct_id,
# Carried through from record() so a resend can be collapsed.
"uuid": event.get("uuid"),
"timestamp": event.get("timestamp"),
"properties": {
# Fallback only: events recorded by a build before source
# moved into record() have none of their own.
"source": _source_tag,
"language": "python",
"$process_person_profile": False,
@@ -373,16 +981,24 @@ def flush() -> int:
**(event.get("properties") or {}),
},
}
for event in events[start : start + BATCH_SIZE]
for event in chunk
]
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
return sent
sent += len(batch)
try:
claim.unlink()
except OSError:
pass
return sent
# Keep only what has not been delivered, and release the lease.
# Previously the whole file was kept and the retry re-posted every
# batch, including the ones that had already arrived.
_release_claim(claim, events[start:])
return sent, False
sent += len(chunk)
# Record progress and refresh the lease after each successful batch, so
# a crash repeats at most one batch instead of the entire file. If the
# rewrite fails the claim still holds delivered events, so stop rather
# than carry on as though progress were recorded — continuing is how the
# duplicate delivery this PR fixes would come back.
if not _rewrite_claim(claim, events[start + len(chunk) :]):
_release_claim(claim, events[start + len(chunk) :])
return sent, False
return sent, True
def main() -> int:
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.1",
"version": "0.3.3",
"homepage": "https://docs.mem0.ai/integrations/claude-code",
"native": {
"pluginRoot": "${CLAUDE_PLUGIN_ROOT}",
@@ -7,8 +7,8 @@ disable-model-invocation: true
# Pause memory capture
To pause (hooks stop capturing and sending session content; a minimal
anonymous telemetry ping still fires at session start unless
`MEM0_TELEMETRY=false`):
telemetry ping still fires at session start, under your Mem0 account email,
unless `MEM0_TELEMETRY=false`):
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" pause
@@ -12,11 +12,9 @@ Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
query.
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
category; a category is a best-effort label Mem0 assigned when it saved the
memory, so if a category search misses, repeat it without the category. Omit
`scope` to use the configured default, normally `repo`: this repository's
shared memory, which everyone who works in it contributes to, plus your own
preferences.
category. Omit `scope` to use the configured default, normally `repo`: this
repository's shared memory, which everyone who works in it contributes to,
plus your own preferences.
Pass `scope` when the question needs something else: `dir` to narrow the
shared memory to the directory you are working in (a package inside a
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import re
import sqlite3
import subprocess
import sys
@@ -2306,8 +2307,8 @@ def test_sidekick_instructions_reject_unrequested_related_changes():
prompt = (PLUGIN_ROOT / "agents" / "sidekick.md").read_text()
normalized = " ".join(prompt.split())
assert "Skill" in prompt.split("---", 2)[1]
assert "ALWAYS call `search_memories` before answering anything" in normalized
assert "Do not rely on the chat window" in normalized
assert "call `search_memories` with a" in normalized
assert "focused question before searching the repository again" in normalized
assert "Complete only the work the main agent assigned" in normalized
assert "Do not make related improvements" in normalized
assert "report them separately" in normalized
@@ -3460,7 +3461,12 @@ def test_automatic_flush_can_be_disabled_for_external_harnesses(isolated_env):
def test_version_is_single_sourced():
manifest = json.loads((PLUGIN_ROOT / ".claude-plugin" / "plugin.json").read_text())
assert manifest["name"] == "mem0"
assert manifest["version"] == memory_core.PLUGIN_VERSION == "0.3.1"
# Compared against PLUGIN_VERSION, never a literal. A hardcoded version here
# was one more place to edit on every release, inside the test asserting the
# version is single-sourced, and it caught nothing that the agreement checks
# below do not: fifteen places set to the same wrong value would still pass.
assert re.fullmatch(r"\d+\.\d+\.\d+", memory_core.PLUGIN_VERSION), memory_core.PLUGIN_VERSION
assert manifest["version"] == memory_core.PLUGIN_VERSION
root = REPOSITORY_ROOT
for mp in (root / "marketplace.json", root / ".claude-plugin" / "marketplace.json"):
entry = next(p for p in json.loads(mp.read_text())["plugins"] if p["name"] == "mem0")
@@ -4327,7 +4333,7 @@ def test_flush_sends_unified_body_with_both_agent_and_user_id(isolated_env, monk
assert sent_body["run_id"] == "s1"
assert "lane" not in sent_body["metadata"]
assert "Save concise repository facts" in sent_body["agent_custom_instructions"]
assert "invocation that succeeded" in sent_body["agent_custom_instructions"]
assert "Write about the repository, not the user" in sent_body["agent_custom_instructions"]
assert "Do not save repository facts" in sent_body["custom_instructions"]
assert sent_body["custom_categories"] == memory_core.CODING_MEMORY_CATEGORIES
store.close()
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from unittest.mock import patch
@@ -198,9 +199,11 @@ def test_a_stale_claim_is_reclaimed(isolated_env, monkeypatch):
telemetry.record("search")
orphan = telemetry._claim_spool()
assert orphan is not None
monkeypatch.setattr(
telemetry.time, "time", lambda: orphan.stat().st_mtime + telemetry.CLAIM_STALE_SECONDS + 1
)
# Frozen rather than re-stat'd per call: flush() drains the live spool and
# then looks for parked claims in the same run, so by the second look this
# file no longer exists.
stale_now = orphan.stat().st_mtime + telemetry.CLAIM_STALE_SECONDS + 1
monkeypatch.setattr(telemetry.time, "time", lambda: stale_now)
with patch.object(telemetry, "_post", lambda payload, url: True):
assert telemetry.flush() == 1
@@ -210,9 +213,13 @@ def test_an_expired_claim_is_dropped(isolated_env, monkeypatch):
telemetry.record("search")
orphan = telemetry._claim_spool()
assert orphan is not None
monkeypatch.setattr(
telemetry.time, "time", lambda: orphan.stat().st_mtime + telemetry.CLAIM_EXPIRY_SECONDS + 1
)
expired_now = orphan.stat().st_mtime + telemetry.CLAIM_EXPIRY_SECONDS + 1
monkeypatch.setattr(telemetry.time, "time", lambda: expired_now)
# Expiry now only discards a batch that was genuinely retried and failed,
# so age alone is not enough — age it past the attempt budget too.
retried = orphan.parent / orphan.name.replace("-a0.", f"-a{telemetry.MAX_CLAIM_ATTEMPTS}.")
orphan.replace(retried)
os.utime(retried, (expired_now, expired_now - telemetry.CLAIM_EXPIRY_SECONDS - 1))
assert telemetry._claim_spool() is None
assert not list(memory_core.data_dir().glob("telemetry-*.sending"))
@@ -258,12 +265,150 @@ def test_an_unresolvable_key_falls_back_to_the_anonymous_id(isolated_env, monkey
assert telemetry.resolve_distinct_id()[0].startswith("code-anon-")
def test_is_first_run_flips_after_the_first_identity_write(isolated_env):
def test_logging_out_does_not_leave_events_on_the_previous_account(isolated_env, monkeypatch):
"""Review finding: clearing the email kept an id already merged into a person.
The anonymous id is offered to PostHog as $anon_distinct_id on first sign-in,
and that merge is permanent. Keeping it after the key goes away means every
later anonymous event lands on the account that just left.
"""
# Run anonymously first, which is the only way an id exists to be merged.
merged = telemetry.anonymous_id()
monkeypatch.setenv("MEM0_API_KEY", "key-for-account-a")
with patch.object(telemetry, "_resolve_email", lambda key: "a@example.com"):
identified, alias = telemetry.resolve_distinct_id()
assert identified == "a@example.com"
assert alias == merged, "the anonymous id was merged into this account"
monkeypatch.delenv("MEM0_API_KEY", raising=False)
after_logout, logout_alias = telemetry.resolve_distinct_id()
assert after_logout.startswith("code-anon-")
assert after_logout != merged, "reused an id already merged into the previous account"
assert logout_alias == ""
assert "aliased" not in telemetry._read_identity(), "rotated id must be aliasable again"
def test_a_changed_key_that_will_not_resolve_rotates_the_anonymous_id(isolated_env, monkeypatch):
"""Same leak by the other route: fingerprint disagrees and the lookup fails."""
merged = telemetry.anonymous_id()
monkeypatch.setenv("MEM0_API_KEY", "key-for-account-a")
with patch.object(telemetry, "_resolve_email", lambda key: "a@example.com"):
telemetry.resolve_distinct_id()
monkeypatch.setenv("MEM0_API_KEY", "key-for-account-b")
with patch.object(telemetry, "_resolve_email", lambda key: ""):
after, alias = telemetry.resolve_distinct_id()
assert after.startswith("code-anon-")
assert after != merged
assert alias == ""
assert "email" not in telemetry._read_identity()
def test_a_legacy_cached_email_is_verified_before_the_key_is_bound(isolated_env, monkeypatch):
"""Review finding: a key changed before upgrading bound the wrong account.
Rows written before fingerprints existed carry an email and no fingerprint.
Adopting the current key without checking pinned that key to the previous
account's email, and every run after that agreed with itself.
"""
telemetry._write_identity({"email": "old@example.com", "anonymous_id": "code-anon-seed"})
monkeypatch.setenv("MEM0_API_KEY", "key-for-account-b")
with patch.object(telemetry, "_resolve_email", lambda key: "new@example.com"):
resolved, alias = telemetry.resolve_distinct_id()
assert resolved == "new@example.com"
assert alias == "", "email to email must never alias; it merges two real people"
stored = telemetry._read_identity()
assert stored["email"] == "new@example.com"
assert stored["key_fingerprint"] == telemetry._digest("key-for-account-b")
def test_a_legacy_row_keeps_working_when_the_account_cannot_be_checked(isolated_env, monkeypatch):
"""Firewalled users must not lose attribution, and must not bind unverified.
The same network that fails /v1/ping/ fails the PostHog POST, so nothing is
delivered under the unverified identity while this holds.
"""
telemetry._write_identity({"email": "old@example.com"})
monkeypatch.setenv("MEM0_API_KEY", "key-for-account-b")
with patch.object(telemetry, "_resolve_email", lambda key: ""):
resolved, _ = telemetry.resolve_distinct_id()
assert resolved == "old@example.com"
assert "key_fingerprint" not in telemetry._read_identity(), "bound an unverified key"
def test_a_failed_upgrade_claim_can_be_retried(isolated_env, monkeypatch):
"""Review finding: a failed rewrite left the sentinel and suppressed forever.
claim_version_change returns early on FileExistsError, and the marker still
holds the old version, so the upgrade for that version was never recorded
again on that machine.
"""
telemetry.claim_install()
state_path = memory_core.data_dir() / "install-state.json"
state = json.loads(state_path.read_text())
state["plugin_version"] = "0.0.1-old"
state_path.write_text(json.dumps(state), encoding="utf-8")
real_replace = Path.replace
def failing_replace(self, target):
raise OSError("disk full")
monkeypatch.setattr(Path, "replace", failing_replace)
assert telemetry.claim_version_change() is None
monkeypatch.setattr(Path, "replace", real_replace)
assert telemetry.claim_version_change() == "0.0.1-old", "sentinel suppressed the retry"
def test_first_run_is_not_flipped_by_writing_the_identity_file(isolated_env):
"""The identity file is written by a successful flush, not by recording.
Keying first-run off it meant an offline user recorded code.install on every
session forever, and every 0.2.x user recorded one on their first 0.3.x run.
"""
assert telemetry.is_first_run()
telemetry.anonymous_id()
assert telemetry.is_first_run()
def test_claiming_install_ends_first_run(isolated_env):
assert telemetry.claim_install() == "install"
assert not telemetry.is_first_run()
def test_install_can_only_be_claimed_once(isolated_env):
"""Two sessions starting together must not both record an install."""
assert telemetry.claim_install() == "install"
assert telemetry.claim_install() is None
def test_a_populated_data_dir_reads_as_an_upgrade(isolated_env):
"""A fresh install has an empty data directory; anything else predates it."""
data_dir = memory_core.data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "requirements.txt").write_text("mem0ai\n", encoding="utf-8")
assert telemetry.claim_install() == "upgrade"
def test_a_version_change_is_claimed_once(isolated_env):
telemetry.claim_install()
state_path = memory_core.data_dir() / "install-state.json"
state = json.loads(state_path.read_text())
state["plugin_version"] = "0.0.1-old"
state_path.write_text(json.dumps(state), encoding="utf-8")
assert telemetry.claim_version_change() == "0.0.1-old"
assert telemetry.claim_version_change() is None
def test_spawn_flush_does_nothing_without_a_spool(isolated_env):
with patch.object(telemetry.subprocess, "Popen") as popen:
assert telemetry.spawn_flush() is False
@@ -273,3 +418,114 @@ def test_spawn_flush_does_nothing_without_a_spool(isolated_env):
with patch.object(telemetry.subprocess, "Popen") as popen:
assert telemetry.spawn_flush() is True
popen.assert_called_once()
def test_salt_is_stable_across_processes(isolated_env):
"""Hooks are separate short-lived processes; one repo must hash one way.
An unlocked read-modify-write let each process mint its own salt, so a
repository hashed several ways in the window before one writer won.
"""
import subprocess as sp
core = str(Path(__file__).resolve().parents[1] / "core")
script = (
f"import sys; sys.path.insert(0, {core!r})\n"
"import telemetry\n"
"print(telemetry._install_salt())"
)
env = {**os.environ, "MEM0_CODE_DATA_DIR": str(memory_core.data_dir())}
salts = {
sp.run([sys.executable, "-c", script], capture_output=True, text=True, env=env).stdout.strip()
for _ in range(4)
}
assert len(salts) == 1, f"one repo hashed {len(salts)} ways: {salts}"
def test_salt_does_not_touch_the_identity_file(isolated_env):
"""The identity file is is_first_run's marker and the sender's email store.
Writing the salt into it would create it from record(), suppressing the
install event, and would race resolve_distinct_id, which holds a stale copy
of that dict across a network call.
"""
telemetry._install_salt()
assert not telemetry._identity_path().exists()
def test_no_salt_means_no_hash_rather_than_an_unsalted_one(isolated_env, monkeypatch):
"""A read-only data dir drops the property; it must not emit a weak digest.
The previous fallback was a digest of the salt file's own path, which an
attacker can compute, memoized for the whole process. A property named
repo_hash carrying an effectively unsalted digest is worse than no property:
it reads as protected and is not.
"""
telemetry._salt_cache = ""
monkeypatch.setattr(telemetry.os, "open", lambda *a, **k: (_ for _ in ()).throw(OSError("read-only")))
assert telemetry._install_salt() == ""
assert telemetry._scoped_digest("git@github.com:acme/secret.git") == ""
def test_a_half_written_salt_is_never_visible_to_another_process(isolated_env, monkeypatch):
"""The window this closes: file created, value not yet written.
O_CREAT|O_EXCL then write leaves the name present and empty in between. A
hook reading it there used to get "", fall back to the path digest and cache
that for its whole run, so the same repo hashed two ways depending on timing.
Publishing by link means the name either does not exist or is complete.
"""
telemetry._salt_cache = ""
salt_path = telemetry._salt_path()
observed = []
real_link = telemetry.os.link
def observing_link(source, target):
# Stand where the racing reader stands: after the temp file is written,
# before the real name exists.
observed.append(salt_path.exists())
return real_link(source, target)
monkeypatch.setattr(telemetry.os, "link", observing_link)
salt = telemetry._install_salt()
assert observed == [False], "the salt name existed before it held a value"
assert len(salt) == 32
assert salt_path.read_text(encoding="utf-8").strip() == salt
def test_a_filesystem_without_hardlinks_still_gets_a_salt(isolated_env, monkeypatch):
"""Publishing by link must not become a silent loss of the hashes.
Some network mounts and container volumes reject os.link. Returning ""
there would drop repo_hash and session_hash on every run for that whole
cohort, which is a bigger loss than the narrow race the link closes.
"""
telemetry._salt_cache = ""
monkeypatch.setattr(
telemetry.os, "link", lambda src, dst: (_ for _ in ()).throw(OSError(38, "not implemented"))
)
salt = telemetry._install_salt()
assert len(salt) == 32, "no salt on a filesystem without hardlinks"
assert telemetry._salt_path().read_text(encoding="utf-8").strip() == salt
assert telemetry._scoped_digest("git@github.com:acme/x.git") != ""
assert not list(telemetry._salt_path().parent.glob("telemetry-salt.*.tmp"))
def test_a_concurrent_writer_does_not_clobber_the_published_salt(isolated_env):
"""Second process to finish must adopt the first one's salt, not replace it.
os.link rather than os.replace is what makes losing the race harmless.
"""
telemetry._salt_cache = ""
first = telemetry._install_salt()
telemetry._salt_cache = ""
second = telemetry._install_salt()
assert second == first
assert not list(telemetry._salt_path().parent.glob("telemetry-salt.*.tmp")), "temp file left behind"
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.1",
"version": "0.3.3",
"description": "Cross-session memory and token savings for coding agents.",
"author": { "name": "Mem0", "email": "support@mem0.ai" },
"homepage": "https://docs.mem0.ai/integrations/codex",
@@ -0,0 +1,11 @@
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
HARNESS_ID = "codex"
SOURCE_TAG = "CODEX_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "codex"
+17 -1
View File
@@ -290,6 +290,11 @@ def run(
if args.plugin_data_dir:
os.environ[data_dir_env] = args.plugin_data_dir
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
# after them always saw content and every fresh install reported an upgrade.
data_dir_was_empty = telemetry.data_dir_was_empty()
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
@@ -305,8 +310,19 @@ def run(
return 0
if args.action == "session-start":
if telemetry.is_first_run():
# Claims the marker atomically and says which event to record, so a
# second session starting alongside this one cannot record it too.
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
if first_event == "install":
telemetry.record("install")
elif first_event == "upgrade":
# First run after a build that never wrote the marker; the
# predecessor version was never recorded anywhere.
telemetry.record("upgrade", from_version="pre-0.3")
else:
previous = telemetry.claim_version_change()
if previous:
telemetry.record("upgrade", from_version=previous)
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
+3 -10
View File
@@ -21,16 +21,9 @@ from memory_core import (
PROTOCOL_VERSION = "2024-11-05"
TOOL_NAME = "search_memories"
TOOL_DESCRIPTION = (
"Search memories from earlier work in this repository. ALWAYS call this "
"tool before answering anything that could depend on prior context: the "
"user's preferences, facts about this codebase, history, people, projects, "
"or earlier decisions. Do not rely on the chat window alone. The "
"repository's memory is shared by everyone who works in it and includes "
"what it took to run, test, or build here, so search before assuming an "
"invocation works. The scope argument changes what is searched: 'repo' "
"(default) is the whole repository's shared memory plus your own "
"preferences, 'dir' narrows the shared part to the directory you are "
"working in, and 'mine' is your preferences alone."
"Search memories from earlier work in this repository. Use it before "
"repeating investigation or when earlier decisions, fixes, commands, or "
"results may help."
)
TOOL_SCHEMA = {
"type": "object",
+42 -9
View File
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.1"
PLUGIN_VERSION = "0.3.3"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
@@ -71,15 +71,13 @@ MAX_FLUSH_ATTEMPTS = 5
FORGET_PAGE_SIZE = 100
FORGET_MAX_PAGES = 50
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help anyone with future coding work in this repository.
PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help with future coding work.
A completed change should produce one memory explaining the resulting behavior, where it is implemented when useful, and any important constraints or reasoning. Exploration or accepted decisions may produce separate memories only when they are independently useful.
A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own.
Use the coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Use the current coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state.
Write about the repository, not the user, assistant, session, or task. Do not include test results, documentation updates, release notes, or temporary state.
If nothing useful was established, return no memories."""
@@ -1800,6 +1798,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1833,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1860,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2006,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2556,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
+655 -39
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""Anonymous usage telemetry for Mem0 agent plugins.
"""Usage telemetry for Mem0 agent plugins.
Events are linked to your Mem0 account email when an API key is configured, and
to a random per-machine id otherwise. Not anonymous — the Python SDK and CLI
attribute the same way.
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
touches the network: `record` appends one JSON line to a local spool and returns.
@@ -9,7 +13,8 @@ started once per session and again from the flush worker that is already detache
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
Never sends prompts, memory text, queries, file paths, repository names, or API
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
keys: only event names, durations, counts, coarse outcomes, and repo/session
identifiers hashed with a random per-install salt.
"""
from __future__ import annotations
@@ -29,8 +34,24 @@ from typing import Any
import memory_core
_harness: str = "generic"
_source_tag: str = "MEM0_PLUGIN"
# Seeded from the per-host module the build generates into core/. Two processes
# in this pipeline never call init() — mcp_server.py, and the detached
# `python3 telemetry.py` sender that spawn_flush() starts — so a module default
# was what every one of their events got labelled with.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import HARNESS_ID as _DEFAULT_HARNESS
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG
except ImportError:
_DEFAULT_HARNESS = "generic"
_DEFAULT_SOURCE_TAG = "MEM0_PLUGIN"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
_salt_cache: str = ""
_harness: str = _DEFAULT_HARNESS
_source_tag: str = _DEFAULT_SOURCE_TAG
_PRIVATE_KEYS = {
"apikey",
"authorization",
@@ -56,10 +77,19 @@ _PRIVATE_KEYS = {
}
def init(harness: str = "generic", source_tag: str = "") -> None:
def init(harness: str = "", source_tag: str = "") -> None:
"""Override the generated identity. Optional — core/_harness_id.py is the default.
The fallback shape matches memory_core.configure_harness's (``<HOST>_PLUGIN``).
It used to be ``MEM0_<HOST>_PLUGIN`` here and ``<host>_plugin`` there, which
meant one plugin could emit three different source values depending on which
process happened to send the batch.
"""
global _harness, _source_tag
_harness = harness
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
_harness = harness or _DEFAULT_HARNESS
_source_tag = source_tag or (
f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG
)
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
@@ -70,6 +100,16 @@ BATCH_SIZE = 100
SEND_TIMEOUT = 5
CLAIM_STALE_SECONDS = 120
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
# A batch is only discarded once it has genuinely been retried this many times.
MAX_CLAIM_ATTEMPTS = 3
# Parked claims drained per run, after the live spool. Bounded so a long backlog
# cannot turn one flush into an unbounded send loop.
MAX_PARKED_PER_RUN = 3
# Added to the wait before a released claim becomes reclaimable, per attempt
# already spent. Releasing straight to "reclaimable now" let two senders burn the
# whole budget within seconds of one another on a single momentary failure, and
# discard a batch a retry a minute later would have delivered.
RETRY_COOLDOWN_SECONDS = 60
def is_enabled() -> bool:
@@ -83,9 +123,126 @@ def is_enabled() -> bool:
def _digest(value: str, length: int = 16) -> str:
"""Unsalted digest. Only for values that are already secrets (API keys)."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
def _salt_path() -> Path:
return memory_core.data_dir() / "telemetry-salt"
def _install_salt() -> str:
"""Random per-install salt, created once and memoized for the process.
Deliberately its own file, claimed with O_CREAT|O_EXCL, rather than a key in
the identity file. Three reasons, all of which produced wrong data when this
lived in the identity dict:
- Hooks are short-lived separate processes firing on every tool call, and
people run more than one agent window. A read-modify-write would let each
process mint its own salt, so one repository would hash several ways in the
window before a writer won.
- resolve_distinct_id holds a copy of the identity dict across a network call
to /v1/ping/, so whichever write landed second erased the other's key —
losing either the salt (repo_hash changes mid-stream) or the email (a
second $identify, splitting the person).
- Touching the identity file from record() would create it, and is_first_run
keys off that file, so recording an event would silently suppress the
install event.
Published atomically, and there is deliberately no derived fallback. Creating
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
the file exists and is empty, and a concurrent hook that reads it in that
window gets nothing. Falling back to a digest of the path would hand that
process a salt an attacker can compute, memoized for its whole run, which is
the privacy control this function exists to provide silently turning itself
off under load. The salt is written to a private temp file first and linked
into place, so the name either does not exist or already has the full value.
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
rather than emit an unsalted one.
"""
global _salt_cache
if _salt_cache:
return _salt_cache
path = _salt_path()
# Read before writing. Hooks are separate processes firing on every tool
# call, so all but the first find the salt already published; going straight
# to create-fsync-link-unlink meant every one of them paid an fsync to
# discover that, on a path whose whole promise is appending a line and
# returning.
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
if _salt_cache:
return _salt_cache
except OSError:
pass
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(uuid.uuid4().hex)
stream.flush()
os.fsync(stream.fileno())
try:
# Atomic claim: fails if another process already published one.
# os.link rather than replace, which would clobber theirs.
os.link(temporary, path)
except FileExistsError:
pass
except OSError:
# No hardlinks here (some network mounts, some container volumes).
# Claim the name directly instead. That reopens the empty-file
# window, but the window is now benign: a reader that lands in it
# gets "" and omits the hash for that process rather than caching a
# guessable one. Losing the hashes on every run of an entire
# filesystem is the worse failure.
try:
fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fallback, "w", encoding="utf-8") as stream:
stream.write(temporary.read_text(encoding="utf-8"))
except OSError:
pass
except OSError:
pass
finally:
try:
temporary.unlink()
except OSError:
pass
try:
_salt_cache = path.read_text(encoding="utf-8").strip()
except OSError:
_salt_cache = ""
return _salt_cache
def _scoped_digest(value: str, length: int = 16) -> str:
"""Salted digest for values drawn from a guessable space.
repo.identity is a git remote URL, or ``local:<absolute path>`` when there is
no remote — which normally contains the account username. Sixteen unsalted
hex characters over that input space is enumerable, so this is not a
privacy control without the salt. Salting per install keeps every
within-account join the analytics actually use and gives up only
cross-machine joins on the same repository, which nothing computes.
Returns "" when there is no salt, so record() omits the property. An
unsalted digest over this input space is close to plaintext, and emitting one
under a name that implies it is hashed is worse than sending nothing.
"""
if not value:
return ""
salt = _install_salt()
if not salt:
return ""
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
def _safe_value(value: Any) -> Any:
if isinstance(value, str):
return memory_core.redact(value)
@@ -145,9 +302,176 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str:
return created
def _rotate_anonymous_id(identity: dict[str, str]) -> str:
"""Mint a fresh anonymous id because the account context is gone.
The previous id may already have been merged into a person profile by an
$identify, and that merge is permanent. Reusing it after a logout or a key
change attributes everything that follows to the account that just went
away, which is the same misattribution the key fingerprint exists to stop,
only arriving through the anonymous path instead.
`aliased` is cleared with it: the new id has never been merged, so it is
eligible to be aliased into whatever account comes next.
"""
created = f"code-anon-{uuid.uuid4().hex}"
identity["anonymous_id"] = created
identity.pop("aliased", None)
_write_identity(identity)
return created
def _install_state_path() -> Path:
return memory_core.data_dir() / "install-state.json"
def is_first_run() -> bool:
"""Whether this machine has never recorded a plugin event before."""
return not _identity_path().exists()
"""Whether install has never been recorded on this machine.
Deliberately NOT the identity file. That file is only written by a
successful flush, so an offline or firewalled user recorded code.install on
every single session, forever — and every 0.2.x user recorded one on their
first 0.3.x session because 0.2.x never wrote it at all.
"""
return not _install_state_path().exists()
def data_dir_was_empty() -> bool:
"""Whether the data directory is untouched. Call BEFORE anything writes to it.
hook_runner reaches claim_install() only after cache_plugin_api_key() has
written `api-key` and EvidenceStore() has created `evidence.sqlite3`, so
asking at claim time always saw content and every fresh install reported an
upgrade. The caller snapshots this at the top of the run instead.
"""
return not _data_dir_has_content()
def claim_install(was_empty: bool | None = None) -> str | None:
"""Claim the one install/upgrade record for this machine, atomically.
Returns the event to record ("install" or "upgrade"), or None if another
session already claimed it. O_CREAT|O_EXCL so two sessions starting together
cannot both win.
`was_empty` must come from data_dir_was_empty() called before this process
wrote anything. Omitting it falls back to checking now, which is only
correct for a caller that has touched nothing.
"""
if not is_enabled():
# Never consume the one-shot claim while the user is opted out, or they
# would silently lose their install event if they later opt in.
return None
path = _install_state_path()
upgrading = not (data_dir_was_empty() if was_empty is None else was_empty)
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
return None
except OSError:
return None
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump(
{
"plugin_version": memory_core.PLUGIN_VERSION,
"installed_at": memory_core.utc_now(),
"upgraded": upgrading,
},
stream,
)
# Durable before this returns. The O_EXCL open is what makes the
# claim exclusive, so it cannot be replaced by a temp-and-rename
# without losing that, which leaves the content as the thing to make
# safe. A kill between the open and this fsync used to leave a marker
# that exists but parses to nothing: is_first_run reads it as claimed
# and claim_version_change cannot read a version out of it.
stream.flush()
os.fsync(stream.fileno())
except OSError:
pass
return "upgrade" if upgrading else "install"
def _data_dir_has_content() -> bool:
"""Whether anything predates this session in the plugin data directory."""
try:
for entry in memory_core.data_dir().iterdir():
if entry.name != "install-state.json":
return True
except OSError:
pass
return False
def _repair_install_state(path: Path) -> None:
"""Rewrite an unparseable marker so version tracking can resume."""
try:
temporary = path.with_suffix(f".{os.getpid()}.tmp")
temporary.write_text(
json.dumps({"plugin_version": memory_core.PLUGIN_VERSION, "repaired_at": memory_core.utc_now()}),
encoding="utf-8",
)
temporary.replace(path)
except OSError:
pass
def claim_version_change() -> str | None:
"""Return the previously recorded version if it differs, updating the marker.
Only meaningful once the marker exists — the first transition into 0.3.x has
no recorded predecessor and reports "pre-0.3" instead. Claiming by rewriting
the marker means the next session sees no change and records nothing.
"""
path = _install_state_path()
try:
state = json.loads(path.read_text(encoding="utf-8"))
except OSError:
return None
except json.JSONDecodeError:
# A crash between O_EXCL and the write leaves an empty marker. Left
# alone it disables every future upgrade event on this machine, because
# claim_install sees the file and this function cannot parse it.
state = None
if not isinstance(state, dict):
_repair_install_state(path)
return None
previous = str(state.get("plugin_version") or "")
if not previous or previous == memory_core.PLUGIN_VERSION:
return None
# Claim the transition with an exclusive sentinel before rewriting the
# marker. A plain read-modify-write let every concurrently starting session
# observe the old version and each record its own upgrade — and the first
# session after a version bump is exactly when several agent windows restart
# together.
sentinel = path.with_name(f"upgraded-{memory_core.PLUGIN_VERSION}")
try:
os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
except FileExistsError:
return None
except OSError:
return None
state["plugin_version"] = memory_core.PLUGIN_VERSION
state["upgraded_at"] = memory_core.utc_now()
temporary = path.with_suffix(f".{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(state), encoding="utf-8")
temporary.replace(path)
except OSError:
# Release the claim. The marker still records the old version, so
# without this the sentinel makes claim_version_change return early on
# every later run and this version's upgrade is never recorded again.
for leftover in (sentinel, temporary):
try:
leftover.unlink()
except OSError:
pass
return None
return previous
def record(
@@ -168,19 +492,32 @@ def record(
except OSError:
pass
properties = _safe_value(properties)
# Stamped in the RECORDING process, beside harness. `source` used to be
# read in the sending process from a module global, so whichever process
# drained the spool named every event in it. flush() spreads per-event
# properties last, so this now wins over any sender's default.
properties.update(
harness=_harness,
source=_source_tag,
plugin_version=memory_core.PLUGIN_VERSION,
os=sys.platform,
python_version=platform.python_version(),
)
# Assigned only when the digest is real. _scoped_digest returns "" when
# the salt could not be persisted, and an empty property is worse than an
# absent one: it survives the None filter below and reads as a value.
if repo is not None:
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
if repo_hash:
properties["repo_hash"] = repo_hash
if session_id:
properties["session_hash"] = _digest(session_id)
session_hash = _scoped_digest(session_id)
if session_hash:
properties["session_hash"] = session_hash
line = json.dumps(
{
"event": f"{EVENT_PREFIX}.{event}",
"uuid": str(uuid.uuid4()),
"timestamp": memory_core.utc_now(),
"properties": {
key: value for key, value in properties.items() if value is not None
@@ -239,38 +576,201 @@ def spawn_flush() -> bool:
return False
def _claim_name(attempt: int = 0) -> str:
"""Claim filename. The attempt count rides in the name so the 7-day expiry
only ever discards a batch that was actually retried and failed."""
return f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}-a{attempt}.sending"
def _claim_attempt(claim: Path) -> int:
"""Attempts recorded in a claim filename; 0 for the pre-attempt-count shape.
Anchored on field position, not on a leading "a": the legacy shape is
``telemetry-<pid>-<hex>.sending`` and a hex id such as ``a1234567`` would
otherwise parse as attempt 1234567 and be discarded unsent on the first
flush after an upgrade.
"""
stem = claim.name[: -len(".sending")] if claim.name.endswith(".sending") else claim.name
parts = stem.split("-")
if len(parts) != 4:
return 0
tail = parts[3]
if tail.startswith("a") and tail[1:].isdigit():
return int(tail[1:])
return 0
def _touch(path: Path) -> None:
"""Refresh mtime so a claim's age measures time since it was claimed.
``Path.replace`` is ``os.rename``, which preserves mtime — so a claim created
after a quiet minute inherited the spool's last-write time and looked
abandoned the instant it was made. A second sender would then take it over
while the first was still posting, and both would deliver the batch.
"""
try:
os.utime(path, None)
except OSError:
pass
def _claim_spool() -> Path | None:
"""Rename the spool aside so exactly one sender owns each batch."""
directory = memory_core.data_dir()
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
claim = directory / _claim_name()
spool = _spool_path()
try:
spool.replace(claim)
_touch(claim)
return claim
except OSError:
pass
return _claim_parked(directory)
def _sweep_debris(directory: Path) -> None:
"""Remove files nothing else will ever pick up again.
*.partial is a temp file orphaned by a crash between write and rename.
*.corrupt is a batch quarantined for undecodable content. No glob in this
module matches either, so without this they accumulate on disk for the life
of the install.
Quarantined batches are kept far longer than debris: they are the only
evidence left of events that could not be delivered, and someone diagnosing
a report of missing telemetry has to be able to find one.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending")):
for debris in directory.glob("telemetry-*.partial"):
try:
if now - debris.stat().st_mtime > CLAIM_STALE_SECONDS:
debris.unlink()
except OSError:
continue
for quarantined in directory.glob("telemetry-*.corrupt"):
try:
if now - quarantined.stat().st_mtime > CLAIM_EXPIRY_SECONDS:
quarantined.unlink()
except OSError:
continue
# The same reasoning covers *.tmp. _write_identity and _install_salt both
# create one and unlink it in a finally, which a SIGKILL skips, and no glob
# in this module matches the leftovers either.
for temporary in directory.glob("telemetry-*.tmp"):
try:
if now - temporary.stat().st_mtime > CLAIM_STALE_SECONDS:
temporary.unlink()
except OSError:
continue
def _claim_parked(directory: Path) -> Path | None:
"""Take the oldest abandoned claim, if any lease has actually expired.
Kept separate from the live spool so flush() can drain both in one run.
Previously parked batches were only reachable when no spool existed at all,
and because sessions keep recording there usually was one — so a batch
parked by a failed send waited until the 7-day expiry deleted it unsent,
even though its own presence is what started the sender.
"""
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending"), key=_safe_mtime):
try:
age = now - orphan.stat().st_mtime
except OSError:
continue
if age > CLAIM_EXPIRY_SECONDS:
if age < CLAIM_STALE_SECONDS:
# Someone else holds a live lease on it. This check has to come
# first. Claiming a file bumps its attempt count and refreshes its
# mtime, so a sender that has just taken the final attempt looks
# exhausted to everyone else while it is actively draining. Judging
# exhaustion before liveness let a second sender unlink a batch out
# from under its owner, losing every event in it.
continue
# Attempts, not age. Every re-claim touches the mtime and every release
# backdates it by a fixed amount, so age is pinned near the stale
# threshold and never reaches the expiry. Age stays only as a backstop
# for files that never carried an attempt marker.
if _claim_attempt(orphan) >= MAX_CLAIM_ATTEMPTS or age > CLAIM_EXPIRY_SECONDS:
try:
orphan.unlink()
except OSError:
pass
continue
if age < CLAIM_STALE_SECONDS:
continue
claim = orphan.parent / _claim_name(_claim_attempt(orphan) + 1)
try:
orphan.replace(claim)
_touch(claim)
return claim
except OSError:
continue
return None
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _rewrite_claim(claim: Path, remaining: list[dict[str, Any]]) -> bool:
"""Persist the unsent remainder, atomically, and refresh the lease.
Called after every successful batch. Two jobs: a retry resumes where the
send stopped instead of re-posting from the top, and the rewrite doubles as
the lease heartbeat, so a slow sender does not have its claim stolen
mid-flight. Interval is one batch, well inside CLAIM_STALE_SECONDS.
"""
if not remaining:
try:
claim.unlink()
except OSError:
pass
return True
temporary = claim.with_suffix(f".{os.getpid()}.partial")
try:
payload = "".join(json.dumps(event, separators=(",", ":"), default=str) + "\n" for event in remaining)
# fsync before the rename: without it the rename can land while the
# bytes have not, and the claim comes back empty or truncated after a
# crash. _drain then reads zero events and unlinks it.
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(claim)
_touch(claim)
return True
except OSError:
try:
temporary.unlink()
except OSError:
pass
return False
def _release_claim(claim: Path, remaining: list[dict[str, Any]]) -> None:
"""Persist the remainder and drop the lease, because this sender has given up.
Distinct from the per-batch heartbeat: heartbeating on the way out would
make an abandoned batch look actively owned for a further
CLAIM_STALE_SECONDS, delaying the retry for no reason. Ageing it past the
threshold lets the next flush pick it up immediately, while the attempt
count in the filename still bounds how many times that can happen.
"""
if not _rewrite_claim(claim, remaining):
return
try:
# Backdate past the stale threshold so the next flush can pick it up,
# minus a cooldown that grows with the attempts already spent. Clamped so
# the mtime never lands in the future, which would read as a live lease.
cooldown = min(_claim_attempt(claim) * RETRY_COOLDOWN_SECONDS, CLAIM_STALE_SECONDS)
released = time.time() - CLAIM_STALE_SECONDS - 1 + cooldown
os.utime(claim, (released, released))
except OSError:
pass
def _resolve_email(key: str) -> str:
"""Trade the API key for the account email so events join other Mem0 surfaces."""
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
@@ -300,34 +800,130 @@ def _post(payload: dict[str, Any], url: str) -> bool:
def resolve_distinct_id() -> tuple[str, str]:
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
"""Return the PostHog distinct id and the anonymous id it replaced, if any.
The second value becomes a PostHog $identify alias. It is ONLY ever an
anonymous id: aliasing one account email to another merges two real person
profiles and cannot be undone, so a key that now belongs to a different
account re-resolves with no alias.
"""
identity = _read_identity()
email = identity.get("email", "")
if email:
return email, ""
key = memory_core.api_key()
fingerprint = _digest(key) if key else ""
email = identity.get("email", "")
if email and fingerprint:
recorded = identity.get("key_fingerprint", "")
if recorded == fingerprint:
return email, ""
if not recorded:
# Rows written before fingerprints existed. Verify rather than
# adopt: a key changed before the upgrade would otherwise bind the
# new key to the previous account's email, permanently, and the
# fingerprint would then agree with itself forever after.
verified = _resolve_email(key)
if not verified:
# Offline, firewalled, or the API is down. Keep the previous
# behaviour and retry on the next flush rather than dropping a
# real account attribution. Safe because the same network that
# failed /v1/ping/ is about to fail the PostHog POST, so nothing
# is delivered under the unverified identity in the meantime.
return email, ""
identity["email"] = verified
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return verified, ""
if not key:
# No key to verify the account with; do not keep attributing to it.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
email = _resolve_email(key)
if not email:
resolved = _resolve_email(key)
if not resolved:
# The key changed and will not resolve (revoked, offline, API down).
# Reaching here with an email means the recorded fingerprint disagreed,
# so the key really did change. Drop the account and rotate: the stored
# anonymous id may already be merged into that account's person, and
# reusing it would keep the events on the profile we are trying to
# leave.
if email:
identity.pop("email", None)
identity.pop("key_fingerprint", None)
return _rotate_anonymous_id(identity), ""
return anonymous_id(identity), ""
previous = identity.get("anonymous_id", "")
identity["email"] = email
# Alias only when going anonymous -> email for the first time. Once an anon
# id has been merged into an account it must never be offered again: an
# alias naming an already-identified id is what could link two real people.
previous = "" if (email or identity.get("aliased")) else identity.get("anonymous_id", "")
if previous:
identity["aliased"] = True
identity["email"] = resolved
identity["key_fingerprint"] = fingerprint
_write_identity(identity)
return email, previous
return resolved, previous
def flush() -> int:
"""Drain claimed spools to PostHog and return the number of events sent."""
"""Drain the live spool, then any parked claims, and return events sent."""
if not is_enabled():
return 0
claim = _claim_spool()
sent, delivered = _drain(_claim_spool())
if not delivered:
# The network is failing. Retrying other batches now would only burn
# their attempt budget against the same broken connection.
return sent
# Parked batches used to starve behind the live spool indefinitely. Bounded
# per run so a long backlog cannot turn one flush into an unbounded loop.
directory = memory_core.data_dir()
_sweep_debris(directory)
for _ in range(MAX_PARKED_PER_RUN):
parked = _claim_parked(directory)
if parked is None:
break
count, delivered = _drain(parked)
sent += count
if not delivered:
break
return sent
def _drain(claim: Path | None) -> tuple[int, bool]:
"""Post one claimed batch file, recording progress after every batch.
Returns (events sent, whether everything was delivered).
"""
if claim is None:
return 0
return 0, True
try:
lines = claim.read_text(encoding="utf-8").splitlines()
except ValueError:
# UnicodeDecodeError from a torn write: the content is unrecoverable, so
# quarantine rather than retry. flush() runs from a bare `finally:` in
# flush_worker, so raising here also skips the handoff cleanup, and an
# undecodable file would otherwise be re-read on every flush forever.
# Reported as delivered because there is nothing left to deliver and the
# rest of the run should continue.
try:
claim.replace(claim.with_suffix(".corrupt"))
except OSError:
try:
claim.unlink()
except OSError:
pass
return 0, True
except OSError:
return 0
# Could not read it, which is not the same as having nothing to send.
# The file is left exactly where it is: a vanished or briefly unreadable
# claim is retryable, and quarantining it here would discard events over
# a transient filesystem error. Reported as undelivered so the run stops
# instead of counting a batch nothing was posted from as delivered.
return 0, False
events = []
for line in lines:
try:
@@ -337,11 +933,18 @@ def flush() -> int:
if isinstance(value, dict) and value.get("event"):
events.append(value)
if not events:
# Only delete when the file really is empty. A non-empty file that
# parses to nothing is a torn write, and its contents are the unsent
# remainder — deleting it is the data loss this PR exists to prevent.
try:
claim.unlink()
empty = claim.stat().st_size == 0
except OSError:
empty = True
try:
claim.replace(claim.with_suffix(".corrupt")) if not empty else claim.unlink()
except OSError:
pass
return 0
return 0, True
distinct_id, aliased_anonymous_id = resolve_distinct_id()
if aliased_anonymous_id:
@@ -360,12 +963,17 @@ def flush() -> int:
sent = 0
for start in range(0, len(events), BATCH_SIZE):
chunk = events[start : start + BATCH_SIZE]
batch = [
{
"event": event["event"],
"distinct_id": distinct_id,
# Carried through from record() so a resend can be collapsed.
"uuid": event.get("uuid"),
"timestamp": event.get("timestamp"),
"properties": {
# Fallback only: events recorded by a build before source
# moved into record() have none of their own.
"source": _source_tag,
"language": "python",
"$process_person_profile": False,
@@ -373,16 +981,24 @@ def flush() -> int:
**(event.get("properties") or {}),
},
}
for event in events[start : start + BATCH_SIZE]
for event in chunk
]
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
return sent
sent += len(batch)
try:
claim.unlink()
except OSError:
pass
return sent
# Keep only what has not been delivered, and release the lease.
# Previously the whole file was kept and the retry re-posted every
# batch, including the ones that had already arrived.
_release_claim(claim, events[start:])
return sent, False
sent += len(chunk)
# Record progress and refresh the lease after each successful batch, so
# a crash repeats at most one batch instead of the entire file. If the
# rewrite fails the claim still holds delivered events, so stop rather
# than carry on as though progress were recorded — continuing is how the
# duplicate delivery this PR fixes would come back.
if not _rewrite_claim(claim, events[start + len(chunk) :]):
_release_claim(claim, events[start + len(chunk) :])
return sent, False
return sent, True
def main() -> int:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.1",
"version": "0.3.3",
"homepage": "https://docs.mem0.ai/integrations/codex",
"native": {
"pluginRoot": "${PLUGIN_ROOT}",
@@ -7,8 +7,8 @@ disable-model-invocation: true
# Pause memory capture
To pause (hooks stop capturing and sending session content; a minimal
anonymous telemetry ping still fires at session start unless
`MEM0_TELEMETRY=false`):
telemetry ping still fires at session start, under your Mem0 account email,
unless `MEM0_TELEMETRY=false`):
```bash
python3 "${PLUGIN_ROOT}/core/memory_cli.py" --harness "codex" --plugin-data-dir "${PLUGIN_DATA}" pause

Some files were not shown because too many files have changed in this diff Show More