Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a5497469d | |||
| 12617b6d8d | |||
| cbfb37c623 | |||
| 71dc0cae07 | |||
| 8d6c001966 | |||
| 989c7da0fc | |||
| d675cf68ad | |||
| 2c6ff619d1 | |||
| f4acc89a29 | |||
| 43849c6e9d | |||
| 47a69e1e72 | |||
| ea9bbcabed | |||
| 0cddc36d52 | |||
| 83b07b1537 | |||
| 8c02c425a5 | |||
| f8082a7345 | |||
| 5d38e3703a | |||
| fd8fd087ea | |||
| a214ec37bc | |||
| 8b38da9ab8 | |||
| 17852dc648 | |||
| a39a802bbc | |||
| 19f7134082 | |||
| a8d3634312 | |||
| 1a5c7ad28c | |||
| 4e38b057fd | |||
| 3362999095 | |||
| 012cd32c3a | |||
| e4e0307ae6 |
@@ -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.4"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.4"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ jobs:
|
||||
- name: Type check
|
||||
run: bun run type-check
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"displayName": "Mem0",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"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,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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||
|
||||
__version__ = "0.2.12"
|
||||
__version__ = "0.2.13"
|
||||
|
||||
@@ -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/
|
||||
---
|
||||
+276
-37
@@ -7,6 +7,24 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<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 +192,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 +982,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 +1245,21 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-09-23" description="v3.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 +1897,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:**
|
||||
@@ -2060,6 +2100,16 @@ A full-featured command-line interface for Mem0, available in both Python and No
|
||||
<Tabs>
|
||||
<Tab title="Mem0 Plugin">
|
||||
|
||||
<Update label="2026-09-25" description="Portable agent plugin v0.3.4">
|
||||
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when no key is set in the plugin settings or `MEM0_API_KEY`, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Auth:** A plugin setting that reaches the plugin as an unexpanded `${api_key}` or `${user_id}` placeholder is treated as unset. It is no longer sent as the API key, cached to disk, or used as the user ID ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **State:** The `search_memories` MCP server now keeps its state in `~/.mem0/coding-agent-plugin`, the same directory the status, pause, resume and forget skills use, instead of `~/.mem0/mem0-plugin` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-08" description="Shared agent plugin runtime">
|
||||
|
||||
**Changed:**
|
||||
@@ -2076,7 +2126,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 +2421,34 @@ 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-25" description="Claude Code plugin v0.3.4">
|
||||
|
||||
Sidekick is now available only in Claude Code, with Sonnet, worktree isolation, and parent memories.
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when no key is set in the plugin settings or `MEM0_API_KEY`, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Auth:** A plugin setting that reaches the plugin as an unexpanded `${api_key}` or `${user_id}` placeholder is treated as unset. It is no longer sent as the API key, cached to disk, or used as the user ID ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="Claude Code plugin v0.3.3">
|
||||
|
||||
**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 +2471,34 @@ 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-25" description="Cursor plugin v0.3.4">
|
||||
|
||||
Removes Sidekick and its start/stop hooks. Memory capture, search, and six skills remain available.
|
||||
**Fixes:**
|
||||
- **Auth:** An API key set in the Cursor plugin settings now reaches the `search_memories` MCP server. The server looked for the cached key in `~/.mem0/mem0-plugin` while the hooks cached it in `~/.mem0/cursor-plugin`, so search asked for auth whenever Cursor did not pass the setting to the MCP server directly ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when no key is set in the plugin settings or `MEM0_API_KEY`, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Auth:** A plugin setting that reaches the plugin as an unexpanded `${api_key}` or `${user_id}` placeholder is treated as unset. It is no longer sent as the API key, cached to disk, or used as the user ID ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="Cursor plugin v0.3.3">
|
||||
|
||||
**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 +2521,32 @@ 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-25" description="Codex plugin v0.3.4">
|
||||
|
||||
Renames shared tracking to use subagent terminology. Native subagent memory support remains available.
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when `MEM0_API_KEY` is not set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="Codex plugin v0.3.3">
|
||||
|
||||
**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 +2566,32 @@ 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-25" description="OpenCode plugin v0.4.2">
|
||||
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when neither `MEM0_API_KEY` nor a shell profile sets one, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<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 +2690,33 @@ 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-25" description="Antigravity plugin v0.3.4">
|
||||
|
||||
Removes Sidekick. Memory capture, search, and six skills remain available.
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when `MEM0_API_KEY` is not set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Data directory:** The `search_memories` MCP server uses `~/.mem0/antigravity-plugin`, the data directory the hooks use, instead of `~/.mem0/mem0-plugin` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="Antigravity plugin v0.3.3">
|
||||
|
||||
**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 +2812,33 @@ 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-25" description="Kimi Code plugin v0.3.4">
|
||||
|
||||
Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skills remain available.
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when `MEM0_API_KEY` is not set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Data directory:** The `search_memories` MCP server uses `~/.mem0/kimi-plugin`, the data directory the hooks use, instead of `~/.mem0/mem0-plugin` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="Kimi Code plugin v0.3.3">
|
||||
|
||||
**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 +2876,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 +3179,29 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
|
||||
|
||||
<Tab title="Pi Agent">
|
||||
|
||||
<Update label="2026-09-25" description="Pi Agent plugin v0.3.3">
|
||||
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when neither `MEM0_API_KEY` nor `~/.pi/agent/mem0-config.json` sets one, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<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 +3315,29 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
|
||||
|
||||
<Tab title="DeepSeek Harness">
|
||||
|
||||
<Update label="2026-09-25" description="deepseek-plugin v0.3.3">
|
||||
|
||||
**Fixes:**
|
||||
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when neither `config.apiKey` nor `MEM0_API_KEY` is set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
- **Config:** An empty `apiKey` now counts as unset and falls through to `MEM0_API_KEY` and the `mem0 init` key, instead of failing validation ([#7449](https://github.com/mem0ai/mem0/pull/7449))
|
||||
|
||||
</Update>
|
||||
|
||||
<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 +3382,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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
+19
-3
@@ -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",
|
||||
@@ -444,7 +444,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 +513,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 +1032,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",
|
||||
|
||||
@@ -28,6 +28,10 @@ echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc && source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The Mem0 plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` takes precedence when set.
|
||||
</Tip>
|
||||
|
||||
## Installation
|
||||
|
||||
**Option A: degit** (recommended):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -166,12 +166,42 @@ claude plugin update mem0@mem0-plugins --scope user
|
||||
|
||||
| Problem | Fix |
|
||||
| --- | --- |
|
||||
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. |
|
||||
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set, or run `mem0 init` with the [Mem0 CLI](/platform/cli). The plugin falls back to the key it saves. |
|
||||
| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status` to confirm. |
|
||||
| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. |
|
||||
| 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
|
||||
|
||||
@@ -35,6 +35,10 @@ source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The plugin (Option A) also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` takes precedence when set.
|
||||
</Tip>
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A: Plugin Marketplace (Recommended)
|
||||
|
||||
@@ -35,6 +35,10 @@ source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The full plugin (Option A) also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. That includes Cursor opened from the Dock, which does not load your shell profile. A key from the plugin configuration or the environment takes precedence.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Already have `mem0` configured as an MCP server in Cursor? Remove the existing entry from your Cursor MCP settings before installing to avoid duplicate tools.
|
||||
</Warning>
|
||||
@@ -164,6 +168,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
|
||||
## Troubleshooting
|
||||
|
||||
- **"Connection failed"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`
|
||||
- **Still asked for an API key**: Run `mem0 init` with the [Mem0 CLI](/platform/cli). The full plugin falls back to the key it saves.
|
||||
- **Duplicate tools**: Do not combine the full plugin with an MCP-only option. Remove the standalone `mem0` MCP entry before installing the plugin.
|
||||
- **No tools appearing**: Go to Cursor Settings > MCP and verify the `mem0` server shows as connected
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `config.apiKey` and `MEM0_API_KEY` take precedence.
|
||||
</Tip>
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Build and pack the plugin:
|
||||
@@ -103,7 +107,7 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|
||||
| Field | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key. Falls back to the key `mem0 init` saved. |
|
||||
| `userId` | yes | | Default entity that owns the memories |
|
||||
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
@@ -114,7 +118,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.
|
||||
|
||||
+111
-130
@@ -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:
|
||||
|
||||
@@ -11,38 +11,21 @@ You can run Mem0 in three ways:
|
||||
- **Self-hosted server mode**: point the plugin at a Mem0 server you run yourself (the Docker-shipped server). The plugin only talks HTTP to your server.
|
||||
- **OSS mode**: run Mem0 in-process with your own LLM, embedder, and vector store. No Mem0 server required.
|
||||
|
||||
## Requirements and compatibility
|
||||
|
||||
Use Python 3.11+ and a recent Hermes release. The host contract was checked against
|
||||
Hermes `v0.21.3` (`v2026.9.14`) and main at
|
||||
`c62bd9f2078a946108f1c9d9b24bf118963277ef`. Earlier host versions have not been validated.
|
||||
|
||||
<Warning>
|
||||
Both checked Hermes revisions still bundle Mem0. Hermes gives bundled memory providers
|
||||
precedence over a same-named user plugin. Installing or enabling this plugin does not
|
||||
replace that bundled copy. Use the isolated preview instructions in the
|
||||
[plugin README](https://github.com/mem0ai/mem0/tree/main/integrations/hermes-plugin#local-worktree-preview)
|
||||
to try this version in a separate Hermes checkout. Once Hermes removes its bundled Mem0
|
||||
provider, the user-installed copy loads directly.
|
||||
</Warning>
|
||||
|
||||
Keep `memory.provider: mem0`, `$HERMES_HOME/mem0.json`, and your existing `MEM0_*` variables.
|
||||
There is no memory migration. An explicitly configured user ID continues to share memories
|
||||
across hosts; otherwise the provider uses the gateway user ID, then `hermes-user`.
|
||||
|
||||
## How It Works
|
||||
|
||||
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at two points in every conversation turn.
|
||||
|
||||
### 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 and the Hermes session as top-level `run_id`. Recall remains scoped to the user across sessions.
|
||||
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.
|
||||
|
||||
The plugin uses the shared `agent-plugin-core` redaction and token-aware batching. Full non-empty user and assistant text is preserved after known-secret redaction; oversized messages are split across requests instead of truncated. Raw tool results and the full historical transcript are not captured. Pattern-based redaction cannot recognize every possible secret.
|
||||
<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
|
||||
|
||||
@@ -50,41 +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_add` | Store a fact after known-secret redaction, with no LLM extraction | `content` (required) |
|
||||
| `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
|
||||
```
|
||||
|
||||
After this integration is published to the Mem0 repository:
|
||||
|
||||
```bash
|
||||
hermes plugins install mem0ai/mem0/integrations/hermes-plugin
|
||||
hermes plugins install mem0ai/mem0/integrations/hermes-plugin-mem0
|
||||
hermes plugins enable mem0
|
||||
hermes memory setup
|
||||
hermes memory status
|
||||
```
|
||||
|
||||
For unpublished worktree changes, use the README's local installation instructions instead.
|
||||
The bundled-provider precedence described above applies to both installation methods.
|
||||
Select **mem0** in setup and choose one of the modes below. Start a fresh Hermes conversation after setup.
|
||||
|
||||
Recent Hermes development versions install the plugin's declared dependencies automatically.
|
||||
On Hermes `v0.21.3`, install them into the environment that runs Hermes:
|
||||
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.
|
||||
|
||||
```bash
|
||||
HERMES_REPO=/path/to/hermes-agent
|
||||
uv pip install --python "$HERMES_REPO/.venv/bin/python" 'mem0ai>=2.0.10,<3' 'httpx>=0.27,<1'
|
||||
```
|
||||
<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>
|
||||
|
||||
OSS providers may need extra packages such as `qdrant-client`, `psycopg2-binary`, or `ollama`, which the setup flow installs when you select them.
|
||||
<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
|
||||
|
||||
@@ -93,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>
|
||||
|
||||
@@ -104,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
|
||||
|
||||
@@ -123,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
|
||||
|
||||
@@ -174,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"}},
|
||||
@@ -228,35 +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` | Gateway user ID, then `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) |
|
||||
| `sync_max_chars` | Uncapped for platform/HTTP; `450` for OSS | Positive maximum per chunk; longer text is split without dropping its tail. `0` disables the character cap |
|
||||
| `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.
|
||||
- **Capture queue**: overlapping turns are queued in memory instead of skipped while an earlier turn is syncing. Failed requests are logged without durable retries. Shutdown waits at most five seconds; a process exit can lose pending turns.
|
||||
- **Existing collections**: an OSS embedding-dimension mismatch fails initialization without deleting vectors. Restore the original embedding configuration or select a new collection name.
|
||||
- **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
|
||||
|
||||
@@ -289,17 +273,14 @@ curl http://localhost:11434/api/tags
|
||||
|
||||
### Memories not appearing
|
||||
|
||||
- `mem0_add` stores text after known-secret redaction with no extraction. Ordinary conversation turns are extracted automatically by the background sync.
|
||||
- `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">
|
||||
|
||||
@@ -99,7 +99,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
|
||||
|
||||
| Problem | Fix |
|
||||
| --- | --- |
|
||||
| Missing API key | Start Kimi from a shell where `MEM0_API_KEY` is exported. |
|
||||
| Missing API key | Start Kimi from a shell where `MEM0_API_KEY` is exported, or run `mem0 init` with the [Mem0 CLI](/platform/cli). The plugin falls back to the key it saves. |
|
||||
| Plugin changes do not appear | Run `/plugins reload`, then `/reload` or `/new`. |
|
||||
| MCP server is disabled | Run `/plugins mcp enable mem0 mem0`, then `/reload`. |
|
||||
| No memory in a later session | Wait a moment for the background flush, then ask Kimi to search memory explicitly. |
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc && source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The OpenCode plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` and your shell profile take precedence.
|
||||
</Tip>
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A: Plugin Install (Recommended)
|
||||
|
||||
@@ -40,6 +40,10 @@ source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The extension also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` and `apiKey` in `mem0-config.json` take precedence.
|
||||
</Tip>
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -68,7 +72,7 @@ For advanced settings, create `~/.pi/agent/mem0-config.json`:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. |
|
||||
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. Falls back to the key `mem0 init` saved. |
|
||||
| `userId` | `string` | `$MEM0_USER_ID` or `"default"` | User identity for memory scoping |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts from conversations automatically |
|
||||
| `defaultScope` | `string` | `"project"` | Default memory scope: `project`, `session`, or `global` |
|
||||
@@ -147,7 +151,7 @@ You: What do you know about my preferences?
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
|
||||
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites) or run `mem0 init`
|
||||
- **Extension not loading**: Check Pi startup output for errors. For a source checkout, run `pnpm build`, then `pi -e ./dist/entry.js` from the plugin directory
|
||||
- **Memories not capturing**: Verify `autoCapture` is `true` (default). Check `/mem0-status` for connection health
|
||||
- **Wrong project detected**: The plugin uses the git repository root as `app_id`. If not in a git repo, it falls back to the working directory name. Run `/mem0-status` to see the detected project
|
||||
|
||||
+8
-2
@@ -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 Agent](https://docs.mem0.ai/integrations/hermes) [Both]: Use when adding the native Mem0 memory provider to Hermes Agent, preserving existing configuration, or checking bundled-provider precedence.
|
||||
- [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.
|
||||
@@ -326,6 +326,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 +364,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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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
-2
@@ -6,8 +6,8 @@ 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 |
|
||||
| `hermes-plugin/` | Native Hermes memory provider; generated shared redaction and batching | Python build script | ruff | pytest with `--confcutdir=integrations/hermes-plugin/tests` |
|
||||
| `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 |
|
||||
| `opencode-plugin/` | `@mem0/opencode-plugin` (Bun/TypeScript) | tsup (via Bun) | tsc | bun test |
|
||||
@@ -44,13 +44,54 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
|
||||
- **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`.
|
||||
- **`agent-plugin-core/`** owns the shared Python memory runtime, TypeScript lifecycle utilities, skill templates, builds, and conformance runner. Claude Code is the behavioral source of truth. Native manifests and adapters live in sibling plugin directories; do not hand-edit their generated `core/` or `skills/` trees. Build and validation details are in [`agent-plugin-core/README.md`](agent-plugin-core/README.md).
|
||||
- **`hermes-plugin/`** preserves the upstream Hermes memory-provider API, setup, and cloud/HTTP/OSS backends. Only `core/message_utils.py` is generated; no MCP server or generic skills are installed. Its offline tests use a separate pytest invocation with `--confcutdir=integrations/hermes-plugin/tests` to avoid importing the host entry point during collection.
|
||||
- **`opencode-plugin/`** is a Bun/TypeScript plugin for OpenCode (`@mem0/opencode-plugin` on npm). It registers Mem0 memory tools as an OpenCode plugin with its own skills and telemetry.
|
||||
- **`openclaw/`**, **`pi-agent-plugin/`**, **`deepseek-plugin/`** are editor and agent plugins with the same shape. `deepseek-plugin/` registers Mem0 search/add tools as a native DeepSeek Harness (Cordis) plugin.
|
||||
- **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete.
|
||||
- **`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/`.
|
||||
@@ -61,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`.
|
||||
|
||||
@@ -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
|
||||
@@ -18,11 +18,10 @@ integrations/
|
||||
├── cursor-plugin/ # Native Cursor package and adapter
|
||||
├── codex-plugin/ # Native Codex package and adapter
|
||||
├── kimi-plugin/ # Native Kimi package and adapter
|
||||
├── antigravity-plugin/ # Native Antigravity package and adapter
|
||||
└── hermes-plugin/ # Native Hermes provider; shared message helpers only
|
||||
└── antigravity-plugin/ # Native Antigravity package and adapter
|
||||
```
|
||||
|
||||
Each native directory owns its manifest, native hooks or adapter, tests, and `plugin-build.json`. Hermes also retains its upstream setup wizard and backend adapters. Its `core/` and `skills/` directories are generated from this module. They are committed because clients install a self-contained plugin directory and the Agent Plugins specification forbids package files from resolving outside the plugin root.
|
||||
Each native directory owns only its manifest, native hooks or adapter, tests, and `plugin-build.json`. Its `core/` and `skills/` directories are generated from this module. They are committed because clients install a self-contained plugin directory and the Agent Plugins specification forbids package files from resolving outside the plugin root.
|
||||
|
||||
Sidekick belongs only to Claude Code. Its agent definition is in `claude-code-plugin/agents/sidekick.md`; its hooks are in `claude-code-plugin/adapters/claude/hook.py`. Other plugins must not register Sidekick. The shared core handles memory and native subagent tracking for Claude Code and Codex.
|
||||
|
||||
@@ -30,7 +29,7 @@ TypeScript integrations (`openclaw`, `opencode-plugin`, `pi-agent-plugin`, and `
|
||||
|
||||
## Shared memory behavior
|
||||
|
||||
The six MCP-based Python packages use the same `search_memories` MCP tool and six skill templates. Native hooks collect conversations and flush them to Mem0 in the background. The portable package uses the Agent Plugins v1 layout so compatible hosts can load its MCP server and skills. It has no lifecycle hooks or flush worker; its bundled `remember` skill assumes automatic capture and cannot save a memory on its own.
|
||||
The six Python packages use the same `search_memories` MCP tool and six skill templates. Native hooks collect conversations and flush them to Mem0 in the background. The portable package uses the Agent Plugins v1 layout so compatible hosts can load its MCP server and skills. It has no lifecycle hooks or flush worker; its bundled `remember` skill assumes automatic capture and cannot save a memory on its own.
|
||||
|
||||
Python search accepts `query`, `top_k`, `category`, `scope`, and optional `run_id`:
|
||||
|
||||
@@ -46,12 +45,10 @@ 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).
|
||||
|
||||
Hermes bundles only `python/message_utils.py`, sharing the same secret redaction and lossless token batching. It retains Hermes user-wide recall, four native tools, three backend modes, and legacy configuration. Completed turns are queued in memory, with session `run_id` on writes; network failures are logged and are not durably retried. Explicit `sync_max_chars` values split text instead of truncating it. It does not install the generic MCP skills, repository scopes, or Sidekick.
|
||||
|
||||
## Build and verify
|
||||
|
||||
From the repository root:
|
||||
@@ -61,7 +58,7 @@ python3.11 -m venv /tmp/mem0-agent-plugins
|
||||
/tmp/mem0-agent-plugins/bin/pip install \
|
||||
-r integrations/agent-plugin-core/requirements-dev.txt
|
||||
|
||||
for host in claude-code cursor codex kimi antigravity hermes; do
|
||||
for host in claude-code cursor codex kimi antigravity; do
|
||||
/tmp/mem0-agent-plugins/bin/python \
|
||||
integrations/agent-plugin-core/build/build.py "$host" \
|
||||
--kind native --check
|
||||
@@ -102,7 +99,7 @@ Do not put a real key in source files, command history shared with others, or pu
|
||||
For another native Python host:
|
||||
|
||||
1. Add `integrations/<host>-plugin/` with its native manifest and the smallest adapter that translates host events.
|
||||
2. Add `plugin-build.json` declaring the plugin-root variable and runtime files. Native providers can select `native.pythonFiles` and set `native.skills: false` when they expose host-native tools instead of MCP.
|
||||
2. Add `plugin-build.json` declaring the plugin-root variable and runtime files.
|
||||
3. Add one adapter contract test.
|
||||
4. Register the host in `build/build.py` and `conformance/run.py`.
|
||||
5. Run `--sync`, `--check`, and the conformance command above.
|
||||
@@ -120,7 +117,6 @@ For a TypeScript host, import the shared lifecycle modules directly and keep onl
|
||||
| Codex | Native prompt and final-response fields | Structured failure indicators when present; otherwise unknown | Parent context; native agent ID |
|
||||
| Kimi | Prompt hooks and completed v2 wire output | Native success/failure hooks | No plugin subagent hooks or agent declaration |
|
||||
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | No plugin subagent hooks or agent declaration |
|
||||
| Hermes | Completed turns via `sync_turn`, split without truncation | No separate tool-evidence capture | No plugin subagent hooks or agent declaration |
|
||||
| Portable v1 | Explicit memory skills | No native lifecycle hooks | No native subagent declaration |
|
||||
|
||||
Python status uses `subagent_runs` and `last_subagent`. Legacy SQLite names and event handling keep existing records and running workers compatible.
|
||||
|
||||
@@ -25,7 +25,6 @@ NATIVE_PLUGINS = {
|
||||
"codex": INTEGRATIONS_ROOT / "codex-plugin",
|
||||
"kimi": INTEGRATIONS_ROOT / "kimi-plugin",
|
||||
"antigravity": INTEGRATIONS_ROOT / "antigravity-plugin",
|
||||
"hermes": INTEGRATIONS_ROOT / "hermes-plugin",
|
||||
}
|
||||
PROTECTED_OUTPUTS = {
|
||||
REPOSITORY_ROOT,
|
||||
@@ -82,6 +81,39 @@ 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'
|
||||
f'DATA_DIR_NAME = "{host}-plugin"\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,
|
||||
@@ -89,26 +121,19 @@ def _bundle_python(
|
||||
*,
|
||||
plugin_data: str = "",
|
||||
portable: bool = False,
|
||||
python_files: list[str] | None = None,
|
||||
skills: bool = True,
|
||||
) -> None:
|
||||
core = staged / "core"
|
||||
core.mkdir()
|
||||
if python_files is None:
|
||||
python_files = sorted(source.name for source in (CORE_ROOT / "python").glob("*.py"))
|
||||
if not isinstance(python_files, list) or any(
|
||||
not isinstance(name, str) or Path(name).name != name or Path(name).suffix != ".py" for name in python_files
|
||||
):
|
||||
raise ValueError("pythonFiles must be a list of Python source filenames")
|
||||
_copy_declared_files(
|
||||
core,
|
||||
CORE_ROOT / "python",
|
||||
{name: name for name in python_files if not portable or name not in {"flush_worker.py", "hook_runner.py"}},
|
||||
)
|
||||
if not isinstance(skills, bool):
|
||||
raise ValueError("native skills must be a boolean")
|
||||
if not skills:
|
||||
return
|
||||
for source in sorted((CORE_ROOT / "python").glob("*.py")):
|
||||
if portable and source.name in {"flush_worker.py", "hook_runner.py"}:
|
||||
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,
|
||||
@@ -164,8 +189,6 @@ def _build_native(host: str, source_root: Path, staged: Path, descriptor: dict)
|
||||
host,
|
||||
native["pluginRoot"],
|
||||
plugin_data=str(native.get("pluginData") or ""),
|
||||
python_files=native.get("pythonFiles"),
|
||||
skills=native.get("skills", True),
|
||||
)
|
||||
_copy_declared_files(staged, source_root, native.get("files", {}))
|
||||
|
||||
@@ -237,10 +260,7 @@ def sync_generated(host: str, kind: str) -> Path:
|
||||
with tempfile.TemporaryDirectory(prefix=f"mem0-sync-{host}-") as temporary:
|
||||
generated = build(host, kind, Path(temporary) / "bundle")
|
||||
for directory in ("core", "skills"):
|
||||
if (generated / directory).is_dir():
|
||||
replace_output(generated / directory, target / directory)
|
||||
elif (target / directory).exists():
|
||||
shutil.rmtree(target / directory)
|
||||
replace_output(generated / directory, target / directory)
|
||||
return target
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = CORE_ROOT.parents[1]
|
||||
PYTHON_HOSTS = ("claude-code", "cursor", "codex", "kimi", "antigravity", "hermes")
|
||||
PYTHON_HOSTS = ("claude-code", "cursor", "codex", "kimi", "antigravity")
|
||||
GROUPS = (
|
||||
"python-bundles",
|
||||
"python-tests",
|
||||
@@ -29,14 +30,9 @@ LIVE_GROUP = "live-platform"
|
||||
|
||||
sys.path.insert(0, str(CORE_ROOT))
|
||||
sys.path.insert(0, str(CORE_ROOT / "python"))
|
||||
from memory_core import redact # noqa: E402
|
||||
from build.build import build # noqa: E402
|
||||
from conformance.artifacts import ( # noqa: E402
|
||||
TYPESCRIPT_ARTIFACTS,
|
||||
)
|
||||
from conformance.artifacts import ( # noqa: E402
|
||||
verify_artifact as _typescript_artifact_check,
|
||||
)
|
||||
from message_utils import redact # noqa: E402
|
||||
from conformance.artifacts import TYPESCRIPT_ARTIFACTS, verify_artifact as _typescript_artifact_check # noqa: E402
|
||||
|
||||
|
||||
def _package_directories() -> dict[str, Path]:
|
||||
@@ -65,14 +61,6 @@ def _runtime_commands() -> dict[str, list[list[str]]]:
|
||||
"-q",
|
||||
"--ignore=integrations/agent-plugin-core/tests/test_conformance.py",
|
||||
"--ignore=integrations/claude-code-plugin/tests/integration",
|
||||
],
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"integrations/hermes-plugin/tests",
|
||||
"--confcutdir=integrations/hermes-plugin/tests",
|
||||
"-q",
|
||||
]
|
||||
],
|
||||
"typescript-core": [["pnpm", "test"], ["pnpm", "typecheck"]],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,21 +27,24 @@ from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import telemetry
|
||||
from message_utils import MAX_EXTRACTION_INPUT_TOKENS as MAX_EXTRACTION_INPUT_TOKENS
|
||||
from message_utils import SECRET_PATTERNS as SECRET_PATTERNS
|
||||
from message_utils import _estimated_tokens as _estimated_tokens
|
||||
from message_utils import _is_agent_assignment as _is_agent_assignment
|
||||
from message_utils import _is_agent_response as _is_agent_response
|
||||
from message_utils import _message_tokens as _message_tokens
|
||||
from message_utils import extraction_message_batches as extraction_message_batches
|
||||
from message_utils import redact as redact
|
||||
|
||||
# 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 DATA_DIR_NAME as _DATA_DIR_NAME
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_DATA_DIR_NAME = "mem0-plugin"
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
PLUGIN_VERSION = "0.3.4"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
_harness_data_dir_name: str = "mem0-plugin"
|
||||
_harness_data_dir_name: str = _DATA_DIR_NAME
|
||||
_harness_source_tag: str = "mem0_plugin"
|
||||
|
||||
|
||||
@@ -73,19 +77,18 @@ CHECKPOINT_EXCHANGES = 5
|
||||
CHECKPOINT_MESSAGES = 10
|
||||
CHECKPOINT_SOURCE_CHARS = 40000
|
||||
DEFAULT_MAX_CONTEXT_CHARS = 4000
|
||||
MAX_EXTRACTION_INPUT_TOKENS = 24000
|
||||
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."""
|
||||
|
||||
@@ -145,11 +148,48 @@ BUILD_COMMAND_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
|
||||
re.compile(
|
||||
r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
|
||||
r"\s*[:=]\s*)[^\s&\"']+"
|
||||
),
|
||||
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
|
||||
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(
|
||||
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
|
||||
r'|(?:access|refresh|session)[_-]?token|token|authorization|credential'
|
||||
r')"\s*:\s*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def redact(value: Any) -> str:
|
||||
text = (
|
||||
value
|
||||
if isinstance(value, str)
|
||||
else json.dumps(value, ensure_ascii=False, default=str)
|
||||
)
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.groups:
|
||||
text = pattern.sub(r"\1[REDACTED]", text)
|
||||
else:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def bounded(value: Any, limit: int) -> str:
|
||||
text = redact(value).strip()
|
||||
if len(text) <= limit:
|
||||
@@ -349,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
|
||||
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
|
||||
|
||||
|
||||
_PLUGIN_API_KEY_ENV = (
|
||||
"PLUGIN_OPTION_API_KEY",
|
||||
"CLAUDE_PLUGIN_OPTION_API_KEY",
|
||||
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _configured(value: object) -> str:
|
||||
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
|
||||
text = value.strip() if isinstance(value, str) else ""
|
||||
return "" if text.startswith("${") and text.endswith("}") else text
|
||||
|
||||
|
||||
def _first_env(*names: str) -> str:
|
||||
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
|
||||
|
||||
|
||||
def _mem0_cli_api_key() -> str:
|
||||
"""The key `mem0 init` saved to the Mem0 CLI config."""
|
||||
try:
|
||||
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
|
||||
return _configured(config["platform"]["api_key"])
|
||||
except (OSError, ValueError, LookupError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def api_key() -> str:
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
|
||||
if configured:
|
||||
return configured
|
||||
try:
|
||||
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
|
||||
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return ""
|
||||
cached = ""
|
||||
return cached or _mem0_cli_api_key()
|
||||
|
||||
|
||||
def cache_plugin_api_key() -> bool:
|
||||
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
|
||||
configured = (
|
||||
os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
configured = _first_env(*_PLUGIN_API_KEY_ENV)
|
||||
if not configured:
|
||||
return False
|
||||
|
||||
@@ -400,14 +456,7 @@ def cache_plugin_api_key() -> bool:
|
||||
|
||||
def clear_stale_api_key_cache() -> bool:
|
||||
"""Drop the cached key file once every configured key source is gone."""
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
if configured:
|
||||
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
|
||||
return False
|
||||
path = data_dir() / "api-key"
|
||||
if not path.exists():
|
||||
@@ -430,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
|
||||
|
||||
|
||||
def _plugin_option(name: str, fallback: str = "") -> str:
|
||||
return (
|
||||
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(fallback)
|
||||
or ""
|
||||
).strip()
|
||||
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
|
||||
|
||||
|
||||
def user_id() -> str:
|
||||
@@ -1675,6 +1719,118 @@ def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]
|
||||
return messages
|
||||
|
||||
|
||||
def _estimated_tokens(value: str) -> int:
|
||||
"""Conservatively estimate tokens without adding a tokenizer dependency."""
|
||||
ascii_chars = sum(ord(char) < 128 for char in value)
|
||||
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
|
||||
|
||||
|
||||
def _message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
|
||||
|
||||
|
||||
def _is_agent_assignment(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get(
|
||||
"content", ""
|
||||
).startswith("Subagent assignment (")
|
||||
|
||||
|
||||
def _is_agent_response(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get(
|
||||
"content", ""
|
||||
).startswith("Subagent response (")
|
||||
|
||||
|
||||
def extraction_message_batches(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
exchanges: list[list[dict[str, str]]] = []
|
||||
exchange: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "user" and exchange:
|
||||
exchanges.append(exchange)
|
||||
exchange = []
|
||||
exchange.append(message)
|
||||
if exchange:
|
||||
exchanges.append(exchange)
|
||||
|
||||
units: list[list[dict[str, str]]] = []
|
||||
for exchange in exchanges:
|
||||
if _message_tokens(exchange) <= max_tokens:
|
||||
units.append(exchange)
|
||||
continue
|
||||
index = 0
|
||||
while index < len(exchange):
|
||||
message = exchange[index]
|
||||
if (
|
||||
_is_agent_assignment(message)
|
||||
and index + 1 < len(exchange)
|
||||
and _is_agent_response(exchange[index + 1])
|
||||
):
|
||||
units.append(exchange[index : index + 2])
|
||||
index += 2
|
||||
else:
|
||||
units.append([message])
|
||||
index += 1
|
||||
|
||||
bounded_units: list[list[dict[str, str]]] = []
|
||||
for unit in units:
|
||||
if _message_tokens(unit) <= max_tokens:
|
||||
bounded_units.append(unit)
|
||||
continue
|
||||
for message in unit:
|
||||
remaining = message["content"]
|
||||
while remaining:
|
||||
low, high = 0, len(remaining)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
if low == 0:
|
||||
raise ValueError("Extraction token budget cannot fit a message")
|
||||
bounded_units.append([{**message, "content": remaining[:low]}])
|
||||
remaining = remaining[low:]
|
||||
|
||||
batches: list[list[dict[str, str]]] = []
|
||||
batch: list[dict[str, str]] = []
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
batch = list(unit)
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
|
||||
|
||||
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]:
|
||||
@@ -1682,7 +1838,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:
|
||||
@@ -1709,7 +1865,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:
|
||||
@@ -1855,6 +2011,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,
|
||||
@@ -2398,7 +2561,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,127 +0,0 @@
|
||||
"""Shared, host-independent redaction and lossless extraction batching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MAX_EXTRACTION_INPUT_TOKENS = 24000
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
|
||||
re.compile(r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"),
|
||||
re.compile(
|
||||
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
|
||||
r"\s*[:=]\s*)[^\s&\"']+"
|
||||
),
|
||||
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
|
||||
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(
|
||||
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
|
||||
r"|(?:access|refresh|session)[_-]?token|token|authorization|credential"
|
||||
r')"\s*:\s*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def redact(value: Any) -> str:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.groups:
|
||||
text = pattern.sub(r"\1[REDACTED]", text)
|
||||
else:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def _estimated_tokens(value: str) -> int:
|
||||
"""Conservatively estimate tokens without adding a tokenizer dependency."""
|
||||
ascii_chars = sum(ord(char) < 128 for char in value)
|
||||
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
|
||||
|
||||
|
||||
def _message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
|
||||
|
||||
|
||||
def _is_agent_assignment(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent assignment (")
|
||||
|
||||
|
||||
def _is_agent_response(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent response (")
|
||||
|
||||
|
||||
def extraction_message_batches(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
exchanges: list[list[dict[str, str]]] = []
|
||||
exchange: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "user" and exchange:
|
||||
exchanges.append(exchange)
|
||||
exchange = []
|
||||
exchange.append(message)
|
||||
if exchange:
|
||||
exchanges.append(exchange)
|
||||
|
||||
units: list[list[dict[str, str]]] = []
|
||||
for exchange in exchanges:
|
||||
if _message_tokens(exchange) <= max_tokens:
|
||||
units.append(exchange)
|
||||
continue
|
||||
index = 0
|
||||
while index < len(exchange):
|
||||
message = exchange[index]
|
||||
if _is_agent_assignment(message) and index + 1 < len(exchange) and _is_agent_response(exchange[index + 1]):
|
||||
units.append(exchange[index : index + 2])
|
||||
index += 2
|
||||
else:
|
||||
units.append([message])
|
||||
index += 1
|
||||
|
||||
bounded_units: list[list[dict[str, str]]] = []
|
||||
for unit in units:
|
||||
if _message_tokens(unit) <= max_tokens:
|
||||
bounded_units.append(unit)
|
||||
continue
|
||||
for message in unit:
|
||||
remaining = message["content"]
|
||||
while remaining:
|
||||
low, high = 0, len(remaining)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
if low == 0:
|
||||
raise ValueError("Extraction token budget cannot fit a message")
|
||||
bounded_units.append([{**message, "content": remaining[:low]}])
|
||||
remaining = remaining[low:]
|
||||
|
||||
batches: list[list[dict[str, str]]] = []
|
||||
batch: list[dict[str, str]] = []
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
batch = list(unit)
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
@@ -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,4 +1,3 @@
|
||||
jsonschema>=4.23,<5
|
||||
pytest>=8,<10
|
||||
skills-ref==0.1.1
|
||||
httpx>=0.27,<1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
|
||||
number of completed flushes, not a pending count), and the doctor check
|
||||
results. If doctor reports an authentication failure (401 / invalid key), say
|
||||
clearly that the Mem0 API key is invalid or expired and that memories are NOT
|
||||
being created. Never report an auth failure as "no memories found". Suggest
|
||||
reinstalling with `--config api_key=...` in that case.
|
||||
being created. Never report an auth failure as "no memories found". When the
|
||||
key is missing or invalid, suggest updating the plugin's API key setting,
|
||||
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
|
||||
Mem0 CLI saves in `~/.mem0/config.json`).
|
||||
|
||||
@@ -15,7 +15,6 @@ from build.build import ( # noqa: E402
|
||||
bundle_drift,
|
||||
render_template,
|
||||
replace_output,
|
||||
sync_generated,
|
||||
)
|
||||
from build.validate import validate_bundle # noqa: E402
|
||||
|
||||
@@ -71,6 +70,40 @@ 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["DATA_DIR_NAME"] == "coding-agent-plugin"
|
||||
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
|
||||
assert identity["DATA_DIR_NAME"] == f"{host}-plugin"
|
||||
|
||||
|
||||
@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)
|
||||
@@ -114,7 +147,6 @@ def test_native_control_skills_select_the_host_store(host: str, tmp_path: Path)
|
||||
("codex", "native"),
|
||||
("kimi", "native"),
|
||||
("antigravity", "native"),
|
||||
("hermes", "native"),
|
||||
],
|
||||
)
|
||||
def test_installable_plugin_directories_are_current(host: str, kind: str) -> None:
|
||||
@@ -135,91 +167,3 @@ def test_marketplaces_keep_public_names_and_reference_real_plugins() -> None:
|
||||
assert [plugin["name"] for plugin in codex_marketplace["plugins"]] == ["mem0"]
|
||||
codex = codex_marketplace["plugins"][0]
|
||||
assert codex["source"]["path"] == "./integrations/codex-plugin"
|
||||
|
||||
|
||||
def test_native_bundle_can_select_runtime_without_skills(tmp_path: Path, monkeypatch) -> None:
|
||||
from build import build as builder
|
||||
|
||||
source = tmp_path / "plugin"
|
||||
source.mkdir()
|
||||
(source / "__init__.py").write_text("# Native plugin adapter\n", encoding="utf-8")
|
||||
(source / "plugin-build.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"native": {
|
||||
"pluginRoot": "${PLUGIN_ROOT}",
|
||||
"pythonFiles": ["message_utils.py"],
|
||||
"skills": False,
|
||||
"files": {"__init__.py": "__init__.py"},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setitem(builder.NATIVE_PLUGINS, "test-host", source)
|
||||
stale_skill = source / "skills" / "remember" / "SKILL.md"
|
||||
stale_skill.parent.mkdir(parents=True)
|
||||
stale_skill.write_text("stale generated skill", encoding="utf-8")
|
||||
|
||||
root = build("test-host", "native", tmp_path / "output")
|
||||
|
||||
assert {path.name for path in (root / "core").iterdir()} == {"message_utils.py"}
|
||||
assert not (root / "skills").exists()
|
||||
assert (root / "__init__.py").read_text(encoding="utf-8") == "# Native plugin adapter\n"
|
||||
sync_generated("test-host", "native")
|
||||
assert bundle_drift("test-host", "native") == []
|
||||
assert not (source / "skills").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("files", ["message_utils.py", ["../README.md"], ["/tmp/source.py"], [7], ["missing.py"]])
|
||||
def test_native_runtime_selection_rejects_invalid_sources(files, tmp_path: Path) -> None:
|
||||
from build.build import _build_native
|
||||
|
||||
with pytest.raises(ValueError, match="pythonFiles|native source file"):
|
||||
_build_native(
|
||||
"test",
|
||||
tmp_path,
|
||||
tmp_path,
|
||||
{
|
||||
"native": {
|
||||
"pluginRoot": "${PLUGIN_ROOT}",
|
||||
"pythonFiles": files,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_native_runtime_selection_rejects_escaping_symlink(tmp_path: Path, monkeypatch) -> None:
|
||||
from build import build as builder
|
||||
|
||||
source = tmp_path / "shared" / "python"
|
||||
source.mkdir(parents=True)
|
||||
secret = tmp_path / "outside.py"
|
||||
secret.write_text("secret", encoding="utf-8")
|
||||
(source / "message_utils.py").symlink_to(secret)
|
||||
monkeypatch.setattr(builder, "CORE_ROOT", source.parent)
|
||||
staged = tmp_path / "bundle"
|
||||
staged.mkdir()
|
||||
|
||||
with pytest.raises(ValueError, match="inside their roots"):
|
||||
builder._build_native(
|
||||
"test",
|
||||
tmp_path,
|
||||
staged,
|
||||
{
|
||||
"native": {
|
||||
"pluginRoot": "${PLUGIN_ROOT}",
|
||||
"pythonFiles": ["message_utils.py"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_hermes_bundle_uses_only_host_independent_runtime(tmp_path: Path) -> None:
|
||||
root = build("hermes", "native", tmp_path / "hermes")
|
||||
|
||||
assert {path.name for path in (root / "core").iterdir()} == {"message_utils.py"}
|
||||
assert (root / "__init__.py").is_file()
|
||||
assert (root / "plugin.yaml").is_file()
|
||||
assert not (root / "skills").exists()
|
||||
assert not (root / "agents").exists()
|
||||
|
||||
@@ -10,9 +10,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from conformance import run as conformance_run # noqa: E402
|
||||
from conformance.run import _command_check # noqa: E402
|
||||
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER = PLUGIN_ROOT / "conformance" / "run.py"
|
||||
PYTHON_HOSTS = {"claude-code", "cursor", "codex", "kimi", "antigravity", "hermes"}
|
||||
PYTHON_HOSTS = {"claude-code", "cursor", "codex", "kimi", "antigravity"}
|
||||
|
||||
|
||||
def test_python_bundle_conformance_builds_every_host(tmp_path: Path) -> None:
|
||||
@@ -77,12 +78,6 @@ def test_conformance_plan_covers_every_runtime(tmp_path: Path) -> None:
|
||||
"deepseek",
|
||||
}
|
||||
assert all(entry["status"] == "planned" for entry in payload["checks"])
|
||||
python_commands = [entry["command"] for entry in payload["checks"] if entry["group"] == "python-tests"]
|
||||
assert any(
|
||||
"integrations/hermes-plugin/tests" in command
|
||||
and "--confcutdir=integrations/hermes-plugin/tests" in command
|
||||
for command in python_commands
|
||||
)
|
||||
assert {
|
||||
entry["group"]
|
||||
for entry in payload["checks"]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Launch each host's real hooks and MCP server the way the host does, and check the search is authenticated."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
import pytest
|
||||
|
||||
INTEGRATIONS = Path(__file__).resolve().parents[2]
|
||||
KEY = "m0-plugin-setting-key"
|
||||
CODEX_INHERITED_ENV = ("HOME", "PATH", "LANG", "TMPDIR")
|
||||
|
||||
|
||||
class _Mem0Api(BaseHTTPRequestHandler):
|
||||
authorizations: list[str]
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
||||
self.authorizations.append(self.headers.get("Authorization", ""))
|
||||
body = b'{"results": []}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mem0_api():
|
||||
handler = type("Handler", (_Mem0Api,), {"authorizations": []})
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{server.server_port}", handler.authorizations
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def _json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _expand(value: str, variables: dict[str, str]) -> str:
|
||||
return Template(value).safe_substitute(variables)
|
||||
|
||||
|
||||
def _run_hook(command: str, cwd: Path, env: dict[str, str], payload: dict) -> None:
|
||||
result = subprocess.run(
|
||||
["sh", "-c", command], cwd=cwd, env=env, input=json.dumps(payload), text=True, capture_output=True
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def _search(argv: list[str], cwd: Path, env: dict[str, str]) -> dict:
|
||||
requests = [
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "search_memories", "arguments": {"query": "earlier fixes"}},
|
||||
},
|
||||
]
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
input="\n".join(json.dumps(request) for request in requests) + "\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout.splitlines()[-1])
|
||||
|
||||
|
||||
def _claude_code(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
|
||||
root = INTEGRATIONS / "claude-code-plugin"
|
||||
plugin_env = {"CLAUDE_PLUGIN_ROOT": str(root), "CLAUDE_PLUGIN_DATA": str(tmp_path / "claude-data")}
|
||||
hook_env = {**host_env, **plugin_env, **({"CLAUDE_PLUGIN_OPTION_API_KEY": KEY} if setting else {})}
|
||||
command = _json(root / "hooks" / "hooks.json")["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
_run_hook(command, repo, hook_env, {"session_id": "s1", "cwd": str(repo)})
|
||||
|
||||
server = _json(root / ".mcp.json")["mcpServers"]["mem0"]
|
||||
env = {**host_env, **{name: _expand(value, plugin_env) for name, value in server["env"].items()}}
|
||||
return [sys.executable, *(_expand(argument, plugin_env) for argument in server["args"])], repo, env
|
||||
|
||||
|
||||
def _cursor(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
|
||||
root = INTEGRATIONS / "cursor-plugin"
|
||||
command = _json(root / "hooks" / "hooks.json")["hooks"]["sessionStart"][0]["command"]
|
||||
if setting:
|
||||
command = command.replace("${api_key}", KEY)
|
||||
hook_env = {**host_env, "CURSOR_PLUGIN_ROOT": str(root)}
|
||||
_run_hook(command, repo, hook_env, {"conversation_id": "c1", "workspace_roots": [str(repo)]})
|
||||
|
||||
server = _json(root / "mcp.json")["mcpServers"]["mem0"]
|
||||
args = [_expand(argument, {"CURSOR_PLUGIN_ROOT": str(root)}) for argument in server["args"]]
|
||||
return [sys.executable, *args], repo, {**host_env, **server["env"]}
|
||||
|
||||
|
||||
def _codex(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
|
||||
root = INTEGRATIONS / "codex-plugin"
|
||||
codex_env = {**host_env, **({"MEM0_API_KEY": KEY} if setting else {})}
|
||||
data = str(tmp_path / "codex-data")
|
||||
hook_env = {**codex_env, "PLUGIN_ROOT": str(root), "PLUGIN_DATA": data, "CLAUDE_PLUGIN_DATA": data}
|
||||
command = _json(root / "hooks" / "hooks.json")["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
_run_hook(command, repo, hook_env, {"session_id": "s1", "cwd": str(repo)})
|
||||
|
||||
server = _json(root / ".mcp.json")["mcpServers"]["mem0"]
|
||||
forwarded = [*CODEX_INHERITED_ENV, *server["env_vars"]]
|
||||
env = {name: codex_env[name] for name in forwarded if name in codex_env}
|
||||
return [sys.executable, *server["args"]], root / server["cwd"], env
|
||||
|
||||
|
||||
HOSTS = {"claude-code": _claude_code, "cursor": _cursor, "codex": _codex}
|
||||
|
||||
|
||||
def _mem0_init(home: Path, key: str) -> None:
|
||||
(home / ".mem0").mkdir(parents=True, exist_ok=True)
|
||||
(home / ".mem0" / "config.json").write_text(json.dumps({"platform": {"api_key": key}}), encoding="utf-8")
|
||||
|
||||
|
||||
def _host_env(home: Path, api_url: str) -> dict[str, str]:
|
||||
return {
|
||||
"HOME": str(home),
|
||||
"PATH": os.environ["PATH"],
|
||||
"MEM0_API_URL": api_url,
|
||||
"MEM0_TELEMETRY": "false",
|
||||
"MEM0_CODE_USER_ID": "test-user",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key_source", ["plugin setting", "mem0 init"])
|
||||
@pytest.mark.parametrize("host", sorted(HOSTS))
|
||||
def test_mcp_server_searches_with_the_key_the_user_configured(host, key_source, tmp_path, mem0_api):
|
||||
"""The key reaches the MCP server however the host delivers it: plugin setting, env, or `mem0 init`."""
|
||||
api_url, authorizations = mem0_api
|
||||
home = tmp_path / "home"
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
if key_source == "mem0 init":
|
||||
_mem0_init(home, KEY)
|
||||
|
||||
argv, cwd, env = HOSTS[host](_host_env(home, api_url), tmp_path, repo, key_source == "plugin setting")
|
||||
response = _search(argv, cwd, env)
|
||||
|
||||
assert not response["result"].get("isError"), response
|
||||
assert authorizations == [f"Token {KEY}"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", sorted(HOSTS))
|
||||
def test_a_removed_plugin_setting_gives_way_to_a_later_mem0_init(host, tmp_path, mem0_api):
|
||||
"""The key saved from a plugin setting is dropped once the setting is gone, so a newer `mem0 init` key wins."""
|
||||
api_url, authorizations = mem0_api
|
||||
home = tmp_path / "home"
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
host_env = _host_env(home, api_url)
|
||||
HOSTS[host](host_env, tmp_path, repo, True)
|
||||
_mem0_init(home, "m0-mem0-init-key")
|
||||
|
||||
argv, cwd, env = HOSTS[host](host_env, tmp_path, repo, False)
|
||||
response = _search(argv, cwd, env)
|
||||
|
||||
assert not response["result"].get("isError"), response
|
||||
assert authorizations == ["Token m0-mem0-init-key"]
|
||||
@@ -1,39 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CORE = Path(__file__).resolve().parents[1] / "python"
|
||||
|
||||
|
||||
def test_message_helpers_are_standalone_and_keep_legacy_exports() -> None:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import message_utils
|
||||
assert "memory_core" not in sys.modules
|
||||
assert "telemetry" not in sys.modules
|
||||
text = "prefix " + "日本語🙂" * 250 + ' {"password": "secret value"}'
|
||||
redacted = message_utils.redact(text)
|
||||
assert "secret value" not in redacted
|
||||
messages = [{"role": "user", "content": redacted}]
|
||||
batches = message_utils.extraction_message_batches(messages, max_tokens=100)
|
||||
assert len(batches) > 1
|
||||
assert all(message_utils._message_tokens(batch) <= 100 for batch in batches)
|
||||
assert "".join(m["content"] for batch in batches for m in batch) == redacted
|
||||
import memory_core
|
||||
for name in ("redact", "SECRET_PATTERNS", "extraction_message_batches", "_message_tokens"):
|
||||
assert getattr(memory_core, name) is getattr(message_utils, name)
|
||||
""",
|
||||
],
|
||||
cwd=CORE,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -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,294 @@
|
||||
"""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}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness,spec", sorted(HOSTS.items()))
|
||||
def test_data_dir_resolves_without_init(harness, spec):
|
||||
"""mcp_server never configures the harness, so its default must match configure_harness(harness) in hooks and skills."""
|
||||
directory, _ = 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 os; os.environ.pop('MEM0_CODE_DATA_DIR')\n"
|
||||
"import mcp_server, memory_core; print(memory_core.data_dir())\n"
|
||||
f"memory_core.configure_harness({harness!r}); print(memory_core.data_dir())",
|
||||
)
|
||||
expected = str(Path(tmp) / ".mem0" / f"{harness}-plugin")
|
||||
assert out.splitlines() == [expected, expected]
|
||||
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function mem0CliApiKey(homeDir: string): string {
|
||||
try {
|
||||
const key = JSON.parse(readFileSync(join(homeDir, ".mem0", "config.json"), "utf8"))?.platform?.api_key;
|
||||
return typeof key === "string" ? key.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -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,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { mem0CliApiKey } from "../src/credentials.ts";
|
||||
|
||||
function homeWithCliConfig(config: string): string {
|
||||
const home = mkdtempSync(join(tmpdir(), "mem0-cli-home-"));
|
||||
mkdirSync(join(home, ".mem0"));
|
||||
writeFileSync(join(home, ".mem0", "config.json"), config);
|
||||
return home;
|
||||
}
|
||||
|
||||
test("reads the key mem0 init saved", () => {
|
||||
const home = homeWithCliConfig(JSON.stringify({ platform: { api_key: " m0-cli-key\n" } }));
|
||||
assert.equal(mem0CliApiKey(home), "m0-cli-key");
|
||||
});
|
||||
|
||||
test("missing, malformed, or non-string config reads as no key", () => {
|
||||
assert.equal(mem0CliApiKey(mkdtempSync(join(tmpdir(), "mem0-cli-home-"))), "");
|
||||
assert.equal(mem0CliApiKey(homeWithCliConfig("{not json")), "");
|
||||
assert.equal(mem0CliApiKey(homeWithCliConfig("null")), "");
|
||||
assert.equal(mem0CliApiKey(homeWithCliConfig(JSON.stringify({ platform: { api_key: 42 } }))), "");
|
||||
});
|
||||
@@ -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,12 @@
|
||||
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
|
||||
|
||||
HARNESS_ID = "antigravity"
|
||||
SOURCE_TAG = "ANTIGRAVITY_PLUGIN"
|
||||
DATA_DIR_NAME = "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",
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,21 +27,24 @@ from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import telemetry
|
||||
from message_utils import MAX_EXTRACTION_INPUT_TOKENS as MAX_EXTRACTION_INPUT_TOKENS
|
||||
from message_utils import SECRET_PATTERNS as SECRET_PATTERNS
|
||||
from message_utils import _estimated_tokens as _estimated_tokens
|
||||
from message_utils import _is_agent_assignment as _is_agent_assignment
|
||||
from message_utils import _is_agent_response as _is_agent_response
|
||||
from message_utils import _message_tokens as _message_tokens
|
||||
from message_utils import extraction_message_batches as extraction_message_batches
|
||||
from message_utils import redact as redact
|
||||
|
||||
# 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 DATA_DIR_NAME as _DATA_DIR_NAME
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_DATA_DIR_NAME = "mem0-plugin"
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
PLUGIN_VERSION = "0.3.4"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
_harness_data_dir_name: str = "mem0-plugin"
|
||||
_harness_data_dir_name: str = _DATA_DIR_NAME
|
||||
_harness_source_tag: str = "mem0_plugin"
|
||||
|
||||
|
||||
@@ -73,19 +77,18 @@ CHECKPOINT_EXCHANGES = 5
|
||||
CHECKPOINT_MESSAGES = 10
|
||||
CHECKPOINT_SOURCE_CHARS = 40000
|
||||
DEFAULT_MAX_CONTEXT_CHARS = 4000
|
||||
MAX_EXTRACTION_INPUT_TOKENS = 24000
|
||||
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."""
|
||||
|
||||
@@ -145,11 +148,48 @@ BUILD_COMMAND_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
|
||||
re.compile(
|
||||
r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
|
||||
r"\s*[:=]\s*)[^\s&\"']+"
|
||||
),
|
||||
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
|
||||
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(
|
||||
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
|
||||
r'|(?:access|refresh|session)[_-]?token|token|authorization|credential'
|
||||
r')"\s*:\s*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def redact(value: Any) -> str:
|
||||
text = (
|
||||
value
|
||||
if isinstance(value, str)
|
||||
else json.dumps(value, ensure_ascii=False, default=str)
|
||||
)
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.groups:
|
||||
text = pattern.sub(r"\1[REDACTED]", text)
|
||||
else:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def bounded(value: Any, limit: int) -> str:
|
||||
text = redact(value).strip()
|
||||
if len(text) <= limit:
|
||||
@@ -349,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
|
||||
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
|
||||
|
||||
|
||||
_PLUGIN_API_KEY_ENV = (
|
||||
"PLUGIN_OPTION_API_KEY",
|
||||
"CLAUDE_PLUGIN_OPTION_API_KEY",
|
||||
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _configured(value: object) -> str:
|
||||
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
|
||||
text = value.strip() if isinstance(value, str) else ""
|
||||
return "" if text.startswith("${") and text.endswith("}") else text
|
||||
|
||||
|
||||
def _first_env(*names: str) -> str:
|
||||
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
|
||||
|
||||
|
||||
def _mem0_cli_api_key() -> str:
|
||||
"""The key `mem0 init` saved to the Mem0 CLI config."""
|
||||
try:
|
||||
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
|
||||
return _configured(config["platform"]["api_key"])
|
||||
except (OSError, ValueError, LookupError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def api_key() -> str:
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
|
||||
if configured:
|
||||
return configured
|
||||
try:
|
||||
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
|
||||
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return ""
|
||||
cached = ""
|
||||
return cached or _mem0_cli_api_key()
|
||||
|
||||
|
||||
def cache_plugin_api_key() -> bool:
|
||||
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
|
||||
configured = (
|
||||
os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
configured = _first_env(*_PLUGIN_API_KEY_ENV)
|
||||
if not configured:
|
||||
return False
|
||||
|
||||
@@ -400,14 +456,7 @@ def cache_plugin_api_key() -> bool:
|
||||
|
||||
def clear_stale_api_key_cache() -> bool:
|
||||
"""Drop the cached key file once every configured key source is gone."""
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
if configured:
|
||||
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
|
||||
return False
|
||||
path = data_dir() / "api-key"
|
||||
if not path.exists():
|
||||
@@ -430,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
|
||||
|
||||
|
||||
def _plugin_option(name: str, fallback: str = "") -> str:
|
||||
return (
|
||||
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(fallback)
|
||||
or ""
|
||||
).strip()
|
||||
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
|
||||
|
||||
|
||||
def user_id() -> str:
|
||||
@@ -1675,6 +1719,118 @@ def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]
|
||||
return messages
|
||||
|
||||
|
||||
def _estimated_tokens(value: str) -> int:
|
||||
"""Conservatively estimate tokens without adding a tokenizer dependency."""
|
||||
ascii_chars = sum(ord(char) < 128 for char in value)
|
||||
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
|
||||
|
||||
|
||||
def _message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
|
||||
|
||||
|
||||
def _is_agent_assignment(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get(
|
||||
"content", ""
|
||||
).startswith("Subagent assignment (")
|
||||
|
||||
|
||||
def _is_agent_response(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get(
|
||||
"content", ""
|
||||
).startswith("Subagent response (")
|
||||
|
||||
|
||||
def extraction_message_batches(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
exchanges: list[list[dict[str, str]]] = []
|
||||
exchange: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "user" and exchange:
|
||||
exchanges.append(exchange)
|
||||
exchange = []
|
||||
exchange.append(message)
|
||||
if exchange:
|
||||
exchanges.append(exchange)
|
||||
|
||||
units: list[list[dict[str, str]]] = []
|
||||
for exchange in exchanges:
|
||||
if _message_tokens(exchange) <= max_tokens:
|
||||
units.append(exchange)
|
||||
continue
|
||||
index = 0
|
||||
while index < len(exchange):
|
||||
message = exchange[index]
|
||||
if (
|
||||
_is_agent_assignment(message)
|
||||
and index + 1 < len(exchange)
|
||||
and _is_agent_response(exchange[index + 1])
|
||||
):
|
||||
units.append(exchange[index : index + 2])
|
||||
index += 2
|
||||
else:
|
||||
units.append([message])
|
||||
index += 1
|
||||
|
||||
bounded_units: list[list[dict[str, str]]] = []
|
||||
for unit in units:
|
||||
if _message_tokens(unit) <= max_tokens:
|
||||
bounded_units.append(unit)
|
||||
continue
|
||||
for message in unit:
|
||||
remaining = message["content"]
|
||||
while remaining:
|
||||
low, high = 0, len(remaining)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
if low == 0:
|
||||
raise ValueError("Extraction token budget cannot fit a message")
|
||||
bounded_units.append([{**message, "content": remaining[:low]}])
|
||||
remaining = remaining[low:]
|
||||
|
||||
batches: list[list[dict[str, str]]] = []
|
||||
batch: list[dict[str, str]] = []
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
batch = list(unit)
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
|
||||
|
||||
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]:
|
||||
@@ -1682,7 +1838,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:
|
||||
@@ -1709,7 +1865,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:
|
||||
@@ -1855,6 +2011,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,
|
||||
@@ -2398,7 +2561,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,127 +0,0 @@
|
||||
"""Shared, host-independent redaction and lossless extraction batching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MAX_EXTRACTION_INPUT_TOKENS = 24000
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
|
||||
re.compile(r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"),
|
||||
re.compile(
|
||||
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
|
||||
r"\s*[:=]\s*)[^\s&\"']+"
|
||||
),
|
||||
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
|
||||
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(
|
||||
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
|
||||
r"|(?:access|refresh|session)[_-]?token|token|authorization|credential"
|
||||
r')"\s*:\s*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def redact(value: Any) -> str:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.groups:
|
||||
text = pattern.sub(r"\1[REDACTED]", text)
|
||||
else:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def _estimated_tokens(value: str) -> int:
|
||||
"""Conservatively estimate tokens without adding a tokenizer dependency."""
|
||||
ascii_chars = sum(ord(char) < 128 for char in value)
|
||||
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
|
||||
|
||||
|
||||
def _message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
|
||||
|
||||
|
||||
def _is_agent_assignment(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent assignment (")
|
||||
|
||||
|
||||
def _is_agent_response(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent response (")
|
||||
|
||||
|
||||
def extraction_message_batches(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
exchanges: list[list[dict[str, str]]] = []
|
||||
exchange: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "user" and exchange:
|
||||
exchanges.append(exchange)
|
||||
exchange = []
|
||||
exchange.append(message)
|
||||
if exchange:
|
||||
exchanges.append(exchange)
|
||||
|
||||
units: list[list[dict[str, str]]] = []
|
||||
for exchange in exchanges:
|
||||
if _message_tokens(exchange) <= max_tokens:
|
||||
units.append(exchange)
|
||||
continue
|
||||
index = 0
|
||||
while index < len(exchange):
|
||||
message = exchange[index]
|
||||
if _is_agent_assignment(message) and index + 1 < len(exchange) and _is_agent_response(exchange[index + 1]):
|
||||
units.append(exchange[index : index + 2])
|
||||
index += 2
|
||||
else:
|
||||
units.append([message])
|
||||
index += 1
|
||||
|
||||
bounded_units: list[list[dict[str, str]]] = []
|
||||
for unit in units:
|
||||
if _message_tokens(unit) <= max_tokens:
|
||||
bounded_units.append(unit)
|
||||
continue
|
||||
for message in unit:
|
||||
remaining = message["content"]
|
||||
while remaining:
|
||||
low, high = 0, len(remaining)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
if low == 0:
|
||||
raise ValueError("Extraction token budget cannot fit a message")
|
||||
bounded_units.append([{**message, "content": remaining[:low]}])
|
||||
remaining = remaining[low:]
|
||||
|
||||
batches: list[list[dict[str, str]]] = []
|
||||
batch: list[dict[str, str]] = []
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
batch = list(unit)
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
@@ -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.4",
|
||||
"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
|
||||
|
||||
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
|
||||
number of completed flushes, not a pending count), and the doctor check
|
||||
results. If doctor reports an authentication failure (401 / invalid key), say
|
||||
clearly that the Mem0 API key is invalid or expired and that memories are NOT
|
||||
being created. Never report an auth failure as "no memories found". Suggest
|
||||
reinstalling with `--config api_key=...` in that case.
|
||||
being created. Never report an auth failure as "no memories found". When the
|
||||
key is missing or invalid, suggest updating the plugin's API key setting,
|
||||
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
|
||||
Mem0 CLI saves in `~/.mem0/config.json`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"description": "Cross-session memory and token savings for coding agents.",
|
||||
"author": {
|
||||
"name": "Mem0"
|
||||
|
||||
@@ -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,12 @@
|
||||
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
|
||||
|
||||
HARNESS_ID = "claude-code"
|
||||
SOURCE_TAG = "CLAUDE_CODE_PLUGIN"
|
||||
DATA_DIR_NAME = "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",
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,21 +27,24 @@ from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import telemetry
|
||||
from message_utils import MAX_EXTRACTION_INPUT_TOKENS as MAX_EXTRACTION_INPUT_TOKENS
|
||||
from message_utils import SECRET_PATTERNS as SECRET_PATTERNS
|
||||
from message_utils import _estimated_tokens as _estimated_tokens
|
||||
from message_utils import _is_agent_assignment as _is_agent_assignment
|
||||
from message_utils import _is_agent_response as _is_agent_response
|
||||
from message_utils import _message_tokens as _message_tokens
|
||||
from message_utils import extraction_message_batches as extraction_message_batches
|
||||
from message_utils import redact as redact
|
||||
|
||||
# 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 DATA_DIR_NAME as _DATA_DIR_NAME
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_DATA_DIR_NAME = "mem0-plugin"
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
PLUGIN_VERSION = "0.3.4"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
_harness_data_dir_name: str = "mem0-plugin"
|
||||
_harness_data_dir_name: str = _DATA_DIR_NAME
|
||||
_harness_source_tag: str = "mem0_plugin"
|
||||
|
||||
|
||||
@@ -73,19 +77,18 @@ CHECKPOINT_EXCHANGES = 5
|
||||
CHECKPOINT_MESSAGES = 10
|
||||
CHECKPOINT_SOURCE_CHARS = 40000
|
||||
DEFAULT_MAX_CONTEXT_CHARS = 4000
|
||||
MAX_EXTRACTION_INPUT_TOKENS = 24000
|
||||
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."""
|
||||
|
||||
@@ -145,11 +148,48 @@ BUILD_COMMAND_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
|
||||
re.compile(
|
||||
r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
|
||||
r"\s*[:=]\s*)[^\s&\"']+"
|
||||
),
|
||||
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
|
||||
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(
|
||||
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
|
||||
r'|(?:access|refresh|session)[_-]?token|token|authorization|credential'
|
||||
r')"\s*:\s*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def redact(value: Any) -> str:
|
||||
text = (
|
||||
value
|
||||
if isinstance(value, str)
|
||||
else json.dumps(value, ensure_ascii=False, default=str)
|
||||
)
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.groups:
|
||||
text = pattern.sub(r"\1[REDACTED]", text)
|
||||
else:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def bounded(value: Any, limit: int) -> str:
|
||||
text = redact(value).strip()
|
||||
if len(text) <= limit:
|
||||
@@ -349,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
|
||||
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
|
||||
|
||||
|
||||
_PLUGIN_API_KEY_ENV = (
|
||||
"PLUGIN_OPTION_API_KEY",
|
||||
"CLAUDE_PLUGIN_OPTION_API_KEY",
|
||||
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _configured(value: object) -> str:
|
||||
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
|
||||
text = value.strip() if isinstance(value, str) else ""
|
||||
return "" if text.startswith("${") and text.endswith("}") else text
|
||||
|
||||
|
||||
def _first_env(*names: str) -> str:
|
||||
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
|
||||
|
||||
|
||||
def _mem0_cli_api_key() -> str:
|
||||
"""The key `mem0 init` saved to the Mem0 CLI config."""
|
||||
try:
|
||||
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
|
||||
return _configured(config["platform"]["api_key"])
|
||||
except (OSError, ValueError, LookupError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def api_key() -> str:
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
|
||||
if configured:
|
||||
return configured
|
||||
try:
|
||||
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
|
||||
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return ""
|
||||
cached = ""
|
||||
return cached or _mem0_cli_api_key()
|
||||
|
||||
|
||||
def cache_plugin_api_key() -> bool:
|
||||
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
|
||||
configured = (
|
||||
os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
configured = _first_env(*_PLUGIN_API_KEY_ENV)
|
||||
if not configured:
|
||||
return False
|
||||
|
||||
@@ -400,14 +456,7 @@ def cache_plugin_api_key() -> bool:
|
||||
|
||||
def clear_stale_api_key_cache() -> bool:
|
||||
"""Drop the cached key file once every configured key source is gone."""
|
||||
configured = (
|
||||
os.environ.get("MEM0_API_KEY")
|
||||
or os.environ.get("PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
|
||||
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
if configured:
|
||||
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
|
||||
return False
|
||||
path = data_dir() / "api-key"
|
||||
if not path.exists():
|
||||
@@ -430,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
|
||||
|
||||
|
||||
def _plugin_option(name: str, fallback: str = "") -> str:
|
||||
return (
|
||||
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
|
||||
or os.environ.get(fallback)
|
||||
or ""
|
||||
).strip()
|
||||
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
|
||||
|
||||
|
||||
def user_id() -> str:
|
||||
@@ -1675,6 +1719,118 @@ def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]
|
||||
return messages
|
||||
|
||||
|
||||
def _estimated_tokens(value: str) -> int:
|
||||
"""Conservatively estimate tokens without adding a tokenizer dependency."""
|
||||
ascii_chars = sum(ord(char) < 128 for char in value)
|
||||
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
|
||||
|
||||
|
||||
def _message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
|
||||
|
||||
|
||||
def _is_agent_assignment(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get(
|
||||
"content", ""
|
||||
).startswith("Subagent assignment (")
|
||||
|
||||
|
||||
def _is_agent_response(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get(
|
||||
"content", ""
|
||||
).startswith("Subagent response (")
|
||||
|
||||
|
||||
def extraction_message_batches(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
exchanges: list[list[dict[str, str]]] = []
|
||||
exchange: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "user" and exchange:
|
||||
exchanges.append(exchange)
|
||||
exchange = []
|
||||
exchange.append(message)
|
||||
if exchange:
|
||||
exchanges.append(exchange)
|
||||
|
||||
units: list[list[dict[str, str]]] = []
|
||||
for exchange in exchanges:
|
||||
if _message_tokens(exchange) <= max_tokens:
|
||||
units.append(exchange)
|
||||
continue
|
||||
index = 0
|
||||
while index < len(exchange):
|
||||
message = exchange[index]
|
||||
if (
|
||||
_is_agent_assignment(message)
|
||||
and index + 1 < len(exchange)
|
||||
and _is_agent_response(exchange[index + 1])
|
||||
):
|
||||
units.append(exchange[index : index + 2])
|
||||
index += 2
|
||||
else:
|
||||
units.append([message])
|
||||
index += 1
|
||||
|
||||
bounded_units: list[list[dict[str, str]]] = []
|
||||
for unit in units:
|
||||
if _message_tokens(unit) <= max_tokens:
|
||||
bounded_units.append(unit)
|
||||
continue
|
||||
for message in unit:
|
||||
remaining = message["content"]
|
||||
while remaining:
|
||||
low, high = 0, len(remaining)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
if low == 0:
|
||||
raise ValueError("Extraction token budget cannot fit a message")
|
||||
bounded_units.append([{**message, "content": remaining[:low]}])
|
||||
remaining = remaining[low:]
|
||||
|
||||
batches: list[list[dict[str, str]]] = []
|
||||
batch: list[dict[str, str]] = []
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
batch = list(unit)
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
|
||||
|
||||
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]:
|
||||
@@ -1682,7 +1838,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:
|
||||
@@ -1709,7 +1865,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:
|
||||
@@ -1855,6 +2011,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,
|
||||
@@ -2398,7 +2561,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,127 +0,0 @@
|
||||
"""Shared, host-independent redaction and lossless extraction batching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MAX_EXTRACTION_INPUT_TOKENS = 24000
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"),
|
||||
re.compile(r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+"),
|
||||
re.compile(
|
||||
r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)"
|
||||
r"\s*[:=]\s*)[^\s&\"']+"
|
||||
),
|
||||
re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"),
|
||||
re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"),
|
||||
re.compile(
|
||||
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
re.compile(
|
||||
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
|
||||
r"|(?:access|refresh|session)[_-]?token|token|authorization|credential"
|
||||
r')"\s*:\s*")(?:\\.|[^"\\])*'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def redact(value: Any) -> str:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.groups:
|
||||
text = pattern.sub(r"\1[REDACTED]", text)
|
||||
else:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def _estimated_tokens(value: str) -> int:
|
||||
"""Conservatively estimate tokens without adding a tokenizer dependency."""
|
||||
ascii_chars = sum(ord(char) < 128 for char in value)
|
||||
return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars))
|
||||
|
||||
|
||||
def _message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
return _estimated_tokens(json.dumps(messages, ensure_ascii=False))
|
||||
|
||||
|
||||
def _is_agent_assignment(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent assignment (")
|
||||
|
||||
|
||||
def _is_agent_response(message: dict[str, str]) -> bool:
|
||||
return message.get("role") == "assistant" and message.get("content", "").startswith("Subagent response (")
|
||||
|
||||
|
||||
def extraction_message_batches(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
|
||||
) -> list[list[dict[str, str]]]:
|
||||
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
|
||||
if not messages or _message_tokens(messages) <= max_tokens:
|
||||
return [messages]
|
||||
|
||||
exchanges: list[list[dict[str, str]]] = []
|
||||
exchange: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "user" and exchange:
|
||||
exchanges.append(exchange)
|
||||
exchange = []
|
||||
exchange.append(message)
|
||||
if exchange:
|
||||
exchanges.append(exchange)
|
||||
|
||||
units: list[list[dict[str, str]]] = []
|
||||
for exchange in exchanges:
|
||||
if _message_tokens(exchange) <= max_tokens:
|
||||
units.append(exchange)
|
||||
continue
|
||||
index = 0
|
||||
while index < len(exchange):
|
||||
message = exchange[index]
|
||||
if _is_agent_assignment(message) and index + 1 < len(exchange) and _is_agent_response(exchange[index + 1]):
|
||||
units.append(exchange[index : index + 2])
|
||||
index += 2
|
||||
else:
|
||||
units.append([message])
|
||||
index += 1
|
||||
|
||||
bounded_units: list[list[dict[str, str]]] = []
|
||||
for unit in units:
|
||||
if _message_tokens(unit) <= max_tokens:
|
||||
bounded_units.append(unit)
|
||||
continue
|
||||
for message in unit:
|
||||
remaining = message["content"]
|
||||
while remaining:
|
||||
low, high = 0, len(remaining)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
if low == 0:
|
||||
raise ValueError("Extraction token budget cannot fit a message")
|
||||
bounded_units.append([{**message, "content": remaining[:low]}])
|
||||
remaining = remaining[low:]
|
||||
|
||||
batches: list[list[dict[str, str]]] = []
|
||||
batch: list[dict[str, str]] = []
|
||||
for unit in bounded_units:
|
||||
candidate = [*batch, *unit]
|
||||
if batch and _message_tokens(candidate) > max_tokens:
|
||||
batches.append(batch)
|
||||
batch = list(unit)
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
@@ -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.4",
|
||||
"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
|
||||
|
||||
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
|
||||
number of completed flushes, not a pending count), and the doctor check
|
||||
results. If doctor reports an authentication failure (401 / invalid key), say
|
||||
clearly that the Mem0 API key is invalid or expired and that memories are NOT
|
||||
being created. Never report an auth failure as "no memories found". Suggest
|
||||
reinstalling with `--config api_key=...` in that case.
|
||||
being created. Never report an auth failure as "no memories found". When the
|
||||
key is missing or invalid, suggest updating the plugin's API key setting,
|
||||
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
|
||||
Mem0 CLI saves in `~/.mem0/config.json`).
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -30,9 +31,13 @@ def isolated_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user")
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("PLUGIN_OPTION_API_KEY", raising=False)
|
||||
monkeypatch.delenv("PLUGIN_OPTION_USER_ID", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
|
||||
# The 0.2.x plugin exports these into every hooked shell; without this the
|
||||
# suite fails for anyone running it inside a session with that plugin active.
|
||||
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
|
||||
@@ -2306,8 +2311,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 +3465,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")
|
||||
@@ -3744,6 +3754,71 @@ def test_stale_cached_api_key_is_cleared_when_config_is_removed(
|
||||
assert memory_core.clear_stale_api_key_cache() is False
|
||||
|
||||
|
||||
def _mem0_cli_init(home: Path, config: object) -> None:
|
||||
(home / ".mem0").mkdir(parents=True, exist_ok=True)
|
||||
(home / ".mem0" / "config.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
|
||||
def test_api_key_falls_back_to_the_mem0_cli_config(isolated_env):
|
||||
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": " m0-cli-key\n"}})
|
||||
|
||||
assert memory_core.api_key() == "m0-cli-key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[{"platform": {}}, {"platform": "m0-oops"}, {"platform": {"api_key": 42}}, ["m0-list"]],
|
||||
)
|
||||
def test_malformed_mem0_cli_config_reads_as_no_key(isolated_env, config):
|
||||
_mem0_cli_init(isolated_env / "home", config)
|
||||
|
||||
assert memory_core.api_key() == ""
|
||||
|
||||
|
||||
def test_unreadable_mem0_cli_config_reads_as_no_key(isolated_env):
|
||||
(isolated_env / "home" / ".mem0").mkdir(parents=True)
|
||||
(isolated_env / "home" / ".mem0" / "config.json").write_text("{not json", encoding="utf-8")
|
||||
|
||||
assert memory_core.api_key() == ""
|
||||
|
||||
|
||||
def test_plugin_configured_key_wins_over_the_mem0_cli_config(isolated_env, monkeypatch):
|
||||
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
|
||||
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "m0-plugin-key")
|
||||
assert memory_core.cache_plugin_api_key() is True
|
||||
assert memory_core.api_key() == "m0-plugin-key"
|
||||
|
||||
monkeypatch.delenv("PLUGIN_OPTION_API_KEY")
|
||||
assert memory_core.api_key() == "m0-plugin-key"
|
||||
|
||||
|
||||
def test_unexpanded_host_placeholder_is_never_used_as_the_api_key(isolated_env, monkeypatch):
|
||||
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "${api_key}")
|
||||
|
||||
assert memory_core.cache_plugin_api_key() is False
|
||||
assert not (isolated_env / "data" / "api-key").exists()
|
||||
assert memory_core.api_key() == ""
|
||||
|
||||
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
|
||||
assert memory_core.api_key() == "m0-cli-key"
|
||||
|
||||
|
||||
def test_placeholder_cached_by_an_older_plugin_is_ignored(isolated_env):
|
||||
(isolated_env / "data").mkdir()
|
||||
(isolated_env / "data" / "api-key").write_text("${api_key}", encoding="utf-8")
|
||||
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
|
||||
|
||||
assert memory_core.api_key() == "m0-cli-key"
|
||||
|
||||
|
||||
def test_unexpanded_placeholder_plugin_options_fall_back(isolated_env, monkeypatch):
|
||||
monkeypatch.setenv("PLUGIN_OPTION_USER_ID", "${user_id}")
|
||||
monkeypatch.setenv("PLUGIN_OPTION_TOP_K", "${top_k}")
|
||||
|
||||
assert memory_core.user_id() == "test-user"
|
||||
assert memory_core._plugin_option("top_k") == ""
|
||||
|
||||
|
||||
def _big_batch_messages() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "A" * 20000},
|
||||
@@ -4327,7 +4402,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
|
||||
@@ -23,6 +24,8 @@ def isolated_env(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MEM0_API_URL", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@@ -198,9 +201,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 +215,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 +267,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 +420,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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user