Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f7134082 | ||
|
|
a8d3634312 | ||
|
|
1a5c7ad28c | ||
|
|
4e38b057fd | ||
|
|
3362999095 | ||
|
|
012cd32c3a | ||
|
|
e4e0307ae6 | ||
|
|
84bf468176 | ||
|
|
f135cb9949 | ||
|
|
f5220ff8d4 | ||
|
|
0df3e4b87d |
@@ -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.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.2",
|
||||
"description": "Cross-session memory and token savings for coding agents.",
|
||||
"homepage": "https://mem0.ai",
|
||||
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
|
||||
|
||||
@@ -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__,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
seo:
|
||||
title: "API Reference Overview - Mem0"
|
||||
title: "API Reference Overview"
|
||||
sidebarTitle: "Overview"
|
||||
icon: "terminal"
|
||||
iconType: "solid"
|
||||
description: "REST APIs for memory management, search, and entity operations"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 'Delete Memory'
|
||||
seo:
|
||||
title: "Delete Memory API Endpoint - Mem0"
|
||||
title: "Delete Memory API Endpoint"
|
||||
sidebarTitle: "Delete Memory"
|
||||
description: "Delete a single memory by its unique memory ID from the Mem0 platform using the DELETE endpoint."
|
||||
openapi: delete /v1/memories/{memory_id}/
|
||||
---
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 'Update Memory'
|
||||
seo:
|
||||
title: "Update Memory API Endpoint - Mem0"
|
||||
title: "Update Memory API Endpoint"
|
||||
sidebarTitle: "Update Memory"
|
||||
description: "Update the content, metadata, timestamp, or expiration date of a single memory by its unique ID using the PUT endpoint."
|
||||
openapi: put /v1/memories/{memory_id}/
|
||||
---
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 'Add Member'
|
||||
seo:
|
||||
title: "Add Organization Member API Endpoint - Mem0"
|
||||
title: "Add Organization Member API Endpoint"
|
||||
sidebarTitle: "Add Member"
|
||||
description: "Add a new member to an organization with a specified role such as READER or OWNER access level."
|
||||
openapi: post /api/v1/orgs/organizations/{org_id}/members/
|
||||
---
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 'Get Members'
|
||||
seo:
|
||||
title: "Get Organization Members API Endpoint - Mem0"
|
||||
title: "Get Organization Members API Endpoint"
|
||||
sidebarTitle: "Get Members"
|
||||
description: "Retrieve a list of all members belonging to a specific organization on the Mem0 platform."
|
||||
openapi: get /api/v1/orgs/organizations/{org_id}/members/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Generate Profiles'
|
||||
description: "Start one generation: sample a few entities, or build one for a single entity."
|
||||
openapi: post /v2/profiles/jobs/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Get Generation Job'
|
||||
description: "Read the progress of a generation, and whether it finished."
|
||||
openapi: get /v2/profiles/jobs/{job_id}/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Get Profile Settings'
|
||||
description: "Retrieve the profile schema, custom instructions, and enabled flag for the current project."
|
||||
openapi: get /v2/profiles/settings/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Get Profile'
|
||||
description: "Retrieve the structured profile for a user, with a status describing whether generation has completed."
|
||||
openapi: get /v2/entities/{entity_type}/{entity_id}/profile/
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: 'Update Profile Settings'
|
||||
description: "Set the JSON Schema, custom instructions, or enabled flag that control profile generation for the project."
|
||||
openapi: post /v2/profiles/settings/
|
||||
---
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 'Add Member'
|
||||
seo:
|
||||
title: "Add Project Member API Endpoint - Mem0"
|
||||
title: "Add Project Member API Endpoint"
|
||||
sidebarTitle: "Add Member"
|
||||
description: "Add a new member to a project with a specified role such as READER or OWNER access level."
|
||||
openapi: post /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/
|
||||
---
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 'Get Members'
|
||||
seo:
|
||||
title: "Get Project Members API Endpoint - Mem0"
|
||||
title: "Get Project Members API Endpoint"
|
||||
sidebarTitle: "Get Members"
|
||||
description: "Retrieve a list of all members belonging to a specific project on the Mem0 platform."
|
||||
openapi: get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/
|
||||
---
|
||||
+88
-41
@@ -7,10 +7,11 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-09-23" description="v2.1.0">
|
||||
<Update label="2026-09-18" description="v2.1.0">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add User Profiles to `MemoryClient` and `AsyncMemoryClient`: `get_profile()`, `generate_profile()`, `get_profile_settings()`, `update_profile_settings()`, `sample_profiles()`, and `get_profile_job()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
|
||||
**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>
|
||||
|
||||
@@ -1234,10 +1235,11 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-09-23" description="v3.2.0">
|
||||
<Update label="2026-09-18" description="v3.2.0">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add User Profiles to `MemoryClient`: `getProfile()`, `generateProfile()`, `getProfileSettings()`, `updateProfileSettings()`, `sampleProfiles()`, and `getProfileJob()`. A profile is a structured, always-current JSON summary of one user, shaped by a JSON Schema you configure per project and filled by an LLM from that user's memories. Generation is asynchronous, and every job POST carries an `Idempotency-Key` so a retry reuses the job instead of paying twice ([#7340](https://github.com/mem0ai/mem0/pull/7340))
|
||||
**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>
|
||||
|
||||
@@ -1878,6 +1880,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:**
|
||||
@@ -2090,7 +2099,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)
|
||||
@@ -2385,9 +2394,14 @@ 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-18" description="Claude Code plugin v0.3.2">
|
||||
|
||||
Sidekick is now available only in Claude Code, with Sonnet, worktree isolation, and parent memories.
|
||||
**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>
|
||||
|
||||
@@ -2410,9 +2424,14 @@ 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-18" description="Cursor plugin v0.3.2">
|
||||
|
||||
Removes Sidekick and its start/stop hooks. Memory capture, search, and six skills remain available.
|
||||
**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>
|
||||
|
||||
@@ -2435,9 +2454,14 @@ 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-18" description="Codex plugin v0.3.2">
|
||||
|
||||
Renames shared tracking to use subagent terminology. Native subagent memory support remains available.
|
||||
**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>
|
||||
|
||||
@@ -2457,32 +2481,16 @@ 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-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:**
|
||||
@@ -2581,9 +2589,14 @@ 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-18" description="Antigravity plugin v0.3.2">
|
||||
|
||||
Removes Sidekick. Memory capture, search, and six skills remain available.
|
||||
**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>
|
||||
|
||||
@@ -2679,9 +2692,14 @@ 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-18" description="Kimi Code plugin v0.3.2">
|
||||
|
||||
Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skills remain available.
|
||||
**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>
|
||||
|
||||
@@ -2719,6 +2737,14 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
|
||||
|
||||
<Tab title="OpenClaw">
|
||||
|
||||
<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:**
|
||||
@@ -3007,6 +3033,13 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
|
||||
|
||||
<Tab title="Pi Agent">
|
||||
|
||||
<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:**
|
||||
@@ -3120,6 +3153,13 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
|
||||
|
||||
<Tab title="DeepSeek Harness">
|
||||
|
||||
<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:**
|
||||
@@ -3164,6 +3204,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:**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Configurations
|
||||
seo:
|
||||
title: "Embedder Configuration Reference - Mem0"
|
||||
title: "Embedder Configuration Reference"
|
||||
sidebarTitle: "Configurations"
|
||||
description: "Reference for embedder configuration options in Mem0, including provider selection and model settings."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: AWS Bedrock
|
||||
seo:
|
||||
title: "AWS Bedrock as Embedding Provider - Mem0"
|
||||
title: "AWS Bedrock as Embedding Provider"
|
||||
sidebarTitle: "AWS Bedrock"
|
||||
description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Azure OpenAI
|
||||
seo:
|
||||
title: "Azure OpenAI as Embedding Provider - Mem0"
|
||||
title: "Azure OpenAI as Embedding Provider"
|
||||
sidebarTitle: "Azure OpenAI"
|
||||
description: "Configure Azure OpenAI as an embedding provider in Mem0 with API key, deployment, and endpoint settings."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Google AI
|
||||
seo:
|
||||
title: "Google AI as Embedding Provider - Mem0"
|
||||
title: "Google AI as Embedding Provider"
|
||||
sidebarTitle: "Google AI"
|
||||
description: "Configure Google AI as an embedding provider in Mem0 using Gemini models and the GOOGLE_API_KEY variable."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: LangChain
|
||||
seo:
|
||||
title: "LangChain as Embedding Provider - Mem0"
|
||||
title: "LangChain as Embedding Provider"
|
||||
sidebarTitle: "LangChain"
|
||||
description: "Use LangChain as an embedding provider in Mem0 to access a wide range of models through a unified interface."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "LM Studio"
|
||||
seo:
|
||||
title: "LM Studio as Embedding Provider - Mem0"
|
||||
title: "LM Studio as Embedding Provider"
|
||||
sidebarTitle: "LM Studio"
|
||||
description: "Configure LM Studio as an embedding provider in Mem0 for local embedding generation with models like nomic-embed-text."
|
||||
---
|
||||
You can use embedding models from LM Studio to run Mem0 locally.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Ollama"
|
||||
seo:
|
||||
title: "Ollama as Embedding Provider - Mem0"
|
||||
title: "Ollama as Embedding Provider"
|
||||
sidebarTitle: "Ollama"
|
||||
description: "Configure Ollama as an embedding provider in Mem0 to generate embeddings locally using open-source models."
|
||||
---
|
||||
You can use embedding models from Ollama to run Mem0 locally.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: OpenAI
|
||||
seo:
|
||||
title: "OpenAI as Embedding Provider - Mem0"
|
||||
title: "OpenAI as Embedding Provider"
|
||||
sidebarTitle: "OpenAI"
|
||||
description: "Configure OpenAI as an embedding provider in Mem0 using models like text-embedding-3-large for vector generation."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Together
|
||||
seo:
|
||||
title: "Together AI as Embedding Provider - Mem0"
|
||||
title: "Together AI as Embedding Provider"
|
||||
sidebarTitle: "Together"
|
||||
description: "Configure Together AI as an embedding provider in Mem0 with support for 1024-dimensional embedding models."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Overview
|
||||
seo:
|
||||
title: "Embedding Providers Overview - Mem0"
|
||||
title: "Embedding Providers Overview"
|
||||
sidebarTitle: Overview
|
||||
description: "Overview of all supported embedding model providers in Mem0, including OpenAI, Azure, Ollama, and more."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Configurations
|
||||
seo:
|
||||
title: "LLM Configuration Reference - Mem0"
|
||||
title: "LLM Configuration Reference"
|
||||
sidebarTitle: "Configurations"
|
||||
description: "Reference for LLM configuration options in Mem0 for Python and TypeScript, including value precedence rules."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: AWS Bedrock
|
||||
seo:
|
||||
title: "AWS Bedrock as LLM Provider - Mem0"
|
||||
title: "AWS Bedrock as LLM Provider"
|
||||
sidebarTitle: "AWS Bedrock"
|
||||
description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authentication and Claude model support."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Azure OpenAI
|
||||
seo:
|
||||
title: "Azure OpenAI as LLM Provider - Mem0"
|
||||
title: "Azure OpenAI as LLM Provider"
|
||||
sidebarTitle: "Azure OpenAI"
|
||||
description: "Configure Azure OpenAI as an LLM provider in Mem0 with Azure Identity authentication and deployment settings."
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: DeepSeek
|
||||
description: "Configure DeepSeek as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
|
||||
---
|
||||
|
||||
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to "https://api.deepseek.com").
|
||||
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to `https://api.deepseek.com`).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Google AI
|
||||
seo:
|
||||
title: "Google AI as LLM Provider - Mem0"
|
||||
title: "Google AI as LLM Provider"
|
||||
sidebarTitle: "Google AI"
|
||||
description: "Configure Google Gemini as an LLM provider in Mem0 using the google.genai SDK and GOOGLE_API_KEY variable."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: LangChain
|
||||
seo:
|
||||
title: "LangChain as LLM Provider - Mem0"
|
||||
title: "LangChain as LLM Provider"
|
||||
sidebarTitle: "LangChain"
|
||||
description: "Use LangChain as an LLM provider in Mem0 to integrate with various chat models through a unified interface."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: LM Studio
|
||||
seo:
|
||||
title: "LM Studio as LLM Provider - Mem0"
|
||||
title: "LM Studio as LLM Provider"
|
||||
sidebarTitle: "LM Studio"
|
||||
description: "Configure LM Studio as an LLM provider in Mem0 for running local language models via an OpenAI-compatible API."
|
||||
---
|
||||
|
||||
@@ -78,7 +77,7 @@ m.add(messages, user_id="alice123", metadata={"category": "movies"})
|
||||
To use LM Studio, you need to:
|
||||
1. Download and install [LM Studio](https://lmstudio.ai/)
|
||||
2. Start a local server from the "Server" tab
|
||||
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually http://localhost:1234/v1)
|
||||
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually `http://localhost:1234/v1`)
|
||||
</Note>
|
||||
|
||||
## Config
|
||||
|
||||
@@ -3,7 +3,7 @@ title: MiniMax
|
||||
description: "Configure MiniMax as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
|
||||
---
|
||||
|
||||
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to "https://api.minimax.io/v1").
|
||||
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to `https://api.minimax.io/v1`).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Ollama
|
||||
seo:
|
||||
title: "Ollama as LLM Provider - Mem0"
|
||||
title: "Ollama as LLM Provider"
|
||||
sidebarTitle: "Ollama"
|
||||
description: "Configure Ollama as an LLM provider in Mem0 for running local language models with tool-calling support."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: OpenAI
|
||||
seo:
|
||||
title: "OpenAI as LLM Provider - Mem0"
|
||||
title: "OpenAI as LLM Provider"
|
||||
sidebarTitle: "OpenAI"
|
||||
description: "Configure OpenAI as an LLM provider in Mem0 with support for GPT models and Openrouter compatibility."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Together
|
||||
seo:
|
||||
title: "Together AI as LLM Provider - Mem0"
|
||||
title: "Together AI as LLM Provider"
|
||||
sidebarTitle: "Together"
|
||||
description: "Configure Together AI as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: xAI
|
||||
seo:
|
||||
title: "xAI Grok as LLM Provider - Mem0"
|
||||
title: "xAI Grok as LLM Provider"
|
||||
sidebarTitle: "xAI"
|
||||
description: "Configure xAI Grok models as an LLM provider in Mem0 with API key setup and usage examples."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Overview
|
||||
seo:
|
||||
title: "LLM Providers Overview - Mem0"
|
||||
title: "LLM Providers Overview"
|
||||
sidebarTitle: Overview
|
||||
description: "Overview of all supported LLM providers in Mem0, including OpenAI, Anthropic, Groq, Ollama, and more."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Overview
|
||||
seo:
|
||||
title: "Reranker Providers Overview - Mem0"
|
||||
title: "Reranker Providers Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: 'Pick the right reranker path to boost Mem0 search relevance.'
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Configurations
|
||||
seo:
|
||||
title: "Vector Store Configuration Reference - Mem0"
|
||||
title: "Vector Store Configuration Reference"
|
||||
sidebarTitle: "Configurations"
|
||||
description: "Reference for vector database configuration options in Mem0, including provider selection and connection settings."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: LangChain
|
||||
seo:
|
||||
title: "LangChain as Vector Store Provider - Mem0"
|
||||
title: "LangChain as Vector Store Provider"
|
||||
sidebarTitle: "LangChain"
|
||||
description: "Use LangChain as a unified vector store provider in Mem0 to access multiple vector databases through one interface."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Overview
|
||||
seo:
|
||||
title: "Vector Store Providers Overview - Mem0"
|
||||
title: "Vector Store Providers Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Overview
|
||||
seo:
|
||||
title: "Cookbooks and Tutorials - Mem0"
|
||||
title: "Cookbooks and Tutorials"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Browse cookbook examples and tutorials for building AI applications with Mem0, from companion chatbots to AI agents."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Delete Memory
|
||||
seo:
|
||||
title: "Delete Memory Operation - Mem0"
|
||||
title: "Delete Memory Operation"
|
||||
sidebarTitle: "Delete Memory"
|
||||
description: Remove memories from Mem0 either individually, in bulk, or via filters.
|
||||
icon: "trash"
|
||||
iconType: "solid"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Update Memory
|
||||
seo:
|
||||
title: "Update Memory Operation - Mem0"
|
||||
title: "Update Memory Operation"
|
||||
sidebarTitle: "Update Memory"
|
||||
description: Modify an existing memory by updating its content or metadata.
|
||||
icon: "pen-to-square"
|
||||
iconType: "solid"
|
||||
|
||||
+1
-12
@@ -41,6 +41,7 @@
|
||||
"pages": [
|
||||
"platform/quickstart",
|
||||
"platform/overview",
|
||||
"platform/copilot",
|
||||
"platform/agent-signup",
|
||||
"vibecoding",
|
||||
"platform/cli",
|
||||
@@ -71,7 +72,6 @@
|
||||
"pages": [
|
||||
"platform/features/v2-memory-filters",
|
||||
"platform/features/entity-scoped-memory",
|
||||
"platform/features/user-profiles",
|
||||
"platform/features/graph-memory",
|
||||
"platform/features/async-client",
|
||||
"platform/features/multimodal-support",
|
||||
@@ -512,17 +512,6 @@
|
||||
"api-reference/entities/delete-user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Profiles",
|
||||
"icon": "id-card",
|
||||
"pages": [
|
||||
"api-reference/profiles/get-profile",
|
||||
"api-reference/profiles/get-profile-settings",
|
||||
"api-reference/profiles/update-profile-settings",
|
||||
"api-reference/profiles/generate-profiles",
|
||||
"api-reference/profiles/get-profile-job"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Organizations",
|
||||
"icon": "building",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Overview
|
||||
seo:
|
||||
title: "Integrations Overview - Mem0"
|
||||
title: "Integrations Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Overview of Mem0 integrations with popular AI frameworks and tools for persistent memory and context management."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: AWS Bedrock
|
||||
seo:
|
||||
title: "AWS Bedrock Integration with Mem0"
|
||||
title: "AWS Bedrock Integration"
|
||||
sidebarTitle: "AWS Bedrock"
|
||||
description: "Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage."
|
||||
---
|
||||
|
||||
|
||||
@@ -172,6 +172,36 @@ claude plugin update mem0@mem0-plugins --scope user
|
||||
| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. |
|
||||
| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` |
|
||||
|
||||
## Telemetry
|
||||
|
||||
The plugin sends usage events (which hook ran, timing, result counts, failure
|
||||
types) so Mem0 can see what's used and what's breaking.
|
||||
|
||||
These events are **not anonymous**. When an API key is configured, which
|
||||
installing the plugin requires, they are sent under your Mem0 account email,
|
||||
the same way the Python SDK and the CLI attribute theirs. Without a key they
|
||||
are sent under a random per-machine id.
|
||||
|
||||
Each event carries the event name, the plugin version, the harness it ran in,
|
||||
your OS and Python version, and per-event properties describing what happened:
|
||||
timings, counts, coarse outcome and failure labels, and which model was
|
||||
configured. Repository and session identifiers are hashed with a random salt
|
||||
generated on your machine, so they cannot be linked back to a repository name
|
||||
or path.
|
||||
|
||||
The exact set is enforced in code rather than by this list: every property is
|
||||
filtered through a denylist of sensitive keys and credential-shaped values are
|
||||
redacted before anything is sent.
|
||||
|
||||
Prompts, memory text, queries, file paths, repository names, and API keys are
|
||||
never sent.
|
||||
|
||||
Turn it off:
|
||||
|
||||
```bash
|
||||
export MEM0_TELEMETRY=false
|
||||
```
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
|
||||
Detailed MCP configuration for all clients
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Codex
|
||||
description: "Add persistent memory to OpenAI Codex with automatic capture, automatic recall, a search tool, and six memory skills."
|
||||
---
|
||||
|
||||
Add persistent memory to [**OpenAI Codex**](https://openai.com/index/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session.
|
||||
Add persistent memory to [**OpenAI Codex**](https://openai.com/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session.
|
||||
|
||||
<Info>Current plugin version: `0.3.1`.</Info>
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ Both tools also accept optional per-call `userId`, `agentId`, and `runId` params
|
||||
|
||||
## Telemetry
|
||||
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to this integration. Anonymous usage events include operation names, durations, result counts, and coarse failure kinds. Queries, memory text, entity IDs, and API keys are never included. Set `MEM0_TELEMETRY=false` to opt out.
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to this integration. Usage events include operation names, durations, result counts, and coarse failure kinds. They are **not anonymous**: when an API key is configured they are sent under your Mem0 account email, the same way the SDK attributes its own. Queries, memory text, entity IDs, and API keys are never included. Set `MEM0_TELEMETRY=false` to opt out.
|
||||
|
||||
<Note>
|
||||
This plugin is a developer preview and tracks the evolving DeepSeek Harness plugin API.
|
||||
|
||||
@@ -23,7 +23,7 @@ npm install -g flowise
|
||||
npx flowise start
|
||||
```
|
||||
|
||||
2. Access to the Flowise UI at http://localhost:3000
|
||||
2. Access to the Flowise UI at `http://localhost:3000`
|
||||
3. Basic familiarity with [Flowise's LLM orchestration](https://flowiseai.com/#features) concepts
|
||||
|
||||
## Setup and Configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Hermes Agent
|
||||
description: "Add long-term memory to Hermes agents with Mem0: managed Platform, a self-hosted server, or fully local OSS — with current-turn recall and background fact extraction."
|
||||
description: "Add long-term memory to Hermes agents using Mem0 Platform, a self-hosted server, or local OSS mode with background fact extraction."
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Langchain
|
||||
seo:
|
||||
title: "LangChain Integration with Mem0"
|
||||
title: "LangChain Integration"
|
||||
sidebarTitle: "Langchain"
|
||||
description: "Build personalized AI agents using LangChain for conversation flow and Mem0 for long-term memory retention."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: n8n
|
||||
seo:
|
||||
title: "n8n Integration with Mem0"
|
||||
title: "n8n Integration"
|
||||
sidebarTitle: "n8n"
|
||||
description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required."
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+1
-6
@@ -171,6 +171,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
|
||||
- [Introduction](https://docs.mem0.ai/introduction) [Both]: Use when the user wants a one-page overview of how memory fits between the LLM and the app.
|
||||
- [Vibe Code with Mem0](https://docs.mem0.ai/vibecoding) [Both]: Use when the user is in Claude Code, Cursor, or Windsurf and wants memory wired into their editor.
|
||||
- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, hosted API, dashboard.
|
||||
- [Mem0 Copilot](https://docs.mem0.ai/platform/copilot) [Platform]: Use when inspecting project memories, reviewing configuration changes, or testing extraction in the dashboard.
|
||||
- [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup) [Platform]: Use when an AI agent needs to mint a Mem0 API key autonomously - four commands, no email or dashboard, human claims ownership later.
|
||||
- [Platform vs Open Source](https://docs.mem0.ai/platform/platform-vs-oss) [Both]: Use when the user is deciding between managed and self-hosted.
|
||||
- [Platform Quickstart](https://docs.mem0.ai/platform/quickstart) [Platform]: Use for the first Platform integration - API key plus `MemoryClient.add/search`.
|
||||
@@ -197,7 +198,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
|
||||
### Features - Essential
|
||||
- [V2 Memory Filters](https://docs.mem0.ai/platform/features/v2-memory-filters) [Platform]: Use when compound filters (AND/OR on metadata, entity, time) are needed at search.
|
||||
- [Entity-Scoped Memory](https://docs.mem0.ai/platform/features/entity-scoped-memory) [Platform]: Use when partitioning memories by user, agent, app, or run.
|
||||
- [Profiles](https://docs.mem0.ai/platform/features/user-profiles) [Platform]: Use when a structured always-current summary of a user is needed in one read, instead of searching their memories.
|
||||
- [Graph Memory](https://docs.mem0.ai/platform/features/graph-memory) [Platform]: Use when connecting facts across memories through shared entities for entity-centric or multi-hop questions.
|
||||
- [Async Client](https://docs.mem0.ai/platform/features/async-client) [Platform]: Use when the app issues many concurrent Mem0 calls and needs non-blocking I/O.
|
||||
- [Multimodal Support](https://docs.mem0.ai/platform/features/multimodal-support) [Platform]: Use when storing images or PDFs as memory input.
|
||||
@@ -363,11 +363,6 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key).
|
||||
### Entities
|
||||
- [Get Users](https://docs.mem0.ai/api-reference/entities/get-users) [Platform]: Use when listing users, agents, or apps known to a project.
|
||||
- [Delete User](https://docs.mem0.ai/api-reference/entities/delete-user) [Platform]: Use when removing an entity and all its memories.
|
||||
- [Get Profile](https://docs.mem0.ai/api-reference/profiles/get-profile) [Platform]: Use when reading a user's structured profile and branching on its generation status.
|
||||
- [Get Profile Settings](https://docs.mem0.ai/api-reference/profiles/get-profile-settings) [Platform]: Use when checking the project's profile schema, instructions, or enabled flag.
|
||||
- [Update Profile Settings](https://docs.mem0.ai/api-reference/profiles/update-profile-settings) [Platform]: Use when defining or changing the JSON Schema that shapes profiles for a project.
|
||||
- [Generate Profiles](https://docs.mem0.ai/api-reference/profiles/generate-profiles) [Platform]: Use when building profiles now: a sample of ten, or one entity.
|
||||
- [Get Generation Job](https://docs.mem0.ai/api-reference/profiles/get-profile-job) [Platform]: Use when checking how far a generation has got, and whether it finished.
|
||||
|
||||
### Organizations
|
||||
- [Create Organization](https://docs.mem0.ai/api-reference/organization/create-org) [Platform]: Use when setting up a new org.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Custom Instructions
|
||||
seo:
|
||||
title: "Open Source Custom Instructions - Mem0"
|
||||
title: "Open Source Custom Instructions"
|
||||
sidebarTitle: "Custom Instructions"
|
||||
description: Tailor fact extraction so Mem0 stores only the details you care about.
|
||||
icon: "wand-magic-sparkles"
|
||||
---
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Multimodal Support
|
||||
seo:
|
||||
title: "Open Source Multimodal Support - Mem0"
|
||||
title: "Open Source Multimodal Support"
|
||||
sidebarTitle: "Multimodal Support"
|
||||
description: Capture and recall memories from both text and images.
|
||||
icon: "image"
|
||||
---
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
seo:
|
||||
title: "Open Source Features Overview - Mem0"
|
||||
title: "Open Source Features Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Self-hosting features that extend Mem0 beyond basic memory storage"
|
||||
icon: "list"
|
||||
---
|
||||
|
||||
@@ -56,7 +56,7 @@ The Mem0 REST API server exposes every OSS memory operation over HTTP. Run it al
|
||||
make bootstrap # starts Compose, creates an admin, issues the first API key
|
||||
```
|
||||
|
||||
Or to start the stack only and finish setup via the browser wizard at http://localhost:3000:
|
||||
Or to start the stack only and finish setup via the browser wizard at `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
seo:
|
||||
title: "Mem0 Open Source Overview"
|
||||
title: "Open Source Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Self-host Mem0 with full control over your infrastructure and data"
|
||||
icon: "house"
|
||||
---
|
||||
|
||||
+1
-507
@@ -8070,512 +8070,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/entities/{entity_type}/{entity_id}/profile/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_read",
|
||||
"summary": "Get an entity's profile",
|
||||
"description": "Return the memory profile for one user.\n\nGeneration is asynchronous, so a known entity that has no profile yet is a normal 200 carrying a `status`. A 404 means only that no such entity exists.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "entity_type",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"description": "The kind of entity that carries the profile."
|
||||
},
|
||||
{
|
||||
"name": "entity_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The entity's id, as supplied when the memory was added."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The profile envelope.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "The generated profile, shaped by the project's schema. Empty unless status is succeeded."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"succeeded",
|
||||
"pending",
|
||||
"failed",
|
||||
"not_enabled",
|
||||
"insufficient_data"
|
||||
],
|
||||
"description": "Generation state. Branch on this rather than on an empty profile."
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"entity_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"generation_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unsupported entity type."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such entity in this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/profiles/settings/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_settings_read",
|
||||
"summary": "Get profile settings",
|
||||
"description": "Return the profile settings for the project the API key is scoped to.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current settings.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether profile generation runs for this project. Project-wide."
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"estimates": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"samples": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"full_rebuild": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_settings_update",
|
||||
"summary": "Update profile settings",
|
||||
"description": "Update the project's profile settings. Only the fields present in the body are written, so one setting can change without re-sending the others.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether profile generation runs for this project. Project-wide."
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step. Send null to clear it."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Settings as stored after the update.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether profile generation runs for this project. Project-wide."
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"nullable": true,
|
||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extra guidance for the extraction step."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"estimates": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"samples": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"full_rebuild": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The schema is not a valid profile schema."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/profiles/jobs/": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_create_job",
|
||||
"summary": "Generate profiles",
|
||||
"description": "Start one generation. `operation` says what to build:\n\n- `sample` — up to 10 real entities, so a schema can be judged before it is used widely. These are real profiles: they are saved to those entities and count toward usage.\n- `trigger` — one entity, named by `entity_id`.\n\nSend an `Idempotency-Key` header. Replaying the same key returns the same job instead of charging twice. Poll `status_url` from the response until the status is terminal.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "Idempotency-Key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"description": "Makes a retry safe: the same key returns the same job."
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"operation",
|
||||
"entity_type"
|
||||
],
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sample",
|
||||
"trigger"
|
||||
],
|
||||
"description": "What to generate. Optional only when `entity_id` is set, which means `trigger`."
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"entity_id": {
|
||||
"type": "string",
|
||||
"description": "One entity, for `trigger`."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"description": "How many entities to sample."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Job accepted.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"status_url": {
|
||||
"type": "string",
|
||||
"description": "Poll this. Building the path yourself breaks on a route change."
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_count_reserved": {
|
||||
"type": "integer",
|
||||
"description": "Entities reserved against usage for this job."
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"replayed": {
|
||||
"type": "boolean",
|
||||
"description": "True when an Idempotency-Key returned an existing job."
|
||||
},
|
||||
"sampled": {
|
||||
"type": "integer",
|
||||
"description": "`sample` only."
|
||||
},
|
||||
"entity_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "`sample` only: the entity ids picked. Read each with `GET /v2/entities/user/{entity_id}/profile/`."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unknown or missing `operation`, or profiles are not configured."
|
||||
},
|
||||
"402": {
|
||||
"description": "Payment required."
|
||||
},
|
||||
"409": {
|
||||
"description": "A job is already running, or the Idempotency-Key was used for a different request. Branch on `error.code`."
|
||||
},
|
||||
"429": {
|
||||
"description": "Cooldown. `retry_after_seconds` sits inside `error`."
|
||||
},
|
||||
"503": {
|
||||
"description": "`jobs_unavailable` — generation is switched off for this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/profiles/jobs/{job_id}/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"profiles"
|
||||
],
|
||||
"operationId": "profiles_get_job",
|
||||
"summary": "Read a generation job",
|
||||
"description": "The job nests under `job`. `total` is null until `enumeration_complete`, and `completed` is `succeeded + failed + skipped`.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "job_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The job.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"QUEUED",
|
||||
"RUNNING",
|
||||
"SUCCEEDED",
|
||||
"PARTIALLY_SUCCEEDED",
|
||||
"FAILED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
},
|
||||
"enumeration_complete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"completed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"succeeded": {
|
||||
"type": "integer"
|
||||
},
|
||||
"failed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such job in this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -9494,4 +8988,4 @@
|
||||
}
|
||||
},
|
||||
"x-original-swagger-version": "2.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: CLI
|
||||
seo:
|
||||
title: "Mem0 CLI for Terminal Memory Management"
|
||||
title: "CLI for Terminal Memory Management"
|
||||
sidebarTitle: "CLI"
|
||||
description: "Manage memories from your terminal, for both humans and AI agents."
|
||||
icon: "terminal"
|
||||
iconType: "solid"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
title: "Mem0 Copilot"
|
||||
description: "Inspect project memories, review configuration changes, and test extraction from the Mem0 dashboard."
|
||||
icon: "message"
|
||||
---
|
||||
|
||||
Copilot is an AI assistant in the [Mem0 dashboard](https://app.mem0.ai/dashboard/copilot). Ask it to inspect stored memories, suggest extraction rules and categories, change project settings, or explain the platform SDKs and APIs.
|
||||
|
||||
You describe the task in chat. Copilot uses tools to read project data or propose changes. In **Review changes** mode, you approve each change before it runs.
|
||||
|
||||
## Start with your project
|
||||
|
||||
1. Sign in to the dashboard and select the organization and project you want to work on.
|
||||
2. Open **Copilot** in the sidebar.
|
||||
3. Select **Review changes** below the message box.
|
||||
4. Ask: “Show my current project settings and explain what each one does.”
|
||||
5. Expand an activity card, such as **Read project config**, to see its **Input** and **Result**. Check these results when reviewing an answer or confirming a change.
|
||||
|
||||
You can ask about settings or SDK usage with an empty project. Suggestions based on stored data need enough project history to analyze.
|
||||
|
||||
## Inspect stored memories
|
||||
|
||||
Start with a project overview, then narrow the question:
|
||||
|
||||
| Prompt | What to inspect |
|
||||
| --- | --- |
|
||||
| “Summarize what this project has stored and how it is categorized.” | The memories and category patterns Copilot reads. Check which records support its summary. |
|
||||
| “Analyze the memories for user alice.” | Facts stored for `alice`, their categories, and unwanted information. To investigate a missing fact, also provide the original input. |
|
||||
| “Search alice's memories for dietary preferences.” | Memories relevant to the query within that user's scope. |
|
||||
|
||||
Copilot can list memories across the project. Searching by meaning needs a specific User, Agent, App, or Run ID. Select one in **Scope** or name it in your prompt.
|
||||
|
||||
Memory lists are paginated, and suggestions use samples. Check the activity results to see which records were read. Ask for more pages when you need to inspect the rest.
|
||||
|
||||
### Choose the memory scope
|
||||
|
||||
Open **Scope** below the message box. Select an existing ID or type one and choose it.
|
||||
|
||||
| Control | Identifies |
|
||||
| --- | --- |
|
||||
| **User** | The end user whose memories you want to inspect, such as `alice`. |
|
||||
| **Agent** | An AI agent associated with the memories. |
|
||||
| **App** | An application associated with the memories. |
|
||||
| **Run** | A particular execution or session associated with the memories. |
|
||||
|
||||
A field set to **any** adds no filter for that entity type. **Clear scope** removes the selections. Scope gives Copilot default IDs to use; you can request a different entity in a message. Check the activity's **Input** to confirm which IDs it used. Adding a test memory requires a **User** ID, even when another entity is selected.
|
||||
|
||||
<Note>
|
||||
Scope selects memories, not separate settings. Categories, extraction instructions, memory depth, and multilingual settings apply to the selected project. Category and extraction suggestions also analyze project data, regardless of the selected entity.
|
||||
</Note>
|
||||
|
||||
See [Entity-scoped memory](/platform/features/entity-scoped-memory) for how these IDs organize memories in your application.
|
||||
|
||||
## Review and apply changes
|
||||
|
||||
The mode control below the message box determines when changes run:
|
||||
|
||||
- **Review changes**: Copilot pauses before changing project configuration or adding a test memory. Read the proposal and choose whether to apply it.
|
||||
- **Auto-apply**: Copilot can change settings and add test memories without asking for approval. Check the selected project and scope before using it.
|
||||
|
||||
Ask Copilot to show the current settings before requesting an update. In the approval card, review every listed field. Approving the card applies the whole proposal, including fields you did not edit.
|
||||
|
||||
| Action | Effect |
|
||||
| --- | --- |
|
||||
| **Apply change** | Approve the proposed write. Inspect the subsequent result to confirm it succeeded. |
|
||||
| **Edit** | When offered, edit extraction instructions or category names and descriptions. Choose **Apply with edits** to submit your revision. |
|
||||
| **Discard edits** | Return to the original proposal without applying it. |
|
||||
| **Decline** | Reject this proposal without applying it. |
|
||||
| **Ask for something else** | Enter feedback and choose **Send**. This declines the current proposal and sends your feedback as a new message. Review the next proposal before applying it. |
|
||||
|
||||
Not every field has an inline editor. If a proposal includes both extraction instructions and categories, only the instructions get an editor. The editor cannot apply blank instructions or an empty category list. Use **Ask for something else** to change other fields or request separate proposals.
|
||||
|
||||
<Warning>
|
||||
A generated prompt profile contains separate prompts for extraction and summaries. If your project uses one, saving extraction instructions through Copilot deactivates it. Review the replacement rules and test the affected behavior before using them in production.
|
||||
</Warning>
|
||||
|
||||
## What Copilot cannot do
|
||||
|
||||
Copilot cannot perform these actions, even in **Auto-apply** mode:
|
||||
|
||||
- Create or delete API keys.
|
||||
- Directly edit or delete existing memories.
|
||||
- Export project data.
|
||||
- Invite or remove organization or project members, or change their roles and permissions.
|
||||
- Create or delete organizations or projects.
|
||||
- Change billing details or subscription plans.
|
||||
|
||||
Use the dashboard or the relevant platform API for these tasks. Copilot can explain the steps or point you to documentation, but it cannot carry out the actions.
|
||||
|
||||
Copilot supports the managed Mem0 platform. It does not help configure or use the [self-hosted open-source library](/open-source/overview).
|
||||
|
||||
## Example 1: Stop storing small talk, then test extraction
|
||||
|
||||
This walkthrough uses a project where you have noticed greetings or small talk in stored memories. Use a development project when trying configuration changes, since a test user does not isolate project settings.
|
||||
|
||||
<Steps>
|
||||
<Step title="Inspect the problem">
|
||||
Select the project, set **User** to `alice`, and ask:
|
||||
|
||||
> Analyze the memories for user alice.
|
||||
|
||||
Inspect the returned memories. Identify examples of small talk you want to exclude and useful facts you still want to keep.
|
||||
</Step>
|
||||
<Step title="Request a targeted change">
|
||||
With **Review changes** selected, ask:
|
||||
|
||||
> Update my extraction instructions to ignore greetings and small talk. Keep the existing rules for durable user preferences.
|
||||
|
||||
Check for a **Read project config** activity before reviewing the update. If it is missing, ask Copilot to read the current instructions first. If analysis reports insufficient data, ask it to use the rule you provided. You can also set the rule directly through [Custom instructions](/platform/features/custom-instructions).
|
||||
</Step>
|
||||
<Step title="Review the proposal">
|
||||
Review the full replacement text. Keep existing rules your application needs. For this test, the relevant rules could look like:
|
||||
|
||||
```text
|
||||
Remember the following:
|
||||
|
||||
* The user's dietary preferences and food restrictions.
|
||||
|
||||
Don't remember the following:
|
||||
|
||||
* Greetings and small talk.
|
||||
```
|
||||
|
||||
Use **Edit** to adjust the wording, or **Ask for something else** to request a revision.
|
||||
|
||||
Choose **Apply change** or **Apply with edits**, then inspect the result to confirm the instructions were saved.
|
||||
</Step>
|
||||
<Step title="Add a test conversation">
|
||||
Choose a new **User** ID for this test, such as `copilot-extraction-test-01`, and clear any other entity selections. Ask:
|
||||
|
||||
> Add this as a user message for copilot-extraction-test-01: “Hi! How's your day? I prefer vegetarian meals and avoid peanuts.”
|
||||
|
||||
Review the test-memory proposal and choose **Apply change**.
|
||||
|
||||
<Warning>
|
||||
Adding a test memory writes to the selected project. It is not a dry run. Use a dedicated test user so you can find and remove the test data afterward.
|
||||
</Warning>
|
||||
</Step>
|
||||
<Step title="Wait for extraction, then check the result">
|
||||
Expand **Add test memory** and inspect **Result**. A `PENDING` status means extraction is still running. Use the returned `event_id` with the [Get Event API](/api-reference/events/get-event) to check progress. Wait for `SUCCEEDED` before evaluating the output. If the event is `FAILED`, inspect its details before retrying.
|
||||
|
||||
Then ask:
|
||||
|
||||
> List all memories for user copilot-extraction-test-01. Then search that user's memories for dietary preferences.
|
||||
|
||||
Check that the memories retain the vegetarian preference and peanut restriction, and exclude the greeting. Inspect the full list as well as search results: a relevant search can hide unwanted small-talk records.
|
||||
|
||||
If the output is wrong, describe the mismatch and review another instruction change. Use a new test user for the next attempt so earlier memories do not affect the result. Remove test data afterward through the dashboard or [memory deletion API](/api-reference/memory/delete-memories).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Test with new inputs after changing settings. Updating configuration is not a cleanup operation for existing memories. See [Custom instructions](/platform/features/custom-instructions) for guidance on writing extraction rules.
|
||||
|
||||
## Example 2: Suggest categories and extraction instructions
|
||||
|
||||
These workflows sample the selected project's data. Generating a suggestion does not update settings by itself. Copilot can then propose applying it, which follows the selected review mode. Use **Review changes** to inspect suggestions before they are saved.
|
||||
|
||||
| Workflow | Data used | Example prompt |
|
||||
| --- | --- | --- |
|
||||
| Custom categories | Stored memories, excluding deleted memories. | “Suggest custom categories from this project's memories.” |
|
||||
| Extraction instructions | Successful requests to add conversation data, including requests that produced no memories. | “Compare recent add requests with the extracted memories and suggest better extraction instructions.” |
|
||||
|
||||
Category suggestions identify recurring themes. Review the names, descriptions, and examples. Applying a category list replaces the project's previous list; it does not add to it or re-tag existing memories. See [Custom categories](/platform/features/custom-categories) for project and per-call behavior.
|
||||
|
||||
Extraction suggestions compare conversation inputs with what was extracted. One add request can produce several memories or none, so the number of add requests is different from the number of stored memories. Keep your application's existing requirements when reviewing the proposal.
|
||||
|
||||
Both workflows require enough project data. If a suggestion fails because there is too little data, check the activity's **Result** for the current and required counts. You can still inspect memories, ask SDK/API questions, or provide your own rules instead of requesting a data-based suggestion.
|
||||
|
||||
## Example 3: Adjust detail and language
|
||||
|
||||
Ask Copilot to read the current settings, then request the change you need:
|
||||
|
||||
- **Memory depth** controls the level of detail: **Less**, **Medium**, or **More detailed**. Try “Show my current memory depth, then propose More detailed memories.” For deciding *which facts* to retain, use extraction instructions.
|
||||
- **Multilingual** behavior preserves the user's original language. Try “Enable multilingual behavior so memories preserve the language of the input.” Test it with a new conversation in the language your application uses.
|
||||
|
||||
Both are project settings. Review the proposed values and test a fresh input after applying them. See [Organization and project settings](/api-reference/organizations-projects) for configuration through the API.
|
||||
|
||||
## Example 4: Ask SDK and API questions
|
||||
|
||||
Include your language and the task, for example:
|
||||
|
||||
> Show me how to search memories for user alice with the managed Mem0 Python SDK. Link the documentation you used.
|
||||
|
||||
Copilot can look up the official platform documentation. Check the **Browse mem0 docs** and **Read docs page** activities and open the cited pages. If an answer has no sources, ask for them before using the example. The [Platform quickstart](/platform/quickstart) covers adding and searching memories in code.
|
||||
|
||||
## Resume a conversation and check message limits
|
||||
|
||||
Use **History** to reopen your 50 most recently active chats for the selected project. Chats belong to the person who created them; other project members cannot open them. Use **New chat** to start a separate conversation, or **Delete chat** in history to remove one. Deleting a chat does not undo settings changes or remove test memories.
|
||||
|
||||
Message allowances depend on the organization's plan and are shared across its projects and members. They reset at the start of each calendar month in UTC.
|
||||
|
||||
For plans with a limit, a usage notice appears once 80% of the allowance is used. Below that point, no counter is shown. At the limit, new messages are disabled until the allowance resets or the plan is upgraded. Follow the notice's upgrade link to review plan options.
|
||||
|
||||
Sending feedback through **Ask for something else** counts as a new message. Approving or declining a saved proposal without feedback does not use another message. Test additions and searches also use the platform APIs and remain subject to their normal quotas.
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Custom Instructions
|
||||
seo:
|
||||
title: "Platform Custom Instructions - Mem0"
|
||||
title: "Platform Custom Instructions"
|
||||
sidebarTitle: "Custom Instructions"
|
||||
description: 'Control how Mem0 extracts and stores memories using natural language guidelines'
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Multimodal Support
|
||||
seo:
|
||||
title: "Platform Multimodal Support - Mem0"
|
||||
title: "Platform Multimodal Support"
|
||||
sidebarTitle: "Multimodal Support"
|
||||
description: Integrate images and documents into your interactions with Mem0
|
||||
---
|
||||
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
---
|
||||
title: Profiles
|
||||
description: "Build a structured, always-current summary of each user from their memories, shaped by a JSON Schema you define."
|
||||
---
|
||||
|
||||
# Profiles
|
||||
|
||||
Memories are individual facts. A profile is the summary of all of them for one entity: a single structured object, shaped by a JSON Schema you define, that Mem0 keeps current as new memories arrive.
|
||||
|
||||
Search answers "what did this user say about X". A profile answers "who is this user", in one read, with no query to write.
|
||||
|
||||
<Info>
|
||||
**Use profiles when…**
|
||||
- You want to personalize a first response, before the user says anything in this session.
|
||||
- You need a compact object to drop into a prompt instead of a list of memories.
|
||||
- You want the same fields for every user, so your code can rely on their shape.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
User Profiles are in **beta** and available on request. To enable them for your
|
||||
organization, contact [support@mem0.ai](mailto:support@mem0.ai).
|
||||
</Note>
|
||||
|
||||
## How it works
|
||||
|
||||
1. You define a **schema**: the fields a profile should contain, each with a description.
|
||||
2. Mem0 builds each entity's profile from their memories, and rebuilds it as new memories arrive.
|
||||
3. You read the profile whenever you need it.
|
||||
|
||||
Generation is **asynchronous**. A profile is not ready the instant an entity's first memory lands, so a read tells you where it is with a `status` rather than failing.
|
||||
|
||||
## Define the schema
|
||||
|
||||
The schema is JSON Schema. Every property needs a `description` — that is what tells the model how to fill the field, so a vague description gives a vague profile.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
client.update_profile_settings(
|
||||
enabled=True,
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"communication_style": {
|
||||
"type": "string",
|
||||
"description": "How the user prefers to be addressed: terse, detailed, formal, casual",
|
||||
},
|
||||
"expertise_areas": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Subjects the user demonstrates working knowledge of",
|
||||
},
|
||||
"current_goals": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "What the user is actively trying to accomplish",
|
||||
},
|
||||
},
|
||||
},
|
||||
custom_instructions="Prefer durable traits over one-off remarks.",
|
||||
)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import MemoryClient from "mem0ai";
|
||||
|
||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
||||
|
||||
await client.updateProfileSettings({
|
||||
enabled: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
communication_style: {
|
||||
type: "string",
|
||||
description:
|
||||
"How the user prefers to be addressed: terse, detailed, formal, casual",
|
||||
},
|
||||
expertise_areas: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Subjects the user demonstrates working knowledge of",
|
||||
},
|
||||
current_goals: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What the user is actively trying to accomplish",
|
||||
},
|
||||
},
|
||||
},
|
||||
customInstructions: "Prefer durable traits over one-off remarks.",
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
Your schema's property names reach the API exactly as you write them. The SDKs do not rewrite them, so a profile always comes back with the field names you chose.
|
||||
</Note>
|
||||
|
||||
Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone.
|
||||
|
||||
## Read a profile
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
result = client.get_profile("alice")
|
||||
|
||||
if result["status"] == "succeeded":
|
||||
print(result["profile"])
|
||||
else:
|
||||
print("not ready:", result["status"])
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const result = await client.getProfile({ entityId: "alice" });
|
||||
|
||||
if (result.status === "succeeded") {
|
||||
console.log(result.profile);
|
||||
} else {
|
||||
console.log("not ready:", result.status);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
A response looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": {
|
||||
"communication_style": "terse",
|
||||
"expertise_areas": ["distributed systems", "postgres"],
|
||||
"current_goals": ["cut p99 latency", "migrate off the legacy queue"]
|
||||
},
|
||||
"status": "succeeded",
|
||||
"entity_type": "user",
|
||||
"entity_id": "alice",
|
||||
"updated_at": "2026-02-08T10:30:00Z",
|
||||
"generation_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
`generation_count` is how many times this profile has been (re)generated — `0` before the first generation completes.
|
||||
|
||||
### Always branch on `status`
|
||||
|
||||
`profile` is empty unless `status` is `succeeded`. Check the status rather than the emptiness of the object, so a profile that is merely still building is not mistaken for a user you know nothing about.
|
||||
|
||||
| `status` | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| `succeeded` | Profile is built and current | Use it |
|
||||
| `pending` | Generation is queued or running | Read again shortly |
|
||||
| `insufficient_data` | Not enough memories to say anything yet | Fall back to defaults |
|
||||
| `not_enabled` | Profiles are off for this project | Enable them in settings |
|
||||
| `failed` | The last generation did not complete | Retry, or trigger a new one |
|
||||
|
||||
A `404` means only that no such entity exists in your project.
|
||||
|
||||
## Generate a profile on demand
|
||||
|
||||
Profiles are built once an entity has accumulated enough messages, so a brand-new user has none during their first few interactions. Trigger one directly to close that gap:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.generate_profile("alice")
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
await client.generateProfile({ entityId: "alice" });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The call returns as soon as the work is queued. Poll the read endpoint and branch on `status`.
|
||||
|
||||
## Test a schema before applying it
|
||||
|
||||
A schema that reads well can still produce disappointing profiles. Sample a few real entities and inspect the output before committing to it.
|
||||
|
||||
Sampling is asynchronous: the call returns a job as soon as it is queued. Poll `status_url` until the job is terminal, then read each sampled entity's profile:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
import time
|
||||
|
||||
job = client.sample_profiles(limit=5)
|
||||
|
||||
# Poll until the sample job reaches a terminal state (job status is UPPERCASE).
|
||||
TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"}
|
||||
deadline = time.time() + 120
|
||||
while True:
|
||||
status = client.get_profile_job(job["status_url"])["job"]
|
||||
if status["status"] in TERMINAL:
|
||||
break
|
||||
if time.time() > deadline:
|
||||
raise TimeoutError("Sample job did not finish in time")
|
||||
time.sleep(3)
|
||||
|
||||
print(status["status"], status["succeeded"], "of", status["total"])
|
||||
|
||||
# The create response lists the sampled entities; read each one's saved profile.
|
||||
for entity_id in job.get("entity_ids", []):
|
||||
print(client.get_profile(entity_id))
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const job = await client.sampleProfiles({ limit: 5 });
|
||||
|
||||
// Poll until the sample job reaches a terminal state (job status is UPPERCASE).
|
||||
const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"];
|
||||
const deadline = Date.now() + 120_000;
|
||||
let status;
|
||||
while (true) {
|
||||
status = (await client.getProfileJob(job.statusUrl)).job;
|
||||
if (TERMINAL.includes(status.status)) break;
|
||||
if (Date.now() > deadline)
|
||||
throw new Error("Sample job did not finish in time");
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
console.log(status.status, status.succeeded, "of", status.total);
|
||||
|
||||
// The create response lists the sampled entities; read each one's saved profile.
|
||||
for (const entityId of job.entityIds ?? []) {
|
||||
console.log(await client.getProfile({ entityId }));
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
These are real generations. The profiles are saved to those entities and count toward your usage, so sampling is not wasted work and not a free dry run. A sample covers up to 10 entities and cannot be repeated immediately.
|
||||
|
||||
## Apply a new schema to existing entities
|
||||
|
||||
A new schema shapes the next generation. Profiles that already exist keep their values until their entity is generated again.
|
||||
|
||||
Each entity picks the new schema up as it sends more memories, and you can generate one now with `generate_profile`.
|
||||
|
||||
<Note>
|
||||
Rebuilding every profile in a project at once is not available yet. Refresh profiles one entity at a time with `generate_profile`, or let each one update on its own as its entity sends more memories.
|
||||
</Note>
|
||||
|
||||
## When profiles update
|
||||
|
||||
You never call an "update profile" endpoint — Mem0 keeps each profile current for you. Two things drive it:
|
||||
|
||||
- **Automatically, as memories accumulate.** Mem0 refreshes an entity's profile after roughly every **10 messages** it receives, folding the new memories into the existing profile. There is no schedule to wait for and no extra call to make: the same `add` you already do keeps the profile moving.
|
||||
- **On demand.** Call `generate_profile` to build or refresh a profile immediately — useful for a brand-new entity that has not yet crossed the automatic threshold.
|
||||
|
||||
Generation is **asynchronous and incremental**. A refresh runs in the background a short while after its trigger, so a read taken immediately after an `add` may still show the previous profile (or `pending`). Branch on `status` rather than assuming the latest memory is already reflected.
|
||||
|
||||
<Note>
|
||||
Updates are **incremental**, not a full rebuild each time — Mem0 merges what it newly learns into the stored profile and keeps the fields your schema still defines. After a schema change, existing profiles pick it up as their entities send more memories, or when you call `generate_profile` — see [Apply a new schema to existing entities](#apply-a-new-schema-to-existing-entities).
|
||||
</Note>
|
||||
|
||||
## Use a profile in a prompt
|
||||
|
||||
The point of the structure is that it drops straight into a prompt:
|
||||
|
||||
```python
|
||||
result = client.get_profile(user_id)
|
||||
|
||||
if result["status"] == "succeeded":
|
||||
profile = result["profile"]
|
||||
system_prompt = f"""You are helping {user_id}.
|
||||
Communication style: {profile.get("communication_style", "unknown")}
|
||||
Areas of expertise: {", ".join(profile.get("expertise_areas", []))}
|
||||
Current goals: {", ".join(profile.get("current_goals", []))}
|
||||
|
||||
Match their style and do not explain what they already know."""
|
||||
else:
|
||||
system_prompt = "You are a helpful assistant."
|
||||
```
|
||||
|
||||
## Writing a schema that works
|
||||
|
||||
- **Describe every field.** The description is the instruction; without it the model guesses.
|
||||
- **Prefer durable traits.** "Prefers dark mode" ages well; "is annoyed today" does not.
|
||||
- **Keep it small.** Ten focused fields beat forty speculative ones, and cost less to generate.
|
||||
- **Say what the field is not.** A description that rules out the near-miss interpretation is worth more than one that only states the obvious.
|
||||
- **Sample before you commit.** It is the only way to see what your descriptions actually produce.
|
||||
|
||||
<Note>
|
||||
A schema has a size budget of roughly **10,000 tokens** of serialized JSON — the whole schema is sent to the model on every generation, so a handful of verbose fields can cost more than many terse ones. Oversized schemas are rejected on save.
|
||||
</Note>
|
||||
|
||||
## Availability
|
||||
|
||||
The feature is in beta and enabled per organization on request — see the note at the top of this page.
|
||||
|
||||
Once it is on, an entity gets a profile when two more things hold:
|
||||
|
||||
- profiles are **enabled** with a schema for the project (see [Define the schema](#define-the-schema)), and
|
||||
- the memory is scoped to an entity — a `user_id`.
|
||||
|
||||
On a project where profiles are turned off, a read returns `status: not_enabled` rather than an error, so you can call it unconditionally and branch on the status.
|
||||
|
||||
## Settings reference
|
||||
|
||||
| Argument | Type | Description |
|
||||
|---|---|---|
|
||||
| `enabled` | boolean | Whether profile generation runs for the project |
|
||||
| `schema` | object | JSON Schema describing the profile. Every property needs a `description` |
|
||||
| `custom_instructions` | string | Extra guidance applied during extraction |
|
||||
|
||||
`enabled` is project-wide. `schema` and `custom_instructions` apply to user
|
||||
profiles, so the stored settings nest them under `entities`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"entities": {
|
||||
"user": {
|
||||
"schema": { "type": "object", "properties": { "...": {} } },
|
||||
"custom_instructions": "Prefer durable traits over one-off remarks."
|
||||
}
|
||||
},
|
||||
"capabilities": { "full_rebuild": false }
|
||||
}
|
||||
```
|
||||
|
||||
That is what a read returns and what a write accepts. The SDKs take the fields
|
||||
flat and nest them for you, so a schema you write with
|
||||
`update_profile_settings` comes back unchanged from `get_profile_settings`.
|
||||
|
||||
<Note>
|
||||
Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
|
||||
</Note>
|
||||
|
||||
## FAQ
|
||||
|
||||
**Do I need to change my `add` or `search` calls to use profiles?**
|
||||
No. Profiles are built from the memories you already add. You define a schema once and read the profile when you need it — your ingestion and retrieval code is unchanged.
|
||||
|
||||
**Why is `profile` empty even though the entity has memories?**
|
||||
Generation is asynchronous and needs enough to work with. Branch on `status`: `pending` means it is still building, and `insufficient_data` means there are not yet enough memories to fill the schema. Read again shortly, or call `generate_profile` to build one now.
|
||||
|
||||
**Is sampling free?**
|
||||
No. `sample_profiles` runs real generations against real memories and **keeps** the profiles it produces, so it counts toward your usage like any other generation. It exists to check a schema on a few entities before you commit to it — not as a zero-cost dry run.
|
||||
|
||||
**Does changing the schema rewrite existing profiles?**
|
||||
No. A schema change applies to the next generation. An existing profile keeps its values until its entity is generated again, which happens as that entity sends more memories, or when you call `generate_profile` for it.
|
||||
|
||||
**What happens to a field I remove from the schema?**
|
||||
It stops being maintained. On an entity's next generation, fields your schema no longer defines are pruned from the stored profile — so keep a field in the schema for as long as you want its value kept.
|
||||
|
||||
**How current is a profile?**
|
||||
It refreshes automatically as memories accumulate (about every 10 messages for an entity), plus any on-demand `generate_profile` calls. Because refreshes run in the background, expect a short delay after the triggering `add` rather than an instant update.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Entity-Scoped Memory" icon="users" href="/platform/features/entity-scoped-memory">
|
||||
How users, agents, apps and runs partition memories.
|
||||
</Card>
|
||||
<Card title="Custom Instructions" icon="pen" href="/platform/features/custom-instructions">
|
||||
Steer what Mem0 extracts in the first place.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
seo:
|
||||
title: "Mem0 Platform Overview"
|
||||
title: "Platform Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Managed memory layer for AI agents, production-ready in minutes"
|
||||
icon: "cloud"
|
||||
---
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.6",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "15.5.21",
|
||||
"next": "15.5.24",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.0.1",
|
||||
|
||||
@@ -1,743 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# User Profiles — live demo\n",
|
||||
"\n",
|
||||
"A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n",
|
||||
"user's memories, shaped by a JSON Schema you supply.\n",
|
||||
"\n",
|
||||
"Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n",
|
||||
"user\"*, in one read, with no query to write — and it is available on the first turn of a\n",
|
||||
"session, before the user has said anything.\n",
|
||||
"\n",
|
||||
"**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n",
|
||||
"generated from them, add 6 more turns that contradict the first set, and watch the\n",
|
||||
"profile rewrite itself. Then it shows every way the API says no.\n",
|
||||
"\n",
|
||||
"**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n",
|
||||
"\n",
|
||||
"> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n",
|
||||
"> production. The cells below read both from the environment.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# This notebook drives the SDK from this worktree, not the published mem0ai:\n",
|
||||
"# the profile fixes below are not released yet.\n",
|
||||
"%pip install -q -e ../..\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Define the schema\n",
|
||||
"\n",
|
||||
"The schema is handed to the model as a **tool definition**, and each field's\n",
|
||||
"`description` is the only instruction the model gets about what belongs there. An\n",
|
||||
"undescribed field is a field the model guesses at.\n",
|
||||
"\n",
|
||||
"Rules worth knowing:\n",
|
||||
"\n",
|
||||
"- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n",
|
||||
" it would bill you to extract nothing\n",
|
||||
"- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n",
|
||||
" they name the storage envelope, so a schema using them could not be read back\n",
|
||||
" unambiguously\n",
|
||||
"- keep it small. The whole schema is sent to the model on every generation\n",
|
||||
"\n",
|
||||
"Descriptions are **not** enforced on write in this build — a property without one is\n",
|
||||
"accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n",
|
||||
"Treat descriptions as your job, not the validator's.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"SCHEMA = {\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\": {\n",
|
||||
" \"occupation\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"description\": \"The person's current job title, in one short phrase.\",\n",
|
||||
" },\n",
|
||||
" \"location\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"description\": \"The city or region the person currently lives in.\",\n",
|
||||
" },\n",
|
||||
" \"interests\": {\n",
|
||||
" \"type\": \"array\",\n",
|
||||
" \"items\": {\"type\": \"string\"},\n",
|
||||
" \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n",
|
||||
" },\n",
|
||||
" \"dietary_restrictions\": {\n",
|
||||
" \"type\": \"array\",\n",
|
||||
" \"items\": {\"type\": \"string\"},\n",
|
||||
" \"description\": \"Foods the person avoids, and why, if they said.\",\n",
|
||||
" },\n",
|
||||
" \"communication_style\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n",
|
||||
" \"description\": \"How this person prefers to be answered.\",\n",
|
||||
" },\n",
|
||||
" \"expertise_level\": {\n",
|
||||
" \"type\": \"string\",\n",
|
||||
" \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n",
|
||||
" \"description\": \"Their technical depth, judged from how they discuss their work.\",\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"settings = client.update_profile_settings(\n",
|
||||
" enabled=True,\n",
|
||||
" schema=SCHEMA,\n",
|
||||
" custom_instructions=(\n",
|
||||
" \"Prefer facts the person stated outright over anything inferred. \"\n",
|
||||
" \"Leave a field empty rather than guessing.\"\n",
|
||||
" ),\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Sorted, because JSONB storage does not preserve the key order you sent.\n",
|
||||
"# Compare a stored schema by SET, never by string or by key order.\n",
|
||||
"stored = settings[\"entities\"][\"user\"][\"schema\"]\n",
|
||||
"print(\"schema fields:\", sorted(stored[\"properties\"]))\n",
|
||||
"print(\"enabled :\", settings[\"enabled\"])\n",
|
||||
"print(\"capabilities :\", settings[\"capabilities\"])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"`enabled` is project-wide; `schema` and `custom_instructions` apply to user profiles\n",
|
||||
"and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n",
|
||||
"you write comes back unchanged from `get_profile_settings()`.\n",
|
||||
"\n",
|
||||
"Only the arguments you pass are written. To turn the feature off without touching your\n",
|
||||
"schema, send `enabled` alone.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. The turns\n",
|
||||
"\n",
|
||||
"Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n",
|
||||
"side effect of the normal pipeline.\n",
|
||||
"\n",
|
||||
"Twelve, not five, because generation fires when an entity crosses a **10-message\n",
|
||||
"boundary**. Below that it waits for a flush window measured in hours, and this notebook\n",
|
||||
"would sit there.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"TURNS = [\n",
|
||||
" (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n",
|
||||
" (\"assistant\", \"Congratulations! What's the new role?\"),\n",
|
||||
" (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n",
|
||||
" (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n",
|
||||
" (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n",
|
||||
" (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n",
|
||||
" (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n",
|
||||
" (\"assistant\", \"Understood — short answers from here.\"),\n",
|
||||
" (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n",
|
||||
" (\"assistant\", \"Bouldering or ropes?\"),\n",
|
||||
" (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n",
|
||||
" (\"assistant\", \"Noted, vegetarian only.\"),\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"response = client.add(\n",
|
||||
" [{\"role\": r, \"content\": c} for r, c in TURNS],\n",
|
||||
" user_id=USER_ID,\n",
|
||||
")\n",
|
||||
"print(json.dumps(response, indent=2)[:300])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n",
|
||||
"`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n",
|
||||
"you know the add finished. Then let the extracted memories settle.\n",
|
||||
"\n",
|
||||
"Under load this can take a minute or more, so the cell says plainly whether it ran out of\n",
|
||||
"time rather than printing `0 memories` as though that were the answer.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"event_id = response[\"event_id\"]\n",
|
||||
"deadline = time.time() + 300\n",
|
||||
"\n",
|
||||
"# 1. The add itself. Terminal status, not a sleep.\n",
|
||||
"event_status = None\n",
|
||||
"while time.time() < deadline:\n",
|
||||
" event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n",
|
||||
" if event_status in (\"SUCCEEDED\", \"FAILED\"):\n",
|
||||
" break\n",
|
||||
" print(f\" add {event_status}\")\n",
|
||||
" time.sleep(5)\n",
|
||||
"print(f\"add finished: {event_status}\")\n",
|
||||
"assert event_status != \"FAILED\", \"the add itself failed — nothing downstream can work\"\n",
|
||||
"\n",
|
||||
"# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n",
|
||||
"# Wait for the count to stop growing instead of breaking on the first result.\n",
|
||||
"memories, stable = [], 0\n",
|
||||
"while time.time() < deadline:\n",
|
||||
" page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n",
|
||||
" found = page.get(\"results\", []) if isinstance(page, dict) else page\n",
|
||||
" stable = stable + 1 if found and len(found) == len(memories) else 0\n",
|
||||
" memories = found\n",
|
||||
" if stable >= 2: # two identical polls in a row\n",
|
||||
" break\n",
|
||||
" print(f\" ... {len(memories)} so far\")\n",
|
||||
" time.sleep(5)\n",
|
||||
"\n",
|
||||
"if memories:\n",
|
||||
" print(f\"\\n{len(memories)} memories extracted:\\n\")\n",
|
||||
" for m in memories:\n",
|
||||
" print(\" \\u2022\", m.get(\"memory\"))\n",
|
||||
"else:\n",
|
||||
" # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n",
|
||||
" # and makes the profile below look like it came from nothing.\n",
|
||||
" print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n",
|
||||
" print(\"worker is down. Everything below will report insufficient_data.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Read the profile\n",
|
||||
"\n",
|
||||
"Crossing the 10-message boundary should already have queued a generation. Read first —\n",
|
||||
"and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n",
|
||||
"distinction is the whole point of the envelope.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"envelope = client.get_profile(USER_ID)\n",
|
||||
"print(json.dumps(envelope, indent=2))\n",
|
||||
"\n",
|
||||
"print(\"\\nstatus vocabulary:\")\n",
|
||||
"print(\" succeeded terminal — a generation ran AND the profile has content\")\n",
|
||||
"print(\" pending queued or running\")\n",
|
||||
"print(\" failed terminal — the last generation did not complete\")\n",
|
||||
"print(\" not_enabled feature off, or plan below Pro\")\n",
|
||||
"print(\" insufficient_data no content to show: no row yet, queued, or a\")\n",
|
||||
"print(\" generation that legitimately found nothing\")\n",
|
||||
"print()\n",
|
||||
"print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n",
|
||||
"print(\"empty extraction still increments the counter, so counting generations\")\n",
|
||||
"print(\"reports 'done' for a profile with nothing in it.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Force it, rather than waiting\n",
|
||||
"\n",
|
||||
"`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n",
|
||||
"until their tenth message. One entity, a few seconds.\n",
|
||||
"\n",
|
||||
"Every call is sent with a fresh `Idempotency-Key`, so a retry after a dropped connection\n",
|
||||
"returns the same job instead of paying twice.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n",
|
||||
" \"\"\"Poll until terminal.\n",
|
||||
"\n",
|
||||
" `since` waits for a generation_count ABOVE that value, which is how you wait\n",
|
||||
" for an UPDATE rather than accepting the profile you already had.\n",
|
||||
"\n",
|
||||
" `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n",
|
||||
" poll through it and give up on the timeout instead.\n",
|
||||
" \"\"\"\n",
|
||||
" deadline = time.time() + timeout\n",
|
||||
" body = None\n",
|
||||
" while time.time() < deadline:\n",
|
||||
" body = client.get_profile(entity_id)\n",
|
||||
" status = (body.get(\"status\") or \"\").lower()\n",
|
||||
" count = body.get(\"generation_count\") or 0\n",
|
||||
" fresh = count > since if since is not None else True\n",
|
||||
" if status == \"succeeded\" and fresh:\n",
|
||||
" return body\n",
|
||||
" if status in (\"failed\", \"not_enabled\"):\n",
|
||||
" raise RuntimeError(f\"generation stopped: {status}\")\n",
|
||||
" print(f\" ... {status} (generation_count={count})\")\n",
|
||||
" time.sleep(interval)\n",
|
||||
" raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
|
||||
"print(\"\\npolling...\")\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" body = wait_for_profile(USER_ID)\n",
|
||||
" print(\"\\n=== PROFILE ===\")\n",
|
||||
" print(json.dumps(body[\"profile\"], indent=2))\n",
|
||||
" print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n",
|
||||
"except TimeoutError as e:\n",
|
||||
" # Say so plainly and let the rest of the notebook skip, rather than raising\n",
|
||||
" # a NameError in every cell below and burying the real cause.\n",
|
||||
" body = None\n",
|
||||
" print(f\"\\nNO PROFILE: {e}\")\n",
|
||||
" print(\"Generation never finished. Usually the ingestion worker is down, or\")\n",
|
||||
" print(\"this project has no memories for the user yet.\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# The model must not invent fields outside your schema — the forced tool call is\n",
|
||||
"# what makes that structural rather than a request.\n",
|
||||
"if body is None:\n",
|
||||
" print(\"skipped — no profile was generated above\")\n",
|
||||
"else:\n",
|
||||
" extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n",
|
||||
" print(\"fields outside the schema:\", extra or \"none\")\n",
|
||||
"\n",
|
||||
" # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n",
|
||||
" # so 'I found nothing' arrives as a type default: 0, \"\", [].\n",
|
||||
" filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n",
|
||||
" print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Now watch it update\n",
|
||||
"\n",
|
||||
"Six more turns that contradict and extend what we already know: a promotion, a move, a\n",
|
||||
"dropped hobby. A profile is a living document, not an append-only log — the model gets the\n",
|
||||
"memories and rewrites the whole thing.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if body is None:\n",
|
||||
" print(\"skipped — no profile was generated above\")\n",
|
||||
"else:\n",
|
||||
" before = body[\"generation_count\"]\n",
|
||||
"\n",
|
||||
" MORE_TURNS = [\n",
|
||||
" (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n",
|
||||
" (\"assistant\", \"Congratulations. Same team?\"),\n",
|
||||
" (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n",
|
||||
" (\"assistant\", \"Big move. How do you feel about it?\"),\n",
|
||||
" (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n",
|
||||
" (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n",
|
||||
" ]\n",
|
||||
"\n",
|
||||
" followup = client.add(\n",
|
||||
" [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n",
|
||||
" user_id=USER_ID,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" # Wait for the add to land before triggering: a generation queued before the new\n",
|
||||
" # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n",
|
||||
" deadline = time.time() + 300\n",
|
||||
" while time.time() < deadline:\n",
|
||||
" status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n",
|
||||
" if status in (\"SUCCEEDED\", \"FAILED\"):\n",
|
||||
" break\n",
|
||||
" time.sleep(5)\n",
|
||||
" print(\"follow-up add:\", status)\n",
|
||||
" time.sleep(15) # let extraction settle\n",
|
||||
"\n",
|
||||
" print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
|
||||
" print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n",
|
||||
" updated = wait_for_profile(USER_ID, since=before)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if body is None:\n",
|
||||
" print(\"skipped — no profile was generated above\")\n",
|
||||
"else:\n",
|
||||
" print(f\"{'field':<22} {'before':<34} after\")\n",
|
||||
" print(\"-\" * 92)\n",
|
||||
" for field in SCHEMA[\"properties\"]:\n",
|
||||
" b = json.dumps(body[\"profile\"].get(field))\n",
|
||||
" a = json.dumps(updated[\"profile\"].get(field))\n",
|
||||
" mark = \" \" if a == b else \"->\"\n",
|
||||
" print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Use it in a prompt\n",
|
||||
"\n",
|
||||
"The point of the structure is that it drops straight into a prompt — no list of memories\n",
|
||||
"to summarize, no query to write.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_system_prompt(entity_id):\n",
|
||||
" result = client.get_profile(entity_id)\n",
|
||||
" if result[\"status\"] != \"succeeded\":\n",
|
||||
" # Branch on status, never on an empty profile: a user whose profile is\n",
|
||||
" # still building is not a user you know nothing about.\n",
|
||||
" return \"You are a helpful assistant.\"\n",
|
||||
"\n",
|
||||
" p = result[\"profile\"]\n",
|
||||
" return f\"\"\"You are helping {entity_id}.\n",
|
||||
"Occupation: {p.get(\"occupation\", \"unknown\")}\n",
|
||||
"Location: {p.get(\"location\", \"unknown\")}\n",
|
||||
"Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n",
|
||||
"Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n",
|
||||
"Preferred style: {p.get(\"communication_style\", \"unknown\")}\n",
|
||||
"\n",
|
||||
"Match their style and do not explain what they already know.\"\"\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(build_system_prompt(USER_ID))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Judge a schema before committing to it\n",
|
||||
"\n",
|
||||
"`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n",
|
||||
"\n",
|
||||
"These are real generations and the results are **kept** — a dry run would cost exactly the\n",
|
||||
"same and leave those users no better off. It is not a free preview.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# 202, not 200: the sample generations are queued, not finished.\n",
|
||||
"#\n",
|
||||
"# A 409 `already_running` means a sample from an earlier run is still going.\n",
|
||||
"# That is the cooldown working, not an error — reuse that job rather than\n",
|
||||
"# failing the notebook.\n",
|
||||
"try:\n",
|
||||
" job = client.sample_profiles(limit=3)\n",
|
||||
" print(json.dumps(job, indent=2)[:400])\n",
|
||||
" print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n",
|
||||
"except Exception as e:\n",
|
||||
" detail = str(e)\n",
|
||||
" print(\"sample refused:\", detail[:200])\n",
|
||||
" running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n",
|
||||
" job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n",
|
||||
" print(\"reusing the running job:\", running)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n",
|
||||
"so format it defensively rather than assuming a number.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Apply a new schema to existing users\n",
|
||||
"\n",
|
||||
"A new schema shapes the **next** generation. Profiles that already exist keep their values\n",
|
||||
"until their user is generated again — which happens as that user sends more memories, or\n",
|
||||
"when you call `generate_profile()` for them.\n",
|
||||
"\n",
|
||||
"A field you **remove** stops being maintained: on the next generation, fields your schema\n",
|
||||
"no longer defines are pruned. Keep a field for as long as you want its value kept."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"# Failure scenarios\n",
|
||||
"\n",
|
||||
"Everything above is the path that works. These are the ways it says no, and what each one\n",
|
||||
"means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n",
|
||||
"\n",
|
||||
"> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n",
|
||||
"> ERROR level before raising, so they appear even for the failures these cells\n",
|
||||
"> deliberately catch. Read the line printed *after* each one — that is the cell's\n",
|
||||
"> own verdict. Nothing here is unhandled.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## F1. Schemas that get rejected\n",
|
||||
"\n",
|
||||
"Rejections happen on **write**, where you can see and fix them — not silently at\n",
|
||||
"generation time, where you would only notice as an empty profile weeks later.\n",
|
||||
"\n",
|
||||
"The last case matters for storage: the user schema lives in one JSONB column alongside\n",
|
||||
"the envelope that separates it, so a schema using the envelope's own reserved keys could\n",
|
||||
"not be read back unambiguously. It is refused rather than stored.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"BAD_SCHEMAS = [\n",
|
||||
" ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n",
|
||||
" ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n",
|
||||
" (\n",
|
||||
" {\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n",
|
||||
" # At the schema ROOT, which is where the envelope's own keys live.\n",
|
||||
" \"_profile_config_version\": 1,\n",
|
||||
" \"entities\": {\"user\": {}},\n",
|
||||
" },\n",
|
||||
" \"reserved settings keys at the schema root\",\n",
|
||||
" ),\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"for bad, why in BAD_SCHEMAS:\n",
|
||||
" try:\n",
|
||||
" client.update_profile_settings(schema=bad)\n",
|
||||
" print(f\"ACCEPTED (unexpected): {why}\")\n",
|
||||
" except Exception as e:\n",
|
||||
" print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n",
|
||||
"\n",
|
||||
"# NOT rejected: a property with no description. The validator allows it and the\n",
|
||||
"# model then has nothing to go on, so the field comes back empty. Descriptions are\n",
|
||||
"# your job, not the validator's.\n",
|
||||
"try:\n",
|
||||
" client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n",
|
||||
" print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n",
|
||||
"finally:\n",
|
||||
" client.update_profile_settings(schema=SCHEMA) # put the good one back\n",
|
||||
"\n",
|
||||
"restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n",
|
||||
"assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n",
|
||||
"print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## F2. A user that does not exist\n",
|
||||
"\n",
|
||||
"404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n",
|
||||
"`insufficient_data`, so an ordinary empty state never looks like an error.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from mem0.exceptions import MemoryNotFoundError\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" client.get_profile(\"user_who_never_existed\")\n",
|
||||
" print(\"ACCEPTED (unexpected)\")\n",
|
||||
"except MemoryNotFoundError as e:\n",
|
||||
" print(\"404 as intended:\", str(e)[:120])\n",
|
||||
"\n",
|
||||
"# ...versus a real user who simply has no profile row yet.\n",
|
||||
"fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n",
|
||||
"client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n",
|
||||
"time.sleep(5)\n",
|
||||
"print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## F3. Profiles turned off\n",
|
||||
"\n",
|
||||
"`enabled` is the one project-wide switch. Every generation path then refuses.\n",
|
||||
"\n",
|
||||
"Nothing is deleted. Your schema and every profile you already built are kept, so turning\n",
|
||||
"it back on resumes rather than restarts.\n",
|
||||
"\n",
|
||||
"Note what a read does **not** do — a profile that already exists keeps reporting\n",
|
||||
"`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n",
|
||||
"with no profile yet. Turning the feature off stops new work; it does not hide what has\n",
|
||||
"already been built.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"client.update_profile_settings(enabled=False)\n",
|
||||
"\n",
|
||||
"print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n",
|
||||
"print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n",
|
||||
"try:\n",
|
||||
" client.generate_profile(USER_ID)\n",
|
||||
" print(\"trigger: ACCEPTED (unexpected)\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(\"trigger:\", str(e)[:160])\n",
|
||||
"\n",
|
||||
"back = client.update_profile_settings(enabled=True) # put it back\n",
|
||||
"print(\"\\nrestored:\", back[\"enabled\"])\n",
|
||||
"print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Cleanup\n",
|
||||
"\n",
|
||||
"Removes the demo users. The profile row cascades with the entity.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "for entity_id in (USER_ID, fresh):\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\n\n# Restore the project's profile settings to the start-of-run snapshot, so a shared\n# env is left exactly as we found it. Passing the original values (including None)\n# clears anything this notebook set — the SDK distinguishes an explicit None from an\n# omitted argument.\n_user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\nclient.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n)\nprint(\"profile settings restored to the pre-notebook snapshot\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Cheat sheet\n",
|
||||
"\n",
|
||||
"| Want | Call | Cost |\n",
|
||||
"| --- | --- | --- |\n",
|
||||
"| configure | `update_profile_settings(...)` | free |\n",
|
||||
"| read | `get_profile(user_id)` | free |\n",
|
||||
"| one user now | `generate_profile(user_id)` | 1 LLM call |\n",
|
||||
"| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n",
|
||||
"| poll a job | `get_profile_job(status_url)` | free |\n",
|
||||
"\n",
|
||||
"**Settings apply to user profiles.** The stored shape is:\n",
|
||||
"\n",
|
||||
"```json\n",
|
||||
"{\"enabled\": true,\n",
|
||||
" \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n",
|
||||
" \"capabilities\": {\"full_rebuild\": false}}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"The SDK takes these flat and nests them for you. Only the fields you pass are written;\n",
|
||||
"`enabled` is the one project-wide switch.\n",
|
||||
"\n",
|
||||
"**Left alone, generation fires** on a 10-message boundary, or after a flush window\n",
|
||||
"measured in hours. `generate_profile()` is how you skip the wait for one user.\n",
|
||||
"\n",
|
||||
"**Three traps:**\n",
|
||||
"\n",
|
||||
"1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n",
|
||||
" through it and give up on a timeout instead.\n",
|
||||
"2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n",
|
||||
" extraction still increments the counter.\n",
|
||||
"3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n",
|
||||
" arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.4"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -49,6 +49,48 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
|
||||
- **`mem0-strands/`** is a native Strands `MemoryStore` (Python, published to PyPI as `mem0-strands`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `mem0-strands/python/`.
|
||||
|
||||
## Surface attribution
|
||||
|
||||
Every integration tells the Mem0 platform which surface it is. Three headers,
|
||||
and the rules on them are what keep one layer from erasing another:
|
||||
|
||||
| Header | Carries | Rule |
|
||||
|--------|---------|------|
|
||||
| `X-Mem0-Source` | one canonical source value | **set-once** — write only if absent |
|
||||
| `X-Application` | the host app it runs inside | **set-once** — write only if absent |
|
||||
| `X-Mem0-Client` | `name/version`, outermost first | **append-only** — add yourself, never replace |
|
||||
|
||||
Set-once means check-then-set, never assignment. An integration that wraps the
|
||||
SDK is the outermost layer and sets the source; the SDK underneath defers to it.
|
||||
Assignment is exactly how every agent plugin came to be indistinguishable from
|
||||
every other one at the platform.
|
||||
|
||||
How to declare it from an integration, in order of preference:
|
||||
|
||||
1. Send the headers yourself, if you make the HTTP call directly.
|
||||
2. Pass `source` in the call options, if you go through an SDK.
|
||||
3. Set `MEM0_SOURCE` / `MEM0_APPLICATION` / `MEM0_CLIENT_STACK` in the
|
||||
environment before constructing the client. The SDKs read these and defer to
|
||||
anything already present.
|
||||
|
||||
Append-only applies where a stack can actually form: an SDK handed a client that
|
||||
already carries `X-Mem0-Client` appends itself rather than replacing. An SDK
|
||||
constructed with no outer context simply reports itself, which is correct — it
|
||||
is the outermost layer in that process.
|
||||
|
||||
The backend recognizes a fixed list of source values and buckets everything else
|
||||
into `OTHERS`. A new value has to land in the platform's `EventSource` enum, so
|
||||
do not invent one without that change going in too.
|
||||
|
||||
`X-Application` is allowlisted the same way, and this one has a rule of its own:
|
||||
**omit the header when you do not know the host.** A value outside the allowlist
|
||||
is discarded server-side, so guessing produces an event that claims an
|
||||
attribution we do not actually have. The portable bundle is the case that
|
||||
matters. It runs in whatever editor a user drops it into, so its build leaves
|
||||
`PLATFORM_APPLICATION` empty and `memory_core` sends no header at all, while the
|
||||
native bundles each name the host they were generated for. If you add a build
|
||||
target, decide which of those two it is.
|
||||
|
||||
## Adding an integration
|
||||
|
||||
1. For a native coding-agent host, add `integrations/<name>-plugin/` with `plugin-build.json`, its manifest, and a thin adapter, then generate its shared runtime. Portable clients use the single `mem0-agent-plugin/` package. Independent TypeScript integrations stay self-contained and import shared lifecycle behavior from `agent-plugin-core/typescript/`.
|
||||
@@ -59,3 +101,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`.
|
||||
|
||||
@@ -81,6 +81,38 @@ def replace_output(staged: Path, output: Path) -> Path:
|
||||
return output
|
||||
|
||||
|
||||
def _render_harness_id(host: str, *, portable: bool = False) -> str:
|
||||
"""Emit core/_harness_id.py for one host.
|
||||
|
||||
Carries both vocabularies from a single definition: the PostHog `source` tag
|
||||
and the platform's X-Mem0-Source / X-Application pair. Keeping them together
|
||||
is what stops the two from drifting into separate vocabularies for the same
|
||||
thing.
|
||||
|
||||
The portable bundle runs in whatever editor a user drops it into, so it does
|
||||
not know its host and must not guess one. HARNESS_ID stays "coding-agent",
|
||||
which is true and useful for grouping in PostHog, but PLATFORM_APPLICATION is
|
||||
left empty: X-Application names a real host app, is checked against an
|
||||
allowlist server-side, and a value that is always discarded is worse than no
|
||||
value -- it reads like an attribution we have and do not.
|
||||
"""
|
||||
tag = host.upper().replace("-", "_") + "_PLUGIN"
|
||||
application = "" if portable else host
|
||||
return (
|
||||
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
|
||||
"\n"
|
||||
f'HARNESS_ID = "{host}"\n'
|
||||
f'SOURCE_TAG = "{tag}"\n'
|
||||
"\n"
|
||||
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
|
||||
"# plugin family is one source; which editor it runs in is the application.\n"
|
||||
"# An empty application means the host is unknown, and memory_core omits\n"
|
||||
"# the header entirely rather than sending a placeholder.\n"
|
||||
'PLATFORM_SOURCE = "MEM0_PLUGIN"\n'
|
||||
f'PLATFORM_APPLICATION = "{application}"\n'
|
||||
)
|
||||
|
||||
|
||||
def _bundle_python(
|
||||
staged: Path,
|
||||
host: str,
|
||||
@@ -96,6 +128,12 @@ def _bundle_python(
|
||||
continue
|
||||
shutil.copy2(source, core / source.name)
|
||||
|
||||
# Generated per host so identity does not depend on an entrypoint remembering
|
||||
# to call telemetry.init(). mcp_server.py and the detached telemetry.py sender
|
||||
# never did, which is how MCP searches reported harness=generic and every
|
||||
# batch they drained was labelled MEM0_PLUGIN regardless of the real host.
|
||||
(core / "_harness_id.py").write_text(_render_harness_id(host, portable=portable), encoding="utf-8")
|
||||
|
||||
values = {
|
||||
"PLUGIN_ROOT": plugin_root,
|
||||
"PLUGIN_DATA": "${PLUGIN_DATA}",
|
||||
|
||||
@@ -290,6 +290,11 @@ def run(
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
|
||||
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
|
||||
# after them always saw content and every fresh install reported an upgrade.
|
||||
data_dir_was_empty = telemetry.data_dir_was_empty()
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
@@ -305,8 +310,19 @@ def run(
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
# Claims the marker atomically and says which event to record, so a
|
||||
# second session starting alongside this one cannot record it too.
|
||||
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
|
||||
if first_event == "install":
|
||||
telemetry.record("install")
|
||||
elif first_event == "upgrade":
|
||||
# First run after a build that never wrote the marker; the
|
||||
# predecessor version was never recorded anywhere.
|
||||
telemetry.record("upgrade", from_version="pre-0.3")
|
||||
else:
|
||||
previous = telemetry.claim_version_change()
|
||||
if previous:
|
||||
telemetry.record("upgrade", from_version=previous)
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable
|
||||
import telemetry
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
PLUGIN_VERSION = "0.3.2"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
||||
return batches
|
||||
|
||||
|
||||
# Platform surface attribution. Read from the generated per-host module so a new
|
||||
# entrypoint is correct without remembering to configure anything.
|
||||
try: # pragma: no cover - absent only in the un-built shared source tree
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
|
||||
def platform_headers(key: str) -> dict[str, str]:
|
||||
"""Auth plus the three surface-identity headers.
|
||||
|
||||
X-Mem0-Source and X-Application are set-once by contract: this is the
|
||||
outermost layer, so it sets them, and nothing below may overwrite them.
|
||||
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": _PLATFORM_SOURCE,
|
||||
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
|
||||
}
|
||||
if _PLATFORM_APPLICATION:
|
||||
headers["X-Application"] = _PLATFORM_APPLICATION
|
||||
return headers
|
||||
|
||||
|
||||
def _request_json(
|
||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=raw,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
||||
"user_id": write_user,
|
||||
"app_id": repo.app_id,
|
||||
"run_id": session_id,
|
||||
# Top level, not metadata: the backend reads `source` from the body or
|
||||
# the query string, never from metadata, which is where this used to
|
||||
# sit. The X-Mem0-Source header is also read, but only from the
|
||||
# platform release that ships alongside this change, so the body value
|
||||
# is what makes attribution work on both. The harness tag stays in
|
||||
# metadata as hook provenance.
|
||||
"source": _PLATFORM_SOURCE,
|
||||
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
|
||||
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
|
||||
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||
request = urllib.request.Request(
|
||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="DELETE",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anonymous usage telemetry for Mem0 agent plugins.
|
||||
"""Usage telemetry for Mem0 agent plugins.
|
||||
|
||||
Events are linked to your Mem0 account email when an API key is configured, and
|
||||
to a random per-machine id otherwise. Not anonymous — the Python SDK and CLI
|
||||
attribute the same way.
|
||||
|
||||
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
|
||||
touches the network: `record` appends one JSON line to a local spool and returns.
|
||||
@@ -9,7 +13,8 @@ started once per session and again from the flush worker that is already detache
|
||||
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
|
||||
|
||||
Never sends prompts, memory text, queries, file paths, repository names, or API
|
||||
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
|
||||
keys: only event names, durations, counts, coarse outcomes, and repo/session
|
||||
identifiers hashed with a random per-install salt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,8 +34,24 @@ from typing import Any
|
||||
|
||||
import memory_core
|
||||
|
||||
_harness: str = "generic"
|
||||
_source_tag: str = "MEM0_PLUGIN"
|
||||
# Seeded from the per-host module the build generates into core/. Two processes
|
||||
# in this pipeline never call init() — mcp_server.py, and the detached
|
||||
# `python3 telemetry.py` sender that spawn_flush() starts — so a module default
|
||||
# was what every one of their events got labelled with.
|
||||
try: # pragma: no cover - absent only in the un-built shared source tree
|
||||
from _harness_id import HARNESS_ID as _DEFAULT_HARNESS
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG
|
||||
except ImportError:
|
||||
_DEFAULT_HARNESS = "generic"
|
||||
_DEFAULT_SOURCE_TAG = "MEM0_PLUGIN"
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
_salt_cache: str = ""
|
||||
_harness: str = _DEFAULT_HARNESS
|
||||
_source_tag: str = _DEFAULT_SOURCE_TAG
|
||||
_PRIVATE_KEYS = {
|
||||
"apikey",
|
||||
"authorization",
|
||||
@@ -56,10 +77,19 @@ _PRIVATE_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def init(harness: str = "generic", source_tag: str = "") -> None:
|
||||
def init(harness: str = "", source_tag: str = "") -> None:
|
||||
"""Override the generated identity. Optional — core/_harness_id.py is the default.
|
||||
|
||||
The fallback shape matches memory_core.configure_harness's (``<HOST>_PLUGIN``).
|
||||
It used to be ``MEM0_<HOST>_PLUGIN`` here and ``<host>_plugin`` there, which
|
||||
meant one plugin could emit three different source values depending on which
|
||||
process happened to send the batch.
|
||||
"""
|
||||
global _harness, _source_tag
|
||||
_harness = harness
|
||||
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
|
||||
_harness = harness or _DEFAULT_HARNESS
|
||||
_source_tag = source_tag or (
|
||||
f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG
|
||||
)
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
|
||||
@@ -70,6 +100,16 @@ BATCH_SIZE = 100
|
||||
SEND_TIMEOUT = 5
|
||||
CLAIM_STALE_SECONDS = 120
|
||||
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
# A batch is only discarded once it has genuinely been retried this many times.
|
||||
MAX_CLAIM_ATTEMPTS = 3
|
||||
# Parked claims drained per run, after the live spool. Bounded so a long backlog
|
||||
# cannot turn one flush into an unbounded send loop.
|
||||
MAX_PARKED_PER_RUN = 3
|
||||
# Added to the wait before a released claim becomes reclaimable, per attempt
|
||||
# already spent. Releasing straight to "reclaimable now" let two senders burn the
|
||||
# whole budget within seconds of one another on a single momentary failure, and
|
||||
# discard a batch a retry a minute later would have delivered.
|
||||
RETRY_COOLDOWN_SECONDS = 60
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
@@ -83,9 +123,126 @@ def is_enabled() -> bool:
|
||||
|
||||
|
||||
def _digest(value: str, length: int = 16) -> str:
|
||||
"""Unsalted digest. Only for values that are already secrets (API keys)."""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _salt_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry-salt"
|
||||
|
||||
|
||||
def _install_salt() -> str:
|
||||
"""Random per-install salt, created once and memoized for the process.
|
||||
|
||||
Deliberately its own file, claimed with O_CREAT|O_EXCL, rather than a key in
|
||||
the identity file. Three reasons, all of which produced wrong data when this
|
||||
lived in the identity dict:
|
||||
|
||||
- Hooks are short-lived separate processes firing on every tool call, and
|
||||
people run more than one agent window. A read-modify-write would let each
|
||||
process mint its own salt, so one repository would hash several ways in the
|
||||
window before a writer won.
|
||||
- resolve_distinct_id holds a copy of the identity dict across a network call
|
||||
to /v1/ping/, so whichever write landed second erased the other's key —
|
||||
losing either the salt (repo_hash changes mid-stream) or the email (a
|
||||
second $identify, splitting the person).
|
||||
- Touching the identity file from record() would create it, and is_first_run
|
||||
keys off that file, so recording an event would silently suppress the
|
||||
install event.
|
||||
|
||||
Published atomically, and there is deliberately no derived fallback. Creating
|
||||
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
|
||||
the file exists and is empty, and a concurrent hook that reads it in that
|
||||
window gets nothing. Falling back to a digest of the path would hand that
|
||||
process a salt an attacker can compute, memoized for its whole run, which is
|
||||
the privacy control this function exists to provide silently turning itself
|
||||
off under load. The salt is written to a private temp file first and linked
|
||||
into place, so the name either does not exist or already has the full value.
|
||||
|
||||
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
|
||||
rather than emit an unsalted one.
|
||||
"""
|
||||
global _salt_cache
|
||||
if _salt_cache:
|
||||
return _salt_cache
|
||||
|
||||
path = _salt_path()
|
||||
# Read before writing. Hooks are separate processes firing on every tool
|
||||
# call, so all but the first find the salt already published; going straight
|
||||
# to create-fsync-link-unlink meant every one of them paid an fsync to
|
||||
# discover that, on a path whose whole promise is appending a line and
|
||||
# returning.
|
||||
try:
|
||||
_salt_cache = path.read_text(encoding="utf-8").strip()
|
||||
if _salt_cache:
|
||||
return _salt_cache
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
stream.write(uuid.uuid4().hex)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
# Atomic claim: fails if another process already published one.
|
||||
# os.link rather than replace, which would clobber theirs.
|
||||
os.link(temporary, path)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError:
|
||||
# No hardlinks here (some network mounts, some container volumes).
|
||||
# Claim the name directly instead. That reopens the empty-file
|
||||
# window, but the window is now benign: a reader that lands in it
|
||||
# gets "" and omits the hash for that process rather than caching a
|
||||
# guessable one. Losing the hashes on every run of an entire
|
||||
# filesystem is the worse failure.
|
||||
try:
|
||||
fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fallback, "w", encoding="utf-8") as stream:
|
||||
stream.write(temporary.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
_salt_cache = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
_salt_cache = ""
|
||||
return _salt_cache
|
||||
|
||||
|
||||
def _scoped_digest(value: str, length: int = 16) -> str:
|
||||
"""Salted digest for values drawn from a guessable space.
|
||||
|
||||
repo.identity is a git remote URL, or ``local:<absolute path>`` when there is
|
||||
no remote — which normally contains the account username. Sixteen unsalted
|
||||
hex characters over that input space is enumerable, so this is not a
|
||||
privacy control without the salt. Salting per install keeps every
|
||||
within-account join the analytics actually use and gives up only
|
||||
cross-machine joins on the same repository, which nothing computes.
|
||||
|
||||
Returns "" when there is no salt, so record() omits the property. An
|
||||
unsalted digest over this input space is close to plaintext, and emitting one
|
||||
under a name that implies it is hashed is worse than sending nothing.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
salt = _install_salt()
|
||||
if not salt:
|
||||
return ""
|
||||
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return memory_core.redact(value)
|
||||
@@ -145,9 +302,176 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str:
|
||||
return created
|
||||
|
||||
|
||||
def _rotate_anonymous_id(identity: dict[str, str]) -> str:
|
||||
"""Mint a fresh anonymous id because the account context is gone.
|
||||
|
||||
The previous id may already have been merged into a person profile by an
|
||||
$identify, and that merge is permanent. Reusing it after a logout or a key
|
||||
change attributes everything that follows to the account that just went
|
||||
away, which is the same misattribution the key fingerprint exists to stop,
|
||||
only arriving through the anonymous path instead.
|
||||
|
||||
`aliased` is cleared with it: the new id has never been merged, so it is
|
||||
eligible to be aliased into whatever account comes next.
|
||||
"""
|
||||
created = f"code-anon-{uuid.uuid4().hex}"
|
||||
identity["anonymous_id"] = created
|
||||
identity.pop("aliased", None)
|
||||
_write_identity(identity)
|
||||
return created
|
||||
|
||||
|
||||
def _install_state_path() -> Path:
|
||||
return memory_core.data_dir() / "install-state.json"
|
||||
|
||||
|
||||
def is_first_run() -> bool:
|
||||
"""Whether this machine has never recorded a plugin event before."""
|
||||
return not _identity_path().exists()
|
||||
"""Whether install has never been recorded on this machine.
|
||||
|
||||
Deliberately NOT the identity file. That file is only written by a
|
||||
successful flush, so an offline or firewalled user recorded code.install on
|
||||
every single session, forever — and every 0.2.x user recorded one on their
|
||||
first 0.3.x session because 0.2.x never wrote it at all.
|
||||
"""
|
||||
return not _install_state_path().exists()
|
||||
|
||||
|
||||
def data_dir_was_empty() -> bool:
|
||||
"""Whether the data directory is untouched. Call BEFORE anything writes to it.
|
||||
|
||||
hook_runner reaches claim_install() only after cache_plugin_api_key() has
|
||||
written `api-key` and EvidenceStore() has created `evidence.sqlite3`, so
|
||||
asking at claim time always saw content and every fresh install reported an
|
||||
upgrade. The caller snapshots this at the top of the run instead.
|
||||
"""
|
||||
return not _data_dir_has_content()
|
||||
|
||||
|
||||
def claim_install(was_empty: bool | None = None) -> str | None:
|
||||
"""Claim the one install/upgrade record for this machine, atomically.
|
||||
|
||||
Returns the event to record ("install" or "upgrade"), or None if another
|
||||
session already claimed it. O_CREAT|O_EXCL so two sessions starting together
|
||||
cannot both win.
|
||||
|
||||
`was_empty` must come from data_dir_was_empty() called before this process
|
||||
wrote anything. Omitting it falls back to checking now, which is only
|
||||
correct for a caller that has touched nothing.
|
||||
"""
|
||||
if not is_enabled():
|
||||
# Never consume the one-shot claim while the user is opted out, or they
|
||||
# would silently lose their install event if they later opt in.
|
||||
return None
|
||||
|
||||
path = _install_state_path()
|
||||
upgrading = not (data_dir_was_empty() if was_empty is None else was_empty)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
json.dump(
|
||||
{
|
||||
"plugin_version": memory_core.PLUGIN_VERSION,
|
||||
"installed_at": memory_core.utc_now(),
|
||||
"upgraded": upgrading,
|
||||
},
|
||||
stream,
|
||||
)
|
||||
# Durable before this returns. The O_EXCL open is what makes the
|
||||
# claim exclusive, so it cannot be replaced by a temp-and-rename
|
||||
# without losing that, which leaves the content as the thing to make
|
||||
# safe. A kill between the open and this fsync used to leave a marker
|
||||
# that exists but parses to nothing: is_first_run reads it as claimed
|
||||
# and claim_version_change cannot read a version out of it.
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except OSError:
|
||||
pass
|
||||
return "upgrade" if upgrading else "install"
|
||||
|
||||
|
||||
def _data_dir_has_content() -> bool:
|
||||
"""Whether anything predates this session in the plugin data directory."""
|
||||
try:
|
||||
for entry in memory_core.data_dir().iterdir():
|
||||
if entry.name != "install-state.json":
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _repair_install_state(path: Path) -> None:
|
||||
"""Rewrite an unparseable marker so version tracking can resume."""
|
||||
try:
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps({"plugin_version": memory_core.PLUGIN_VERSION, "repaired_at": memory_core.utc_now()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def claim_version_change() -> str | None:
|
||||
"""Return the previously recorded version if it differs, updating the marker.
|
||||
|
||||
Only meaningful once the marker exists — the first transition into 0.3.x has
|
||||
no recorded predecessor and reports "pre-0.3" instead. Claiming by rewriting
|
||||
the marker means the next session sees no change and records nothing.
|
||||
"""
|
||||
path = _install_state_path()
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
# A crash between O_EXCL and the write leaves an empty marker. Left
|
||||
# alone it disables every future upgrade event on this machine, because
|
||||
# claim_install sees the file and this function cannot parse it.
|
||||
state = None
|
||||
if not isinstance(state, dict):
|
||||
_repair_install_state(path)
|
||||
return None
|
||||
previous = str(state.get("plugin_version") or "")
|
||||
if not previous or previous == memory_core.PLUGIN_VERSION:
|
||||
return None
|
||||
# Claim the transition with an exclusive sentinel before rewriting the
|
||||
# marker. A plain read-modify-write let every concurrently starting session
|
||||
# observe the old version and each record its own upgrade — and the first
|
||||
# session after a version bump is exactly when several agent windows restart
|
||||
# together.
|
||||
sentinel = path.with_name(f"upgraded-{memory_core.PLUGIN_VERSION}")
|
||||
try:
|
||||
os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
||||
except FileExistsError:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
state["plugin_version"] = memory_core.PLUGIN_VERSION
|
||||
state["upgraded_at"] = memory_core.utc_now()
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(state), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
# Release the claim. The marker still records the old version, so
|
||||
# without this the sentinel makes claim_version_change return early on
|
||||
# every later run and this version's upgrade is never recorded again.
|
||||
for leftover in (sentinel, temporary):
|
||||
try:
|
||||
leftover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
return previous
|
||||
|
||||
|
||||
def record(
|
||||
@@ -168,19 +492,32 @@ def record(
|
||||
except OSError:
|
||||
pass
|
||||
properties = _safe_value(properties)
|
||||
# Stamped in the RECORDING process, beside harness. `source` used to be
|
||||
# read in the sending process from a module global, so whichever process
|
||||
# drained the spool named every event in it. flush() spreads per-event
|
||||
# properties last, so this now wins over any sender's default.
|
||||
properties.update(
|
||||
harness=_harness,
|
||||
source=_source_tag,
|
||||
plugin_version=memory_core.PLUGIN_VERSION,
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
# Assigned only when the digest is real. _scoped_digest returns "" when
|
||||
# the salt could not be persisted, and an empty property is worse than an
|
||||
# absent one: it survives the None filter below and reads as a value.
|
||||
if repo is not None:
|
||||
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
|
||||
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
|
||||
if repo_hash:
|
||||
properties["repo_hash"] = repo_hash
|
||||
if session_id:
|
||||
properties["session_hash"] = _digest(session_id)
|
||||
session_hash = _scoped_digest(session_id)
|
||||
if session_hash:
|
||||
properties["session_hash"] = session_hash
|
||||
line = json.dumps(
|
||||
{
|
||||
"event": f"{EVENT_PREFIX}.{event}",
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"timestamp": memory_core.utc_now(),
|
||||
"properties": {
|
||||
key: value for key, value in properties.items() if value is not None
|
||||
@@ -239,38 +576,201 @@ def spawn_flush() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_name(attempt: int = 0) -> str:
|
||||
"""Claim filename. The attempt count rides in the name so the 7-day expiry
|
||||
only ever discards a batch that was actually retried and failed."""
|
||||
return f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}-a{attempt}.sending"
|
||||
|
||||
|
||||
def _claim_attempt(claim: Path) -> int:
|
||||
"""Attempts recorded in a claim filename; 0 for the pre-attempt-count shape.
|
||||
|
||||
Anchored on field position, not on a leading "a": the legacy shape is
|
||||
``telemetry-<pid>-<hex>.sending`` and a hex id such as ``a1234567`` would
|
||||
otherwise parse as attempt 1234567 and be discarded unsent on the first
|
||||
flush after an upgrade.
|
||||
"""
|
||||
stem = claim.name[: -len(".sending")] if claim.name.endswith(".sending") else claim.name
|
||||
parts = stem.split("-")
|
||||
if len(parts) != 4:
|
||||
return 0
|
||||
tail = parts[3]
|
||||
if tail.startswith("a") and tail[1:].isdigit():
|
||||
return int(tail[1:])
|
||||
return 0
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
"""Refresh mtime so a claim's age measures time since it was claimed.
|
||||
|
||||
``Path.replace`` is ``os.rename``, which preserves mtime — so a claim created
|
||||
after a quiet minute inherited the spool's last-write time and looked
|
||||
abandoned the instant it was made. A second sender would then take it over
|
||||
while the first was still posting, and both would deliver the batch.
|
||||
"""
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _claim_spool() -> Path | None:
|
||||
"""Rename the spool aside so exactly one sender owns each batch."""
|
||||
directory = memory_core.data_dir()
|
||||
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
|
||||
claim = directory / _claim_name()
|
||||
spool = _spool_path()
|
||||
try:
|
||||
spool.replace(claim)
|
||||
_touch(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
pass
|
||||
return _claim_parked(directory)
|
||||
|
||||
|
||||
def _sweep_debris(directory: Path) -> None:
|
||||
"""Remove files nothing else will ever pick up again.
|
||||
|
||||
*.partial is a temp file orphaned by a crash between write and rename.
|
||||
*.corrupt is a batch quarantined for undecodable content. No glob in this
|
||||
module matches either, so without this they accumulate on disk for the life
|
||||
of the install.
|
||||
|
||||
Quarantined batches are kept far longer than debris: they are the only
|
||||
evidence left of events that could not be delivered, and someone diagnosing
|
||||
a report of missing telemetry has to be able to find one.
|
||||
"""
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending")):
|
||||
for debris in directory.glob("telemetry-*.partial"):
|
||||
try:
|
||||
if now - debris.stat().st_mtime > CLAIM_STALE_SECONDS:
|
||||
debris.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
for quarantined in directory.glob("telemetry-*.corrupt"):
|
||||
try:
|
||||
if now - quarantined.stat().st_mtime > CLAIM_EXPIRY_SECONDS:
|
||||
quarantined.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
# The same reasoning covers *.tmp. _write_identity and _install_salt both
|
||||
# create one and unlink it in a finally, which a SIGKILL skips, and no glob
|
||||
# in this module matches the leftovers either.
|
||||
for temporary in directory.glob("telemetry-*.tmp"):
|
||||
try:
|
||||
if now - temporary.stat().st_mtime > CLAIM_STALE_SECONDS:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _claim_parked(directory: Path) -> Path | None:
|
||||
"""Take the oldest abandoned claim, if any lease has actually expired.
|
||||
|
||||
Kept separate from the live spool so flush() can drain both in one run.
|
||||
Previously parked batches were only reachable when no spool existed at all,
|
||||
and because sessions keep recording there usually was one — so a batch
|
||||
parked by a failed send waited until the 7-day expiry deleted it unsent,
|
||||
even though its own presence is what started the sender.
|
||||
"""
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending"), key=_safe_mtime):
|
||||
try:
|
||||
age = now - orphan.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > CLAIM_EXPIRY_SECONDS:
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
# Someone else holds a live lease on it. This check has to come
|
||||
# first. Claiming a file bumps its attempt count and refreshes its
|
||||
# mtime, so a sender that has just taken the final attempt looks
|
||||
# exhausted to everyone else while it is actively draining. Judging
|
||||
# exhaustion before liveness let a second sender unlink a batch out
|
||||
# from under its owner, losing every event in it.
|
||||
continue
|
||||
# Attempts, not age. Every re-claim touches the mtime and every release
|
||||
# backdates it by a fixed amount, so age is pinned near the stale
|
||||
# threshold and never reaches the expiry. Age stays only as a backstop
|
||||
# for files that never carried an attempt marker.
|
||||
if _claim_attempt(orphan) >= MAX_CLAIM_ATTEMPTS or age > CLAIM_EXPIRY_SECONDS:
|
||||
try:
|
||||
orphan.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
continue
|
||||
claim = orphan.parent / _claim_name(_claim_attempt(orphan) + 1)
|
||||
try:
|
||||
orphan.replace(claim)
|
||||
_touch(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _safe_mtime(path: Path) -> float:
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _rewrite_claim(claim: Path, remaining: list[dict[str, Any]]) -> bool:
|
||||
"""Persist the unsent remainder, atomically, and refresh the lease.
|
||||
|
||||
Called after every successful batch. Two jobs: a retry resumes where the
|
||||
send stopped instead of re-posting from the top, and the rewrite doubles as
|
||||
the lease heartbeat, so a slow sender does not have its claim stolen
|
||||
mid-flight. Interval is one batch, well inside CLAIM_STALE_SECONDS.
|
||||
"""
|
||||
if not remaining:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
temporary = claim.with_suffix(f".{os.getpid()}.partial")
|
||||
try:
|
||||
payload = "".join(json.dumps(event, separators=(",", ":"), default=str) + "\n" for event in remaining)
|
||||
# fsync before the rename: without it the rename can land while the
|
||||
# bytes have not, and the claim comes back empty or truncated after a
|
||||
# crash. _drain then reads zero events and unlinks it.
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(claim)
|
||||
_touch(claim)
|
||||
return True
|
||||
except OSError:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _release_claim(claim: Path, remaining: list[dict[str, Any]]) -> None:
|
||||
"""Persist the remainder and drop the lease, because this sender has given up.
|
||||
|
||||
Distinct from the per-batch heartbeat: heartbeating on the way out would
|
||||
make an abandoned batch look actively owned for a further
|
||||
CLAIM_STALE_SECONDS, delaying the retry for no reason. Ageing it past the
|
||||
threshold lets the next flush pick it up immediately, while the attempt
|
||||
count in the filename still bounds how many times that can happen.
|
||||
"""
|
||||
if not _rewrite_claim(claim, remaining):
|
||||
return
|
||||
try:
|
||||
# Backdate past the stale threshold so the next flush can pick it up,
|
||||
# minus a cooldown that grows with the attempts already spent. Clamped so
|
||||
# the mtime never lands in the future, which would read as a live lease.
|
||||
cooldown = min(_claim_attempt(claim) * RETRY_COOLDOWN_SECONDS, CLAIM_STALE_SECONDS)
|
||||
released = time.time() - CLAIM_STALE_SECONDS - 1 + cooldown
|
||||
os.utime(claim, (released, released))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_email(key: str) -> str:
|
||||
"""Trade the API key for the account email so events join other Mem0 surfaces."""
|
||||
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
|
||||
@@ -300,34 +800,130 @@ def _post(payload: dict[str, Any], url: str) -> bool:
|
||||
|
||||
|
||||
def resolve_distinct_id() -> tuple[str, str]:
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any.
|
||||
|
||||
The second value becomes a PostHog $identify alias. It is ONLY ever an
|
||||
anonymous id: aliasing one account email to another merges two real person
|
||||
profiles and cannot be undone, so a key that now belongs to a different
|
||||
account re-resolves with no alias.
|
||||
"""
|
||||
identity = _read_identity()
|
||||
email = identity.get("email", "")
|
||||
if email:
|
||||
return email, ""
|
||||
key = memory_core.api_key()
|
||||
fingerprint = _digest(key) if key else ""
|
||||
email = identity.get("email", "")
|
||||
|
||||
if email and fingerprint:
|
||||
recorded = identity.get("key_fingerprint", "")
|
||||
if recorded == fingerprint:
|
||||
return email, ""
|
||||
if not recorded:
|
||||
# Rows written before fingerprints existed. Verify rather than
|
||||
# adopt: a key changed before the upgrade would otherwise bind the
|
||||
# new key to the previous account's email, permanently, and the
|
||||
# fingerprint would then agree with itself forever after.
|
||||
verified = _resolve_email(key)
|
||||
if not verified:
|
||||
# Offline, firewalled, or the API is down. Keep the previous
|
||||
# behaviour and retry on the next flush rather than dropping a
|
||||
# real account attribution. Safe because the same network that
|
||||
# failed /v1/ping/ is about to fail the PostHog POST, so nothing
|
||||
# is delivered under the unverified identity in the meantime.
|
||||
return email, ""
|
||||
identity["email"] = verified
|
||||
identity["key_fingerprint"] = fingerprint
|
||||
_write_identity(identity)
|
||||
return verified, ""
|
||||
|
||||
if not key:
|
||||
# No key to verify the account with; do not keep attributing to it.
|
||||
if email:
|
||||
identity.pop("email", None)
|
||||
identity.pop("key_fingerprint", None)
|
||||
return _rotate_anonymous_id(identity), ""
|
||||
return anonymous_id(identity), ""
|
||||
email = _resolve_email(key)
|
||||
if not email:
|
||||
|
||||
resolved = _resolve_email(key)
|
||||
if not resolved:
|
||||
# The key changed and will not resolve (revoked, offline, API down).
|
||||
# Reaching here with an email means the recorded fingerprint disagreed,
|
||||
# so the key really did change. Drop the account and rotate: the stored
|
||||
# anonymous id may already be merged into that account's person, and
|
||||
# reusing it would keep the events on the profile we are trying to
|
||||
# leave.
|
||||
if email:
|
||||
identity.pop("email", None)
|
||||
identity.pop("key_fingerprint", None)
|
||||
return _rotate_anonymous_id(identity), ""
|
||||
return anonymous_id(identity), ""
|
||||
previous = identity.get("anonymous_id", "")
|
||||
identity["email"] = email
|
||||
|
||||
# Alias only when going anonymous -> email for the first time. Once an anon
|
||||
# id has been merged into an account it must never be offered again: an
|
||||
# alias naming an already-identified id is what could link two real people.
|
||||
previous = "" if (email or identity.get("aliased")) else identity.get("anonymous_id", "")
|
||||
if previous:
|
||||
identity["aliased"] = True
|
||||
identity["email"] = resolved
|
||||
identity["key_fingerprint"] = fingerprint
|
||||
_write_identity(identity)
|
||||
return email, previous
|
||||
return resolved, previous
|
||||
|
||||
|
||||
def flush() -> int:
|
||||
"""Drain claimed spools to PostHog and return the number of events sent."""
|
||||
"""Drain the live spool, then any parked claims, and return events sent."""
|
||||
if not is_enabled():
|
||||
return 0
|
||||
claim = _claim_spool()
|
||||
sent, delivered = _drain(_claim_spool())
|
||||
if not delivered:
|
||||
# The network is failing. Retrying other batches now would only burn
|
||||
# their attempt budget against the same broken connection.
|
||||
return sent
|
||||
|
||||
# Parked batches used to starve behind the live spool indefinitely. Bounded
|
||||
# per run so a long backlog cannot turn one flush into an unbounded loop.
|
||||
directory = memory_core.data_dir()
|
||||
_sweep_debris(directory)
|
||||
for _ in range(MAX_PARKED_PER_RUN):
|
||||
parked = _claim_parked(directory)
|
||||
if parked is None:
|
||||
break
|
||||
count, delivered = _drain(parked)
|
||||
sent += count
|
||||
if not delivered:
|
||||
break
|
||||
return sent
|
||||
|
||||
|
||||
def _drain(claim: Path | None) -> tuple[int, bool]:
|
||||
"""Post one claimed batch file, recording progress after every batch.
|
||||
|
||||
Returns (events sent, whether everything was delivered).
|
||||
"""
|
||||
if claim is None:
|
||||
return 0
|
||||
return 0, True
|
||||
try:
|
||||
lines = claim.read_text(encoding="utf-8").splitlines()
|
||||
except ValueError:
|
||||
# UnicodeDecodeError from a torn write: the content is unrecoverable, so
|
||||
# quarantine rather than retry. flush() runs from a bare `finally:` in
|
||||
# flush_worker, so raising here also skips the handoff cleanup, and an
|
||||
# undecodable file would otherwise be re-read on every flush forever.
|
||||
# Reported as delivered because there is nothing left to deliver and the
|
||||
# rest of the run should continue.
|
||||
try:
|
||||
claim.replace(claim.with_suffix(".corrupt"))
|
||||
except OSError:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0, True
|
||||
except OSError:
|
||||
return 0
|
||||
# Could not read it, which is not the same as having nothing to send.
|
||||
# The file is left exactly where it is: a vanished or briefly unreadable
|
||||
# claim is retryable, and quarantining it here would discard events over
|
||||
# a transient filesystem error. Reported as undelivered so the run stops
|
||||
# instead of counting a batch nothing was posted from as delivered.
|
||||
return 0, False
|
||||
events = []
|
||||
for line in lines:
|
||||
try:
|
||||
@@ -337,11 +933,18 @@ def flush() -> int:
|
||||
if isinstance(value, dict) and value.get("event"):
|
||||
events.append(value)
|
||||
if not events:
|
||||
# Only delete when the file really is empty. A non-empty file that
|
||||
# parses to nothing is a torn write, and its contents are the unsent
|
||||
# remainder — deleting it is the data loss this PR exists to prevent.
|
||||
try:
|
||||
claim.unlink()
|
||||
empty = claim.stat().st_size == 0
|
||||
except OSError:
|
||||
empty = True
|
||||
try:
|
||||
claim.replace(claim.with_suffix(".corrupt")) if not empty else claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
return 0, True
|
||||
|
||||
distinct_id, aliased_anonymous_id = resolve_distinct_id()
|
||||
if aliased_anonymous_id:
|
||||
@@ -360,12 +963,17 @@ def flush() -> int:
|
||||
|
||||
sent = 0
|
||||
for start in range(0, len(events), BATCH_SIZE):
|
||||
chunk = events[start : start + BATCH_SIZE]
|
||||
batch = [
|
||||
{
|
||||
"event": event["event"],
|
||||
"distinct_id": distinct_id,
|
||||
# Carried through from record() so a resend can be collapsed.
|
||||
"uuid": event.get("uuid"),
|
||||
"timestamp": event.get("timestamp"),
|
||||
"properties": {
|
||||
# Fallback only: events recorded by a build before source
|
||||
# moved into record() have none of their own.
|
||||
"source": _source_tag,
|
||||
"language": "python",
|
||||
"$process_person_profile": False,
|
||||
@@ -373,16 +981,24 @@ def flush() -> int:
|
||||
**(event.get("properties") or {}),
|
||||
},
|
||||
}
|
||||
for event in events[start : start + BATCH_SIZE]
|
||||
for event in chunk
|
||||
]
|
||||
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
|
||||
return sent
|
||||
sent += len(batch)
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return sent
|
||||
# Keep only what has not been delivered, and release the lease.
|
||||
# Previously the whole file was kept and the retry re-posted every
|
||||
# batch, including the ones that had already arrived.
|
||||
_release_claim(claim, events[start:])
|
||||
return sent, False
|
||||
sent += len(chunk)
|
||||
# Record progress and refresh the lease after each successful batch, so
|
||||
# a crash repeats at most one batch instead of the entire file. If the
|
||||
# rewrite fails the claim still holds delivered events, so stop rather
|
||||
# than carry on as though progress were recorded — continuing is how the
|
||||
# duplicate delivery this PR fixes would come back.
|
||||
if not _rewrite_claim(claim, events[start + len(chunk) :]):
|
||||
_release_claim(claim, events[start + len(chunk) :])
|
||||
return sent, False
|
||||
return sent, True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -7,8 +7,8 @@ disable-model-invocation: true
|
||||
# Pause memory capture
|
||||
|
||||
To pause (hooks stop capturing and sending session content; a minimal
|
||||
anonymous telemetry ping still fires at session start unless
|
||||
`MEM0_TELEMETRY=false`):
|
||||
telemetry ping still fires at session start, under your Mem0 account email,
|
||||
unless `MEM0_TELEMETRY=false`):
|
||||
|
||||
```bash
|
||||
python3 "{{PLUGIN_ROOT}}/core/memory_cli.py" --harness "{{HARNESS_ID}}" {{PLUGIN_DATA_ARG}} pause
|
||||
|
||||
@@ -70,6 +70,38 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
def _harness_identity(root: Path) -> dict[str, str]:
|
||||
"""Read the generated core/_harness_id.py without importing it."""
|
||||
values: dict[str, str] = {}
|
||||
for line in (root / "core" / "_harness_id.py").read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line and not line.lstrip().startswith("#"):
|
||||
name, _, raw = line.partition("=")
|
||||
values[name.strip()] = raw.strip().strip('"')
|
||||
return values
|
||||
|
||||
|
||||
def test_the_portable_bundle_declares_no_host_application(tmp_path: Path) -> None:
|
||||
"""It runs in whatever editor a user drops it into, so it cannot know the host.
|
||||
|
||||
X-Application is allowlisted server-side. A guessed value is silently dropped
|
||||
there, which is the worst outcome: the wire says we know the host and the
|
||||
stored event says we do not.
|
||||
"""
|
||||
identity = _harness_identity(build("mem0-agent-plugin", "portable", tmp_path / "portable"))
|
||||
|
||||
assert identity["PLATFORM_APPLICATION"] == ""
|
||||
# The PostHog-side label is still useful for grouping and stays populated.
|
||||
assert identity["HARNESS_ID"] == "coding-agent"
|
||||
assert identity["PLATFORM_SOURCE"] == "MEM0_PLUGIN"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_a_native_bundle_names_the_host_it_was_built_for(host: str, tmp_path: Path) -> None:
|
||||
identity = _harness_identity(build(host, "native", tmp_path / host))
|
||||
|
||||
assert identity["PLATFORM_APPLICATION"] == host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
|
||||
root = build(host, "native", tmp_path / host)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Delivery semantics of the telemetry spool: no duplicates, no starvation.
|
||||
|
||||
These run against a built host's core in-process (not a subprocess) because they
|
||||
need to inject failures into ``_post``. The identity tests next door cover the
|
||||
uninitialised-process case that needs a real interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = CORE_ROOT.parents[1]
|
||||
HOST_CORE = REPOSITORY_ROOT / "integrations" / "claude-code-plugin" / "core"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HOST_CORE.exists(), reason="claude-code-plugin is not built")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def telemetry(tmp_path, monkeypatch):
|
||||
# CI runs this directory and claude-code-plugin/tests in ONE pytest process,
|
||||
# and that suite's conftest sets MEM0_TELEMETRY=false at import, process-wide.
|
||||
# Without this the whole file silently no-ops: record() returns early and
|
||||
# every assertion sees an empty spool. Do not rely on ambient env.
|
||||
monkeypatch.setenv("MEM0_TELEMETRY", "true")
|
||||
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.syspath_prepend(str(HOST_CORE))
|
||||
|
||||
# Save and RESTORE rather than delete. claude-code-plugin/tests/conftest.py
|
||||
# imports memory_core once at collection and calls configure_harness() on it;
|
||||
# dropping the module left a later re-import with default harness config, so
|
||||
# tests in that suite failed depending on collection order.
|
||||
names = ("telemetry", "memory_core", "_harness_id")
|
||||
saved = {name: sys.modules.get(name) for name in names}
|
||||
for name in names:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
module = importlib.import_module("telemetry")
|
||||
monkeypatch.setattr(module, "resolve_distinct_id", lambda: ("tester@example.com", ""))
|
||||
try:
|
||||
yield module
|
||||
finally:
|
||||
for name in names:
|
||||
sys.modules.pop(name, None)
|
||||
if saved[name] is not None:
|
||||
sys.modules[name] = saved[name]
|
||||
|
||||
|
||||
def _delivered(payloads):
|
||||
return [event for payload in payloads if "batch" in payload for event in payload["batch"]]
|
||||
|
||||
|
||||
def test_a_partial_failure_does_not_redeliver_what_already_arrived(telemetry):
|
||||
"""Defect 2a: flush kept the whole claim on failure and retried from the top.
|
||||
|
||||
150 events across two batches, the second failing, previously delivered 250.
|
||||
"""
|
||||
for index in range(150):
|
||||
telemetry.record("search", index=index)
|
||||
|
||||
sent: list[dict] = []
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(payload, url):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2: # second batch fails
|
||||
return False
|
||||
sent.append(payload)
|
||||
return True
|
||||
|
||||
telemetry._post = flaky
|
||||
telemetry.flush()
|
||||
|
||||
telemetry._post = lambda payload, url: sent.append(payload) or True
|
||||
telemetry.flush()
|
||||
|
||||
events = _delivered(sent)
|
||||
assert len(events) == 150
|
||||
assert len({event["uuid"] for event in events}) == 150
|
||||
|
||||
|
||||
def test_a_fresh_claim_is_not_immediately_stealable(telemetry):
|
||||
"""Defect 2b: rename preserves mtime, so a claim inherited the spool's age.
|
||||
|
||||
With the last write older than the stale threshold, a claim made now looked
|
||||
abandoned the instant it existed and a second sender took it over.
|
||||
"""
|
||||
telemetry.record("search")
|
||||
spool = telemetry._spool_path()
|
||||
old = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(spool, (old, old))
|
||||
|
||||
first = telemetry._claim_spool()
|
||||
assert first is not None
|
||||
|
||||
# A second sender starting right now must find nothing to take.
|
||||
assert telemetry._claim_parked(first.parent) is None
|
||||
|
||||
|
||||
def test_a_live_final_attempt_is_not_deleted_by_another_sender(telemetry):
|
||||
"""Review finding: exhaustion was judged before liveness, so owners lost batches.
|
||||
|
||||
Claiming a parked file bumps its attempt count and refreshes its mtime. Once
|
||||
the count reaches the budget, the owner draining it looked exhausted to every
|
||||
other sender, which unlinked the file out from under it. Everything in that
|
||||
batch was gone, which is precisely the loss this PR exists to stop.
|
||||
"""
|
||||
telemetry.record("search", reason="owned-by-the-first-sender")
|
||||
spool = telemetry._spool_path()
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(spool, (stale, stale))
|
||||
|
||||
claim = telemetry._claim_spool()
|
||||
assert claim is not None
|
||||
|
||||
# Walk it to the final attempt, ageing it each round so it can be re-claimed.
|
||||
# _claim_spool hands back a0 and _release_claim keeps the name, so it takes
|
||||
# one full round per attempt to reach the budget.
|
||||
for _ in range(telemetry.MAX_CLAIM_ATTEMPTS):
|
||||
# Carry the marker through each rewrite so the final assertion proves the
|
||||
# events survived, not merely that some file with the right name did.
|
||||
telemetry._release_claim(claim, [{"event": "code.search", "uuid": "owned-by-the-first-sender"}])
|
||||
parked = sorted(claim.parent.glob("telemetry-*.sending"))
|
||||
assert parked, "the batch was dropped while still inside its budget"
|
||||
os.utime(parked[0], (stale, stale))
|
||||
claim = telemetry._claim_parked(claim.parent)
|
||||
assert claim is not None
|
||||
|
||||
assert telemetry._claim_attempt(claim) >= telemetry.MAX_CLAIM_ATTEMPTS
|
||||
assert claim.exists()
|
||||
|
||||
# The owner is draining it right now: fresh mtime, live lease.
|
||||
second_sender = telemetry._claim_parked(claim.parent)
|
||||
|
||||
assert second_sender is None, "a second sender took a batch under a live lease"
|
||||
assert claim.exists(), "a second sender deleted a batch its owner was draining"
|
||||
assert "owned-by-the-first-sender" in claim.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_an_exhausted_batch_is_still_discarded_once_its_lease_lapses(telemetry):
|
||||
"""The liveness check must defer the cleanup, not cancel it.
|
||||
|
||||
Guards the obvious over-correction: skipping live claims is only safe if an
|
||||
abandoned one at the same attempt count is still reaped on a later run.
|
||||
"""
|
||||
telemetry.record("search")
|
||||
spool = telemetry._spool_path()
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(spool, (stale, stale))
|
||||
|
||||
claim = telemetry._claim_spool()
|
||||
assert claim is not None
|
||||
exhausted = claim.parent / telemetry._claim_name(telemetry.MAX_CLAIM_ATTEMPTS)
|
||||
claim.replace(exhausted)
|
||||
os.utime(exhausted, (stale, stale))
|
||||
|
||||
assert telemetry._claim_parked(exhausted.parent) is None
|
||||
assert not exhausted.exists(), "an abandoned exhausted batch was left behind forever"
|
||||
|
||||
|
||||
def test_a_parked_batch_is_drained_behind_the_live_spool(telemetry):
|
||||
"""Defect 6: parked claims were only reachable when no spool existed.
|
||||
|
||||
Because sessions keep recording there usually was one, so a batch parked by
|
||||
a failed send waited until the 7-day expiry deleted it unsent — even though
|
||||
its own presence is what starts the sender.
|
||||
"""
|
||||
telemetry.record("parked")
|
||||
telemetry._post = lambda payload, url: False
|
||||
telemetry.flush()
|
||||
|
||||
parked = list(telemetry.memory_core.data_dir().glob("telemetry-*.sending"))
|
||||
assert len(parked) == 1
|
||||
old = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(parked[0], (old, old))
|
||||
|
||||
telemetry.record("fresh")
|
||||
sent: list[dict] = []
|
||||
telemetry._post = lambda payload, url: sent.append(payload) or True
|
||||
telemetry.flush()
|
||||
|
||||
names = {event["event"] for event in _delivered(sent)}
|
||||
assert names == {"code.parked", "code.fresh"}
|
||||
|
||||
|
||||
def test_a_batch_is_retried_until_the_budget_is_spent_not_discarded(telemetry):
|
||||
"""Expiry discards what failed repeatedly, not what merely sat for a while.
|
||||
|
||||
The budget is the attempt count, because age cannot be one: every re-claim
|
||||
touches the mtime and every release backdates it, so age never accumulates.
|
||||
"""
|
||||
telemetry.record("parked")
|
||||
telemetry._post = lambda payload, url: False
|
||||
telemetry.flush()
|
||||
|
||||
parked = list(telemetry.memory_core.data_dir().glob("telemetry-*.sending"))
|
||||
assert len(parked) == 1
|
||||
assert telemetry._claim_attempt(parked[0]) < telemetry.MAX_CLAIM_ATTEMPTS
|
||||
|
||||
sent: list[dict] = []
|
||||
telemetry._post = lambda payload, url: sent.append(payload) or True
|
||||
telemetry.flush()
|
||||
|
||||
assert [event["event"] for event in _delivered(sent)] == ["code.parked"]
|
||||
|
||||
|
||||
def test_progress_is_recorded_after_every_batch(telemetry):
|
||||
"""A crash repeats at most one batch, not the whole file."""
|
||||
for index in range(250):
|
||||
telemetry.record("search", index=index)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def die_after_two(payload, url):
|
||||
calls["n"] += 1
|
||||
if calls["n"] > 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
telemetry._post = die_after_two
|
||||
telemetry.flush()
|
||||
|
||||
parked = list(telemetry.memory_core.data_dir().glob("telemetry-*.sending"))
|
||||
assert len(parked) == 1
|
||||
remaining = parked[0].read_text(encoding="utf-8").strip().splitlines()
|
||||
# Two batches of 100 landed; only the last 50 should still be pending.
|
||||
assert len(remaining) == 50
|
||||
assert json.loads(remaining[0])["properties"]["index"] == 200
|
||||
|
||||
|
||||
def test_the_heartbeat_actually_refreshes_the_lease(telemetry):
|
||||
"""The claim rewrite doubles as the lease heartbeat.
|
||||
|
||||
Previously asserted `SEND_TIMEOUT * 4 < CLAIM_STALE_SECONDS`, which compares
|
||||
two constants and executes none of the code under test. Drive the real
|
||||
rewrite and watch the mtime move instead.
|
||||
"""
|
||||
for index in range(150):
|
||||
telemetry.record("search", index=index)
|
||||
claim = telemetry._claim_spool()
|
||||
assert claim is not None
|
||||
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(claim, (stale, stale))
|
||||
assert time.time() - claim.stat().st_mtime > telemetry.CLAIM_STALE_SECONDS
|
||||
|
||||
telemetry._rewrite_claim(claim, [{"event": "code.x", "properties": {}}])
|
||||
assert time.time() - claim.stat().st_mtime < telemetry.CLAIM_STALE_SECONDS
|
||||
|
||||
|
||||
def test_an_undeliverable_batch_is_eventually_given_up_on(telemetry):
|
||||
"""Expiry has to be reachable from a state the state machine can produce.
|
||||
|
||||
It was not: every re-claim touched the mtime and every release backdated it
|
||||
by a fixed amount, so age hovered near the stale threshold and the 7-day
|
||||
expiry never fired. An undeliverable batch lived on disk forever, and
|
||||
spawn_flush saw it and started a sender on every hook.
|
||||
"""
|
||||
telemetry.record("doomed")
|
||||
telemetry._post = lambda payload, url: False
|
||||
|
||||
directory = telemetry.memory_core.data_dir()
|
||||
for _ in range(telemetry.MAX_CLAIM_ATTEMPTS + 3):
|
||||
telemetry.flush()
|
||||
# Attempts now carry a cooldown, so a released claim is not instantly
|
||||
# reclaimable. Age it to stand in for the wall time a real retry waits;
|
||||
# without this the loop spins inside one cooldown and proves nothing.
|
||||
for parked in directory.glob("telemetry-*.sending"):
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(parked, (stale, stale))
|
||||
|
||||
leftover = list(directory.glob("telemetry-*.sending"))
|
||||
assert leftover == [], f"batch never given up on: {[p.name for p in leftover]}"
|
||||
|
||||
|
||||
def test_a_batch_that_cannot_be_read_is_not_counted_as_delivered(telemetry):
|
||||
"""Review finding: a read failure reported 'everything delivered'.
|
||||
|
||||
Nothing was posted, so calling it delivered lets flush() carry on to other
|
||||
claims as though this batch had arrived, and hides the failure from the one
|
||||
signal that says the run went badly. It also must not quarantine: a briefly
|
||||
unreadable file is retryable, and moving it to .corrupt discards the events
|
||||
over a transient filesystem error, because nothing ever re-globs .corrupt.
|
||||
"""
|
||||
telemetry.record("search")
|
||||
spool = telemetry._spool_path()
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(spool, (stale, stale))
|
||||
claim = telemetry._claim_spool()
|
||||
assert claim is not None
|
||||
|
||||
original = Path.read_text
|
||||
|
||||
def unreadable(self, *args, **kwargs):
|
||||
if self == claim:
|
||||
raise OSError(5, "I/O error")
|
||||
return original(self, *args, **kwargs)
|
||||
|
||||
Path.read_text = unreadable
|
||||
try:
|
||||
sent, delivered = telemetry._drain(claim)
|
||||
finally:
|
||||
Path.read_text = original
|
||||
|
||||
assert sent == 0
|
||||
assert delivered is False, "an unread batch was reported as delivered"
|
||||
assert claim.exists(), "a transient read error discarded the batch"
|
||||
assert not list(claim.parent.glob("*.corrupt")), "quarantined over a transient error"
|
||||
|
||||
|
||||
def test_undecodable_content_is_still_quarantined_and_the_run_continues(telemetry):
|
||||
"""The other half: genuinely unrecoverable content must not block the run.
|
||||
|
||||
Guards the over-correction. If every read problem returned undelivered, one
|
||||
torn file would stop every later claim on every flush, forever.
|
||||
"""
|
||||
telemetry.record("search")
|
||||
spool = telemetry._spool_path()
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(spool, (stale, stale))
|
||||
claim = telemetry._claim_spool()
|
||||
assert claim is not None
|
||||
claim.write_bytes(b"\xff\xfe torn \x00 write")
|
||||
|
||||
sent, delivered = telemetry._drain(claim)
|
||||
|
||||
assert (sent, delivered) == (0, True)
|
||||
assert not claim.exists()
|
||||
assert list(claim.parent.glob("*.corrupt")), "unrecoverable content was not quarantined"
|
||||
|
||||
|
||||
def test_retries_are_spread_over_real_time_not_burned_at_once(telemetry):
|
||||
"""Review finding: releasing straight to reclaimable spent the budget instantly.
|
||||
|
||||
Two senders hitting one momentary failure could walk a batch from attempt 0
|
||||
to the limit within seconds and discard it, when a retry a minute later would
|
||||
have delivered. Each release now has to age past a cooldown that grows with
|
||||
the attempts already spent.
|
||||
"""
|
||||
telemetry.record("doomed")
|
||||
telemetry._post = lambda payload, url: False
|
||||
|
||||
directory = telemetry.memory_core.data_dir()
|
||||
telemetry.flush()
|
||||
|
||||
parked = list(directory.glob("telemetry-*.sending"))
|
||||
assert parked, "the batch was discarded on its first failure"
|
||||
assert telemetry._claim_attempt(parked[0]) == 0
|
||||
|
||||
# Second sender, immediately: the cooldown has not elapsed, so it must not
|
||||
# be able to spend another attempt.
|
||||
telemetry.flush()
|
||||
still = list(directory.glob("telemetry-*.sending"))
|
||||
assert len(still) == 1
|
||||
assert telemetry._claim_attempt(still[0]) <= 1, "burned attempts without waiting"
|
||||
|
||||
|
||||
def test_a_legacy_claim_filename_is_not_mistaken_for_a_huge_attempt_count(telemetry):
|
||||
"""The old shape is telemetry-<pid>-<hex>.sending, and hex can start with 'a'."""
|
||||
assert telemetry._claim_attempt(Path("telemetry-999-deadbeef.sending")) == 0
|
||||
assert telemetry._claim_attempt(Path("telemetry-999-a1234567.sending")) == 0
|
||||
assert telemetry._claim_attempt(Path("telemetry-999-deadbeef-a2.sending")) == 2
|
||||
|
||||
|
||||
def test_a_torn_claim_is_quarantined_not_deleted(telemetry):
|
||||
"""A non-empty file that parses to nothing is the remainder, not garbage."""
|
||||
telemetry.record("search")
|
||||
claim = telemetry._claim_spool()
|
||||
claim.write_bytes(b"\xff\xfe not utf-8 at all")
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(claim, (stale, stale))
|
||||
|
||||
sent = telemetry.flush()
|
||||
|
||||
assert sent == 0
|
||||
assert not claim.exists()
|
||||
quarantined = list(telemetry.memory_core.data_dir().glob("*.corrupt"))
|
||||
assert len(quarantined) == 1, "torn claim was destroyed instead of kept"
|
||||
|
||||
|
||||
def test_a_failed_rewrite_stops_instead_of_redelivering(telemetry):
|
||||
"""Ignoring the rewrite result reintroduced the duplicates this PR fixes."""
|
||||
for index in range(250):
|
||||
telemetry.record("search", index=index)
|
||||
|
||||
telemetry._rewrite_claim = lambda claim, remaining: False
|
||||
delivered = []
|
||||
telemetry._post = lambda payload, url: delivered.extend(payload.get("batch", [])) or True
|
||||
|
||||
telemetry.flush()
|
||||
assert len(delivered) == 100, f"kept going after a failed rewrite: {len(delivered)}"
|
||||
|
||||
|
||||
def test_partial_files_are_swept(telemetry):
|
||||
"""Nothing else globs *.partial, so a crash mid-rename orphans one forever."""
|
||||
data_dir = telemetry.memory_core.data_dir()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
debris = data_dir / "telemetry-1-abc-a0.1.partial"
|
||||
debris.write_text("x", encoding="utf-8")
|
||||
old = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(debris, (old, old))
|
||||
|
||||
telemetry.flush()
|
||||
assert not debris.exists()
|
||||
|
||||
|
||||
def test_quarantined_batches_are_eventually_collected(telemetry):
|
||||
"""Nothing re-globs .corrupt, so without a sweep they live on disk forever.
|
||||
|
||||
Kept much longer than .partial debris on purpose: a quarantined batch is the
|
||||
only remaining evidence of events that could not be delivered.
|
||||
"""
|
||||
directory = telemetry.memory_core.data_dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
fresh = directory / "telemetry-1-aaaaaaaa-a0.corrupt"
|
||||
old = directory / "telemetry-2-bbbbbbbb-a0.corrupt"
|
||||
for path in (fresh, old):
|
||||
path.write_text("torn", encoding="utf-8")
|
||||
expired = time.time() - (telemetry.CLAIM_EXPIRY_SECONDS + 60)
|
||||
os.utime(old, (expired, expired))
|
||||
|
||||
telemetry._sweep_debris(directory)
|
||||
|
||||
assert fresh.exists(), "a recent quarantine was discarded before anyone could look at it"
|
||||
assert not old.exists(), "an expired quarantine was left on disk forever"
|
||||
|
||||
|
||||
def test_temp_files_orphaned_by_a_kill_are_collected(telemetry):
|
||||
"""_write_identity and _install_salt unlink in a finally, which SIGKILL skips."""
|
||||
directory = telemetry.memory_core.data_dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
orphan = directory / "telemetry-salt.999.tmp"
|
||||
orphan.write_text("abandoned", encoding="utf-8")
|
||||
stale = time.time() - (telemetry.CLAIM_STALE_SECONDS + 60)
|
||||
os.utime(orphan, (stale, stale))
|
||||
|
||||
telemetry._sweep_debris(directory)
|
||||
|
||||
assert not orphan.exists(), "a killed process left a temp file on disk forever"
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Core telemetry behaviour with NO telemetry.init(), in a real subprocess.
|
||||
|
||||
Why this file exists
|
||||
--------------------
|
||||
``telemetry.py`` lives in ``agent-plugin-core/python/`` but its only tests lived
|
||||
under ``claude-code-plugin/tests/``, behind a ``conftest.py`` that calls
|
||||
``configure_harness()`` and ``telemetry.init()`` at import. Core behaviour was
|
||||
therefore only ever exercised inside an already-configured module.
|
||||
|
||||
Two processes in the real pipeline never call ``init()``:
|
||||
|
||||
- ``mcp_server.py``, which records every manual search;
|
||||
- the detached ``python3 telemetry.py`` sender that ``spawn_flush()`` starts at
|
||||
session start, after every skill command, and when the MCP server exits.
|
||||
|
||||
Both fell back to module defaults, so MCP searches reported ``harness=generic``
|
||||
and everything that sender delivered was labelled ``MEM0_PLUGIN`` regardless of
|
||||
which of the six plugins produced it. The suite stayed green throughout.
|
||||
|
||||
These tests run in a fresh interpreter with no conftest, against a built host
|
||||
bundle, which is the only arrangement that can catch that class of bug.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = CORE_ROOT.parents[1]
|
||||
HOSTS = {
|
||||
"claude-code": ("claude-code-plugin", "CLAUDE_CODE_PLUGIN"),
|
||||
"cursor": ("cursor-plugin", "CURSOR_PLUGIN"),
|
||||
"codex": ("codex-plugin", "CODEX_PLUGIN"),
|
||||
"kimi": ("kimi-plugin", "KIMI_PLUGIN"),
|
||||
"antigravity": ("antigravity-plugin", "ANTIGRAVITY_PLUGIN"),
|
||||
# Portable: no flush_worker and no hook_runner, so its ONLY sender is the
|
||||
# uninitialised telemetry.py. A native-only test passes here vacuously.
|
||||
"coding-agent": ("mem0-agent-plugin", "CODING_AGENT_PLUGIN"),
|
||||
}
|
||||
|
||||
|
||||
def _core_dir(directory: str) -> Path:
|
||||
return REPOSITORY_ROOT / "integrations" / directory / "core"
|
||||
|
||||
|
||||
def _run(core: Path, data_dir: Path, body: str) -> str:
|
||||
"""Execute `body` in a fresh interpreter with only the host's core on sys.path."""
|
||||
script = f"import sys; sys.path.insert(0, {str(core)!r})\n{body}"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
"MEM0_CODE_DATA_DIR": str(data_dir),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"HOME": str(data_dir),
|
||||
},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness,spec", sorted(HOSTS.items()))
|
||||
def test_identity_resolves_without_init(harness, spec):
|
||||
"""Every built host knows what it is with no configuration call at all."""
|
||||
directory, source_tag = spec
|
||||
core = _core_dir(directory)
|
||||
if not core.exists():
|
||||
pytest.skip(f"{directory} is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = _run(
|
||||
core,
|
||||
Path(tmp),
|
||||
"import telemetry; print(telemetry._harness, telemetry._source_tag)",
|
||||
)
|
||||
assert out == f"{harness} {source_tag}"
|
||||
|
||||
|
||||
def test_mcp_server_records_the_real_harness():
|
||||
"""mcp_server imports telemetry and never initialises it (server.py has no init).
|
||||
|
||||
Its recorded events used to carry harness=generic for every plugin.
|
||||
"""
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data_dir = Path(tmp)
|
||||
_run(
|
||||
core,
|
||||
data_dir,
|
||||
"import mcp_server, telemetry; telemetry.record('search', trigger='mcp-search')",
|
||||
)
|
||||
spooled = (data_dir / "telemetry.jsonl").read_text(encoding="utf-8").strip()
|
||||
|
||||
event = json.loads(spooled)
|
||||
assert event["properties"]["harness"] == "claude-code"
|
||||
assert event["properties"]["source"] == "CLAUDE_CODE_PLUGIN"
|
||||
|
||||
|
||||
def test_the_detached_sender_does_not_relabel_events():
|
||||
"""`python3 telemetry.py` is the sender spawn_flush() starts, and never inits.
|
||||
|
||||
source is stamped at record time now, so which process sends is irrelevant.
|
||||
"""
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data_dir = Path(tmp)
|
||||
_run(core, data_dir, "import telemetry; telemetry.record('search')")
|
||||
|
||||
captured = data_dir / "captured.json"
|
||||
# Drain with a fresh, unconfigured interpreter, capturing the payload
|
||||
# instead of posting it.
|
||||
_run(
|
||||
core,
|
||||
data_dir,
|
||||
"import json, telemetry\n"
|
||||
"sent = []\n"
|
||||
"telemetry._post = lambda payload, url: sent.append(payload) or True\n"
|
||||
"telemetry.flush()\n"
|
||||
f"open({str(captured)!r}, 'w').write(json.dumps(sent))",
|
||||
)
|
||||
payloads = json.loads(captured.read_text(encoding="utf-8"))
|
||||
|
||||
batches = [p for p in payloads if "batch" in p]
|
||||
assert batches, "nothing was sent"
|
||||
properties = batches[0]["batch"][0]["properties"]
|
||||
assert properties["source"] == "CLAUDE_CODE_PLUGIN"
|
||||
assert properties["harness"] == "claude-code"
|
||||
|
||||
|
||||
def test_every_event_carries_a_uuid_for_dedupe():
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data_dir = Path(tmp)
|
||||
_run(core, data_dir, "import telemetry; telemetry.record('search'); telemetry.record('flush')")
|
||||
lines = (data_dir / "telemetry.jsonl").read_text(encoding="utf-8").strip().splitlines()
|
||||
|
||||
ids = [json.loads(line)["uuid"] for line in lines]
|
||||
assert len(ids) == 2
|
||||
assert len(set(ids)) == 2
|
||||
|
||||
|
||||
def test_source_tag_defaults_agree_between_the_two_modules():
|
||||
"""configure_harness and telemetry.init must derive the same tag.
|
||||
|
||||
They disagreed: `<host>_plugin` in one and `MEM0_<HOST>_PLUGIN` in the other,
|
||||
so one plugin could emit three different source values depending on which
|
||||
process sent the batch.
|
||||
"""
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = _run(
|
||||
core,
|
||||
Path(tmp),
|
||||
"import memory_core, telemetry\n"
|
||||
"memory_core.configure_harness('kimi')\n"
|
||||
"telemetry.init(harness='kimi')\n"
|
||||
"print(memory_core.harness_config()['source_tag'].upper(), telemetry._source_tag)",
|
||||
)
|
||||
left, right = out.split()
|
||||
assert left == right == "KIMI_PLUGIN"
|
||||
|
||||
|
||||
def test_the_plugin_declares_its_surface_in_the_body_and_the_headers():
|
||||
"""Body and headers both, because only the body works on every backend."""
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = _run(
|
||||
core,
|
||||
Path(tmp),
|
||||
"import json, memory_core\n"
|
||||
"h = memory_core.platform_headers('k')\n"
|
||||
"print(json.dumps({'source': h.get('X-Mem0-Source'),"
|
||||
" 'app': h.get('X-Application'),"
|
||||
" 'client': h.get('X-Mem0-Client'),"
|
||||
" 'auth': h.get('Authorization'),"
|
||||
" 'ctype': h.get('Content-Type')}))",
|
||||
)
|
||||
headers = json.loads(out)
|
||||
assert headers["source"] == "MEM0_PLUGIN"
|
||||
assert headers["app"] == "claude-code"
|
||||
assert headers["client"].startswith("mem0-plugin/")
|
||||
# The transport headers the three call sites relied on must survive.
|
||||
assert headers["auth"] == "Token k"
|
||||
assert headers["ctype"] == "application/json"
|
||||
|
||||
|
||||
def _session_start(core: Path, data_dir: Path) -> list[str]:
|
||||
"""Drive the real hook_runner session-start path and return lifecycle events."""
|
||||
recorded = "\n".join(
|
||||
[
|
||||
"import io, json, sys",
|
||||
f"sys.path.insert(0, {str(core)!r})",
|
||||
"import telemetry, hook_runner",
|
||||
"seen = []",
|
||||
"telemetry.record = lambda event, **kw: seen.append(event) or None",
|
||||
"telemetry.spawn_flush = lambda: False",
|
||||
# run() reads sys.argv through argparse; it takes no positional args.
|
||||
"sys.argv = ['hook_runner', 'session-start']",
|
||||
"sys.stdin = io.StringIO('{}')",
|
||||
"hook_runner.run()",
|
||||
"print(json.dumps([e for e in seen if e in ('install', 'upgrade')]))",
|
||||
]
|
||||
)
|
||||
import json as _json
|
||||
|
||||
return _json.loads(_run(core, data_dir, recorded) or "[]")
|
||||
|
||||
|
||||
def test_a_fresh_install_reports_install_not_upgrade():
|
||||
"""The decision must survive the writes hook_runner does before asking.
|
||||
|
||||
claim_install() is reached only after cache_plugin_api_key() has written
|
||||
`api-key` and EvidenceStore() has created `evidence.sqlite3`. Asking "is the
|
||||
data dir empty" at that point always saw content, so code.install could
|
||||
never fire and every new user was counted as an upgrade.
|
||||
"""
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data_dir = Path(tmp) / "data"
|
||||
assert _session_start(core, data_dir) == ["install"]
|
||||
|
||||
|
||||
def test_the_lifecycle_event_fires_exactly_once():
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data_dir = Path(tmp) / "data"
|
||||
first = _session_start(core, data_dir)
|
||||
second = _session_start(core, data_dir)
|
||||
third = _session_start(core, data_dir)
|
||||
|
||||
assert first == ["install"]
|
||||
assert second == []
|
||||
assert third == []
|
||||
|
||||
|
||||
def test_an_existing_data_dir_reports_upgrade():
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data_dir = Path(tmp) / "data"
|
||||
data_dir.mkdir(parents=True)
|
||||
# A 0.2.x leftover: the data dir survives the upgrade.
|
||||
(data_dir / "requirements.txt").write_text("mem0ai\n", encoding="utf-8")
|
||||
assert _session_start(core, data_dir) == ["upgrade"]
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { redactSecrets } from "./lifecycle.ts";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
@@ -74,34 +76,108 @@ export function errorKind(error: unknown): string {
|
||||
return error instanceof Error ? error.constructor.name : "other";
|
||||
}
|
||||
|
||||
// Delivery is retried in memory, not spooled to disk, and that is a decision
|
||||
// rather than an omission. The Python core spools because its hooks are separate
|
||||
// processes that fire per tool call and exit immediately, so nothing survives
|
||||
// without a file. These plugins are loaded into a host that lives for a whole
|
||||
// session, so re-queueing covers the same transient failures without the claim
|
||||
// and lease machinery a correct cross-process spool needs. What that leaves
|
||||
// uncovered is narrow: a session that both starts and ends with no connectivity.
|
||||
const RETRY_BACKOFF_CEILING_MS = 60_000;
|
||||
// Consecutive failed flushes before the queue is dropped. Deliberately NOT the
|
||||
// same thing as Python's budget, which rides in the claim filename and so
|
||||
// follows one batch: this counter lives in the closure and counts the outage,
|
||||
// not the payload. Events captured between attempts join the same queue and go
|
||||
// with it. Per-batch accounting would need an attempt count on every event, and
|
||||
// the queue is already bounded, so the simpler rule is the one in force here.
|
||||
// Without any bound a payload the server will never accept is retried for the
|
||||
// whole session and, now that the backlog is preferred over new events, holds
|
||||
// the queue against everything behind it.
|
||||
const MAX_DELIVERY_ATTEMPTS = 5;
|
||||
|
||||
export function createTelemetry(config: TelemetryConfig) {
|
||||
let queue: Record<string, unknown>[] = [];
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let consecutiveFailures = 0;
|
||||
let retryNotBefore = 0;
|
||||
let exitFlushAttempted = false;
|
||||
let flushing = false;
|
||||
const flushThreshold = config.flushThreshold ?? 10;
|
||||
const maxQueueSize = config.maxQueueSize ?? 100;
|
||||
|
||||
const deliver = config.delivery ?? (async (batch: Record<string, unknown>[]) => {
|
||||
await fetch(POSTHOG_BATCH_URL, {
|
||||
const response = await fetch(POSTHOG_BATCH_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
// fetch only rejects on a network-level failure. Without this check a 500,
|
||||
// a 503 or a 429 resolved normally and the batch was counted as delivered
|
||||
// and dropped, which is the likelier outage than a refused connection.
|
||||
// Any non-2xx is retried, matching the Python core: the backoff and the
|
||||
// queue bound contain a payload that will never be accepted, because the
|
||||
// re-queued batch sits at the front and is the first thing evicted.
|
||||
if (!response.ok) throw new Error(`posthog responded ${response.status}`);
|
||||
});
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
async function flush(force = false): Promise<void> {
|
||||
// One at a time. Two overlapping flushes each detach the queue and each
|
||||
// prepend their own batch back on failure, so the later batch lands in front
|
||||
// of the earlier one and the truncation then drops the OLDER events first,
|
||||
// inverting the priority the failure path exists to establish. A second
|
||||
// caller returns immediately; the queue waits for the next flush.
|
||||
if (flushing) return;
|
||||
if (!queue.length) return;
|
||||
// `force` skips the cooldown. beforeExit is the last chance this process
|
||||
// gets, and gating it on the same backoff meant that after any failure the
|
||||
// exit flush did nothing and the queue died with the process, which is the
|
||||
// loss this whole mechanism exists to prevent.
|
||||
if (!force && Date.now() < retryNotBefore) return;
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
flushing = true;
|
||||
try {
|
||||
await deliver(batch);
|
||||
consecutiveFailures = 0;
|
||||
retryNotBefore = 0;
|
||||
} catch {
|
||||
// Telemetry must never affect plugin behavior.
|
||||
// Put it back. Detaching the batch and swallowing the error deleted the
|
||||
// events outright, so any blip silently dropped telemetry with nothing
|
||||
// recording that it had happened. Every event carries a uuid, so a retry
|
||||
// that duplicates one PostHog already accepted is collapsed there.
|
||||
//
|
||||
consecutiveFailures += 1;
|
||||
if (consecutiveFailures >= MAX_DELIVERY_ATTEMPTS) {
|
||||
// Give up on the queue so a failing outage cannot hold it for the
|
||||
// session. This drops whatever is queued now, which includes events
|
||||
// captured during the outage, not only the batch that kept failing.
|
||||
consecutiveFailures = 0;
|
||||
retryNotBefore = 0;
|
||||
return;
|
||||
}
|
||||
// Keep the FRONT on overflow, so the batch being retried survives and a
|
||||
// new event is what gets dropped. Matches the Python core, where record()
|
||||
// refuses new events once the spool is full rather than evicting the
|
||||
// backlog. Keeping the newest would throw away exactly the events this
|
||||
// retry exists to save.
|
||||
queue = [...batch, ...queue].slice(0, maxQueueSize);
|
||||
retryNotBefore = Date.now() + Math.min(2 ** consecutiveFailures * 1_000, RETRY_BACKOFF_CEILING_MS);
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function beforeExit(): void {
|
||||
void flush();
|
||||
// Once, and only once. Node re-emits beforeExit whenever the handler
|
||||
// schedules more async work, so an unconditional forced flush looped until
|
||||
// the attempt budget was spent: five attempts against a 3s delivery timeout
|
||||
// is fifteen seconds added to the shutdown of whatever editor or CLI is
|
||||
// hosting this. The backoff used to end that loop after one attempt, and
|
||||
// removing it for the forced path removed the only thing bounding it.
|
||||
if (exitFlushAttempted) return;
|
||||
exitFlushAttempted = true;
|
||||
void flush(true);
|
||||
}
|
||||
|
||||
function build(event: string, properties: Record<string, unknown> = {}): Record<string, unknown> | null {
|
||||
@@ -112,6 +188,15 @@ export function createTelemetry(config: TelemetryConfig) {
|
||||
return {
|
||||
event: config.eventName?.(event) ?? event,
|
||||
distinct_id: distinctId,
|
||||
// Stamped once, at capture. This is what makes retrying safe: a batch
|
||||
// re-sent after a failure carries the same ids, so PostHog collapses
|
||||
// anything it already accepted instead of counting it twice.
|
||||
uuid: randomUUID(),
|
||||
// Capture time, not ingestion time. Events now sit through backoff and
|
||||
// across a whole outage, so without this PostHog records them whenever
|
||||
// delivery happened to succeed. It also matters for the uuid dedupe
|
||||
// above, whose key includes the event date.
|
||||
timestamp: new Date().toISOString(),
|
||||
properties: {
|
||||
...safeProperties(properties),
|
||||
...safeProperties(config.commonProperties ?? {}),
|
||||
@@ -134,8 +219,10 @@ export function createTelemetry(config: TelemetryConfig) {
|
||||
try {
|
||||
const payload = build(event, properties);
|
||||
if (!payload) return;
|
||||
// Full means drop this event, not evict the backlog. Same rule as the
|
||||
// failure path above and as Python's record().
|
||||
if (queue.length >= maxQueueSize) return;
|
||||
queue.push(payload);
|
||||
if (queue.length > maxQueueSize) queue = queue.slice(-maxQueueSize);
|
||||
if (!timer) {
|
||||
timer = setInterval(() => void flush(), config.flushIntervalMs ?? 5_000);
|
||||
timer.unref?.();
|
||||
@@ -149,6 +236,8 @@ export function createTelemetry(config: TelemetryConfig) {
|
||||
|
||||
function resetForTesting(): void {
|
||||
queue = [];
|
||||
consecutiveFailures = 0;
|
||||
retryNotBefore = 0;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
process.off("beforeExit", beforeExit);
|
||||
|
||||
@@ -122,3 +122,250 @@ test("error classification does not expose messages", () => {
|
||||
assert.equal(errorKind(new Error("request timeout")), "timeout");
|
||||
assert.equal(errorKind(new Error("fetch failed")), "network");
|
||||
});
|
||||
|
||||
test("a failed delivery keeps the batch instead of deleting it", async () => {
|
||||
// The defect: the queue was detached before the await and the error swallowed,
|
||||
// so one blip destroyed the events with nothing recording that it happened.
|
||||
const attempts: Record<string, unknown>[][] = [];
|
||||
let failNext = true;
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d",
|
||||
flushThreshold: 1000,
|
||||
delivery: async (batch) => {
|
||||
attempts.push(batch);
|
||||
if (failNext) throw new Error("network down");
|
||||
},
|
||||
});
|
||||
|
||||
telemetry.capture("one");
|
||||
telemetry.capture("two");
|
||||
await telemetry.flush();
|
||||
|
||||
assert.equal(attempts.length, 1);
|
||||
assert.equal(telemetry.queueForTesting().length, 2, "events were dropped on failure");
|
||||
|
||||
failNext = false;
|
||||
// Backoff is in force, so wait it out the way wall time would.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_100));
|
||||
await telemetry.flush();
|
||||
|
||||
assert.equal(attempts.length, 2, "never retried");
|
||||
assert.equal(telemetry.queueForTesting().length, 0);
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("a retried event carries the same uuid so PostHog can collapse it", async () => {
|
||||
const attempts: Record<string, unknown>[][] = [];
|
||||
let failNext = true;
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d",
|
||||
flushThreshold: 1000,
|
||||
delivery: async (batch) => {
|
||||
attempts.push(batch);
|
||||
if (failNext) throw new Error("network down");
|
||||
},
|
||||
});
|
||||
|
||||
telemetry.capture("once");
|
||||
await telemetry.flush();
|
||||
failNext = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_100));
|
||||
await telemetry.flush();
|
||||
|
||||
assert.equal(attempts.length, 2);
|
||||
const first = attempts[0][0].uuid;
|
||||
assert.ok(first, "events carry no uuid, so a retry would double count");
|
||||
assert.equal(attempts[1][0].uuid, first, "retry minted a new uuid");
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("repeated failures back off instead of retrying every flush", async () => {
|
||||
let calls = 0;
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d",
|
||||
flushThreshold: 1000,
|
||||
delivery: async () => { calls += 1; throw new Error("blocked"); },
|
||||
});
|
||||
|
||||
telemetry.capture("one");
|
||||
await telemetry.flush();
|
||||
await telemetry.flush();
|
||||
await telemetry.flush();
|
||||
|
||||
assert.equal(calls, 1, "a blocked host was hammered on every flush");
|
||||
assert.equal(telemetry.queueForTesting().length, 1, "the event was lost while backing off");
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("a full queue drops the new event and keeps the batch being retried", async () => {
|
||||
// Python's record() refuses new events once the spool is full rather than
|
||||
// evicting the backlog. Keeping the newest here would throw away exactly the
|
||||
// events the retry exists to save.
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d",
|
||||
flushThreshold: 1000, maxQueueSize: 3,
|
||||
delivery: async () => { throw new Error("down"); },
|
||||
});
|
||||
|
||||
// Fill past the cap BEFORE the flush, so the re-queue actually has to truncate.
|
||||
// Capturing only two left the queue empty at re-queue time and the slice on the
|
||||
// failure path never ran, which is the half that decides the direction.
|
||||
telemetry.capture("a");
|
||||
telemetry.capture("b");
|
||||
telemetry.capture("c");
|
||||
await telemetry.flush();
|
||||
telemetry.capture("d");
|
||||
telemetry.capture("e");
|
||||
|
||||
const events = telemetry.queueForTesting().map((e) => (e as any).event);
|
||||
assert.equal(events.length, 3, "queue grew past maxQueueSize");
|
||||
assert.deepEqual(events, ["a", "b", "c"], "the retried batch was evicted instead of the new events");
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("the exit-time flush ignores the backoff", async () => {
|
||||
// beforeExit is the last chance the process gets. Gating it on the same
|
||||
// cooldown meant that after any failure it did nothing and the queue died.
|
||||
let attempts = 0;
|
||||
let failing = true;
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
delivery: async () => { attempts += 1; if (failing) throw new Error("down"); },
|
||||
});
|
||||
|
||||
telemetry.capture("a");
|
||||
await telemetry.flush();
|
||||
assert.equal(attempts, 1);
|
||||
|
||||
failing = false;
|
||||
await telemetry.flush();
|
||||
assert.equal(attempts, 1, "the backoff should still hold for an ordinary flush");
|
||||
|
||||
await telemetry.flush(true);
|
||||
assert.equal(attempts, 2, "the exit flush was suppressed by the backoff");
|
||||
assert.equal(telemetry.queueForTesting().length, 0);
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("every event carries a capture-time timestamp", async () => {
|
||||
const sent: Record<string, unknown>[][] = [];
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
delivery: async (batch) => { sent.push(batch); },
|
||||
});
|
||||
|
||||
telemetry.capture("a");
|
||||
const capturedAt = Date.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await telemetry.flush();
|
||||
|
||||
const stamped = sent[0][0].timestamp as string;
|
||||
assert.ok(stamped, "no timestamp, so PostHog would record delivery time");
|
||||
assert.ok(Math.abs(Date.parse(stamped) - capturedAt) < 1_000, "not capture time");
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("a batch the server will never accept is eventually given up on", async () => {
|
||||
let attempts = 0;
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
delivery: async () => { attempts += 1; throw new Error("permanently bad"); },
|
||||
});
|
||||
|
||||
telemetry.capture("doomed");
|
||||
for (let i = 0; i < 8; i += 1) await telemetry.flush(true);
|
||||
|
||||
assert.ok(attempts <= 6, `retried ${attempts} times with no cap`);
|
||||
assert.equal(telemetry.queueForTesting().length, 0, "a doomed batch held the queue forever");
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("an HTTP error response is a failure, not a delivery", async () => {
|
||||
// fetch only rejects on a network-level failure, so a 500 used to resolve
|
||||
// normally and the batch was dropped as delivered. Exercises the real default
|
||||
// delivery path rather than an injected one, which is where this hid.
|
||||
const realFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
calls += 1;
|
||||
return new Response("upstream is unwell", { status: 503 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
});
|
||||
try {
|
||||
telemetry.capture("during.outage");
|
||||
await telemetry.flush();
|
||||
|
||||
assert.equal(calls, 1, "never reached the network");
|
||||
assert.equal(telemetry.queueForTesting().length, 1, "a 503 was counted as delivered");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
telemetry.resetForTesting();
|
||||
}
|
||||
});
|
||||
|
||||
test("a 2xx is a delivery", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response("ok", { status: 200 })) as typeof fetch;
|
||||
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
});
|
||||
try {
|
||||
telemetry.capture("fine");
|
||||
await telemetry.flush();
|
||||
assert.equal(telemetry.queueForTesting().length, 0, "a good response did not clear the queue");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
telemetry.resetForTesting();
|
||||
}
|
||||
});
|
||||
|
||||
test("the exit flush is attempted once, not until the budget is spent", async () => {
|
||||
// Node re-emits beforeExit whenever the handler schedules async work, so an
|
||||
// unconditional forced flush looped until MAX_DELIVERY_ATTEMPTS. Against the
|
||||
// real 3s delivery timeout that is fifteen seconds added to a host's shutdown.
|
||||
let attempts = 0;
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
delivery: async () => { attempts += 1; throw new Error("down"); },
|
||||
});
|
||||
|
||||
telemetry.capture("a");
|
||||
const handlers = process.listeners("beforeExit");
|
||||
const ours = handlers[handlers.length - 1] as () => void;
|
||||
ours();
|
||||
ours();
|
||||
ours();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.equal(attempts, 1, `exit flush ran ${attempts} times`);
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
test("overlapping flushes do not reorder the backlog behind newer events", async () => {
|
||||
// Each flush detaches the queue and prepends its own batch back on failure, so
|
||||
// two in flight at once put the LATER batch in front of the earlier one. The
|
||||
// truncation then drops the older events first, inverting the priority the
|
||||
// failure path exists to establish.
|
||||
let release: (() => void)[] = [];
|
||||
const telemetry = createTelemetry({
|
||||
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
|
||||
delivery: () => new Promise((_resolve, reject) => { release.push(() => reject(new Error("down"))); }),
|
||||
});
|
||||
|
||||
telemetry.capture("first");
|
||||
const a = telemetry.flush();
|
||||
telemetry.capture("second");
|
||||
const b = telemetry.flush();
|
||||
|
||||
release.forEach((fn) => fn());
|
||||
await Promise.all([a, b]);
|
||||
|
||||
const events = telemetry.queueForTesting().map((e) => (e as any).event);
|
||||
assert.equal(release.length, 1, "a second delivery started while one was in flight");
|
||||
assert.deepEqual(events, ["first", "second"], `backlog reordered: ${events.join(",")}`);
|
||||
telemetry.resetForTesting();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
|
||||
|
||||
HARNESS_ID = "antigravity"
|
||||
SOURCE_TAG = "ANTIGRAVITY_PLUGIN"
|
||||
|
||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||
# plugin family is one source; which editor it runs in is the application.
|
||||
# An empty application means the host is unknown, and memory_core omits
|
||||
# the header entirely rather than sending a placeholder.
|
||||
PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
PLATFORM_APPLICATION = "antigravity"
|
||||
@@ -290,6 +290,11 @@ def run(
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
|
||||
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
|
||||
# after them always saw content and every fresh install reported an upgrade.
|
||||
data_dir_was_empty = telemetry.data_dir_was_empty()
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
@@ -305,8 +310,19 @@ def run(
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
# Claims the marker atomically and says which event to record, so a
|
||||
# second session starting alongside this one cannot record it too.
|
||||
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
|
||||
if first_event == "install":
|
||||
telemetry.record("install")
|
||||
elif first_event == "upgrade":
|
||||
# First run after a build that never wrote the marker; the
|
||||
# predecessor version was never recorded anywhere.
|
||||
telemetry.record("upgrade", from_version="pre-0.3")
|
||||
else:
|
||||
previous = telemetry.claim_version_change()
|
||||
if previous:
|
||||
telemetry.record("upgrade", from_version=previous)
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable
|
||||
import telemetry
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
PLUGIN_VERSION = "0.3.2"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
||||
return batches
|
||||
|
||||
|
||||
# Platform surface attribution. Read from the generated per-host module so a new
|
||||
# entrypoint is correct without remembering to configure anything.
|
||||
try: # pragma: no cover - absent only in the un-built shared source tree
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
|
||||
def platform_headers(key: str) -> dict[str, str]:
|
||||
"""Auth plus the three surface-identity headers.
|
||||
|
||||
X-Mem0-Source and X-Application are set-once by contract: this is the
|
||||
outermost layer, so it sets them, and nothing below may overwrite them.
|
||||
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": _PLATFORM_SOURCE,
|
||||
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
|
||||
}
|
||||
if _PLATFORM_APPLICATION:
|
||||
headers["X-Application"] = _PLATFORM_APPLICATION
|
||||
return headers
|
||||
|
||||
|
||||
def _request_json(
|
||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=raw,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
||||
"user_id": write_user,
|
||||
"app_id": repo.app_id,
|
||||
"run_id": session_id,
|
||||
# Top level, not metadata: the backend reads `source` from the body or
|
||||
# the query string, never from metadata, which is where this used to
|
||||
# sit. The X-Mem0-Source header is also read, but only from the
|
||||
# platform release that ships alongside this change, so the body value
|
||||
# is what makes attribution work on both. The harness tag stays in
|
||||
# metadata as hook provenance.
|
||||
"source": _PLATFORM_SOURCE,
|
||||
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
|
||||
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
|
||||
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||
request = urllib.request.Request(
|
||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="DELETE",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anonymous usage telemetry for Mem0 agent plugins.
|
||||
"""Usage telemetry for Mem0 agent plugins.
|
||||
|
||||
Events are linked to your Mem0 account email when an API key is configured, and
|
||||
to a random per-machine id otherwise. Not anonymous — the Python SDK and CLI
|
||||
attribute the same way.
|
||||
|
||||
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
|
||||
touches the network: `record` appends one JSON line to a local spool and returns.
|
||||
@@ -9,7 +13,8 @@ started once per session and again from the flush worker that is already detache
|
||||
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
|
||||
|
||||
Never sends prompts, memory text, queries, file paths, repository names, or API
|
||||
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
|
||||
keys: only event names, durations, counts, coarse outcomes, and repo/session
|
||||
identifiers hashed with a random per-install salt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,8 +34,24 @@ from typing import Any
|
||||
|
||||
import memory_core
|
||||
|
||||
_harness: str = "generic"
|
||||
_source_tag: str = "MEM0_PLUGIN"
|
||||
# Seeded from the per-host module the build generates into core/. Two processes
|
||||
# in this pipeline never call init() — mcp_server.py, and the detached
|
||||
# `python3 telemetry.py` sender that spawn_flush() starts — so a module default
|
||||
# was what every one of their events got labelled with.
|
||||
try: # pragma: no cover - absent only in the un-built shared source tree
|
||||
from _harness_id import HARNESS_ID as _DEFAULT_HARNESS
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG
|
||||
except ImportError:
|
||||
_DEFAULT_HARNESS = "generic"
|
||||
_DEFAULT_SOURCE_TAG = "MEM0_PLUGIN"
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
_salt_cache: str = ""
|
||||
_harness: str = _DEFAULT_HARNESS
|
||||
_source_tag: str = _DEFAULT_SOURCE_TAG
|
||||
_PRIVATE_KEYS = {
|
||||
"apikey",
|
||||
"authorization",
|
||||
@@ -56,10 +77,19 @@ _PRIVATE_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def init(harness: str = "generic", source_tag: str = "") -> None:
|
||||
def init(harness: str = "", source_tag: str = "") -> None:
|
||||
"""Override the generated identity. Optional — core/_harness_id.py is the default.
|
||||
|
||||
The fallback shape matches memory_core.configure_harness's (``<HOST>_PLUGIN``).
|
||||
It used to be ``MEM0_<HOST>_PLUGIN`` here and ``<host>_plugin`` there, which
|
||||
meant one plugin could emit three different source values depending on which
|
||||
process happened to send the batch.
|
||||
"""
|
||||
global _harness, _source_tag
|
||||
_harness = harness
|
||||
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
|
||||
_harness = harness or _DEFAULT_HARNESS
|
||||
_source_tag = source_tag or (
|
||||
f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG
|
||||
)
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
|
||||
@@ -70,6 +100,16 @@ BATCH_SIZE = 100
|
||||
SEND_TIMEOUT = 5
|
||||
CLAIM_STALE_SECONDS = 120
|
||||
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
# A batch is only discarded once it has genuinely been retried this many times.
|
||||
MAX_CLAIM_ATTEMPTS = 3
|
||||
# Parked claims drained per run, after the live spool. Bounded so a long backlog
|
||||
# cannot turn one flush into an unbounded send loop.
|
||||
MAX_PARKED_PER_RUN = 3
|
||||
# Added to the wait before a released claim becomes reclaimable, per attempt
|
||||
# already spent. Releasing straight to "reclaimable now" let two senders burn the
|
||||
# whole budget within seconds of one another on a single momentary failure, and
|
||||
# discard a batch a retry a minute later would have delivered.
|
||||
RETRY_COOLDOWN_SECONDS = 60
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
@@ -83,9 +123,126 @@ def is_enabled() -> bool:
|
||||
|
||||
|
||||
def _digest(value: str, length: int = 16) -> str:
|
||||
"""Unsalted digest. Only for values that are already secrets (API keys)."""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _salt_path() -> Path:
|
||||
return memory_core.data_dir() / "telemetry-salt"
|
||||
|
||||
|
||||
def _install_salt() -> str:
|
||||
"""Random per-install salt, created once and memoized for the process.
|
||||
|
||||
Deliberately its own file, claimed with O_CREAT|O_EXCL, rather than a key in
|
||||
the identity file. Three reasons, all of which produced wrong data when this
|
||||
lived in the identity dict:
|
||||
|
||||
- Hooks are short-lived separate processes firing on every tool call, and
|
||||
people run more than one agent window. A read-modify-write would let each
|
||||
process mint its own salt, so one repository would hash several ways in the
|
||||
window before a writer won.
|
||||
- resolve_distinct_id holds a copy of the identity dict across a network call
|
||||
to /v1/ping/, so whichever write landed second erased the other's key —
|
||||
losing either the salt (repo_hash changes mid-stream) or the email (a
|
||||
second $identify, splitting the person).
|
||||
- Touching the identity file from record() would create it, and is_first_run
|
||||
keys off that file, so recording an event would silently suppress the
|
||||
install event.
|
||||
|
||||
Published atomically, and there is deliberately no derived fallback. Creating
|
||||
the file with O_CREAT|O_EXCL and then writing into it leaves a window where
|
||||
the file exists and is empty, and a concurrent hook that reads it in that
|
||||
window gets nothing. Falling back to a digest of the path would hand that
|
||||
process a salt an attacker can compute, memoized for its whole run, which is
|
||||
the privacy control this function exists to provide silently turning itself
|
||||
off under load. The salt is written to a private temp file first and linked
|
||||
into place, so the name either does not exist or already has the full value.
|
||||
|
||||
Returns "" when it genuinely cannot persist. Callers omit the hash entirely
|
||||
rather than emit an unsalted one.
|
||||
"""
|
||||
global _salt_cache
|
||||
if _salt_cache:
|
||||
return _salt_cache
|
||||
|
||||
path = _salt_path()
|
||||
# Read before writing. Hooks are separate processes firing on every tool
|
||||
# call, so all but the first find the salt already published; going straight
|
||||
# to create-fsync-link-unlink meant every one of them paid an fsync to
|
||||
# discover that, on a path whose whole promise is appending a line and
|
||||
# returning.
|
||||
try:
|
||||
_salt_cache = path.read_text(encoding="utf-8").strip()
|
||||
if _salt_cache:
|
||||
return _salt_cache
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
stream.write(uuid.uuid4().hex)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
# Atomic claim: fails if another process already published one.
|
||||
# os.link rather than replace, which would clobber theirs.
|
||||
os.link(temporary, path)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError:
|
||||
# No hardlinks here (some network mounts, some container volumes).
|
||||
# Claim the name directly instead. That reopens the empty-file
|
||||
# window, but the window is now benign: a reader that lands in it
|
||||
# gets "" and omits the hash for that process rather than caching a
|
||||
# guessable one. Losing the hashes on every run of an entire
|
||||
# filesystem is the worse failure.
|
||||
try:
|
||||
fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fallback, "w", encoding="utf-8") as stream:
|
||||
stream.write(temporary.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
_salt_cache = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
_salt_cache = ""
|
||||
return _salt_cache
|
||||
|
||||
|
||||
def _scoped_digest(value: str, length: int = 16) -> str:
|
||||
"""Salted digest for values drawn from a guessable space.
|
||||
|
||||
repo.identity is a git remote URL, or ``local:<absolute path>`` when there is
|
||||
no remote — which normally contains the account username. Sixteen unsalted
|
||||
hex characters over that input space is enumerable, so this is not a
|
||||
privacy control without the salt. Salting per install keeps every
|
||||
within-account join the analytics actually use and gives up only
|
||||
cross-machine joins on the same repository, which nothing computes.
|
||||
|
||||
Returns "" when there is no salt, so record() omits the property. An
|
||||
unsalted digest over this input space is close to plaintext, and emitting one
|
||||
under a name that implies it is hashed is worse than sending nothing.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
salt = _install_salt()
|
||||
if not salt:
|
||||
return ""
|
||||
return hashlib.sha256(f"{salt}:{value}".encode("utf-8")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return memory_core.redact(value)
|
||||
@@ -145,9 +302,176 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str:
|
||||
return created
|
||||
|
||||
|
||||
def _rotate_anonymous_id(identity: dict[str, str]) -> str:
|
||||
"""Mint a fresh anonymous id because the account context is gone.
|
||||
|
||||
The previous id may already have been merged into a person profile by an
|
||||
$identify, and that merge is permanent. Reusing it after a logout or a key
|
||||
change attributes everything that follows to the account that just went
|
||||
away, which is the same misattribution the key fingerprint exists to stop,
|
||||
only arriving through the anonymous path instead.
|
||||
|
||||
`aliased` is cleared with it: the new id has never been merged, so it is
|
||||
eligible to be aliased into whatever account comes next.
|
||||
"""
|
||||
created = f"code-anon-{uuid.uuid4().hex}"
|
||||
identity["anonymous_id"] = created
|
||||
identity.pop("aliased", None)
|
||||
_write_identity(identity)
|
||||
return created
|
||||
|
||||
|
||||
def _install_state_path() -> Path:
|
||||
return memory_core.data_dir() / "install-state.json"
|
||||
|
||||
|
||||
def is_first_run() -> bool:
|
||||
"""Whether this machine has never recorded a plugin event before."""
|
||||
return not _identity_path().exists()
|
||||
"""Whether install has never been recorded on this machine.
|
||||
|
||||
Deliberately NOT the identity file. That file is only written by a
|
||||
successful flush, so an offline or firewalled user recorded code.install on
|
||||
every single session, forever — and every 0.2.x user recorded one on their
|
||||
first 0.3.x session because 0.2.x never wrote it at all.
|
||||
"""
|
||||
return not _install_state_path().exists()
|
||||
|
||||
|
||||
def data_dir_was_empty() -> bool:
|
||||
"""Whether the data directory is untouched. Call BEFORE anything writes to it.
|
||||
|
||||
hook_runner reaches claim_install() only after cache_plugin_api_key() has
|
||||
written `api-key` and EvidenceStore() has created `evidence.sqlite3`, so
|
||||
asking at claim time always saw content and every fresh install reported an
|
||||
upgrade. The caller snapshots this at the top of the run instead.
|
||||
"""
|
||||
return not _data_dir_has_content()
|
||||
|
||||
|
||||
def claim_install(was_empty: bool | None = None) -> str | None:
|
||||
"""Claim the one install/upgrade record for this machine, atomically.
|
||||
|
||||
Returns the event to record ("install" or "upgrade"), or None if another
|
||||
session already claimed it. O_CREAT|O_EXCL so two sessions starting together
|
||||
cannot both win.
|
||||
|
||||
`was_empty` must come from data_dir_was_empty() called before this process
|
||||
wrote anything. Omitting it falls back to checking now, which is only
|
||||
correct for a caller that has touched nothing.
|
||||
"""
|
||||
if not is_enabled():
|
||||
# Never consume the one-shot claim while the user is opted out, or they
|
||||
# would silently lose their install event if they later opt in.
|
||||
return None
|
||||
|
||||
path = _install_state_path()
|
||||
upgrading = not (data_dir_was_empty() if was_empty is None else was_empty)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
json.dump(
|
||||
{
|
||||
"plugin_version": memory_core.PLUGIN_VERSION,
|
||||
"installed_at": memory_core.utc_now(),
|
||||
"upgraded": upgrading,
|
||||
},
|
||||
stream,
|
||||
)
|
||||
# Durable before this returns. The O_EXCL open is what makes the
|
||||
# claim exclusive, so it cannot be replaced by a temp-and-rename
|
||||
# without losing that, which leaves the content as the thing to make
|
||||
# safe. A kill between the open and this fsync used to leave a marker
|
||||
# that exists but parses to nothing: is_first_run reads it as claimed
|
||||
# and claim_version_change cannot read a version out of it.
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except OSError:
|
||||
pass
|
||||
return "upgrade" if upgrading else "install"
|
||||
|
||||
|
||||
def _data_dir_has_content() -> bool:
|
||||
"""Whether anything predates this session in the plugin data directory."""
|
||||
try:
|
||||
for entry in memory_core.data_dir().iterdir():
|
||||
if entry.name != "install-state.json":
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _repair_install_state(path: Path) -> None:
|
||||
"""Rewrite an unparseable marker so version tracking can resume."""
|
||||
try:
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps({"plugin_version": memory_core.PLUGIN_VERSION, "repaired_at": memory_core.utc_now()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def claim_version_change() -> str | None:
|
||||
"""Return the previously recorded version if it differs, updating the marker.
|
||||
|
||||
Only meaningful once the marker exists — the first transition into 0.3.x has
|
||||
no recorded predecessor and reports "pre-0.3" instead. Claiming by rewriting
|
||||
the marker means the next session sees no change and records nothing.
|
||||
"""
|
||||
path = _install_state_path()
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
# A crash between O_EXCL and the write leaves an empty marker. Left
|
||||
# alone it disables every future upgrade event on this machine, because
|
||||
# claim_install sees the file and this function cannot parse it.
|
||||
state = None
|
||||
if not isinstance(state, dict):
|
||||
_repair_install_state(path)
|
||||
return None
|
||||
previous = str(state.get("plugin_version") or "")
|
||||
if not previous or previous == memory_core.PLUGIN_VERSION:
|
||||
return None
|
||||
# Claim the transition with an exclusive sentinel before rewriting the
|
||||
# marker. A plain read-modify-write let every concurrently starting session
|
||||
# observe the old version and each record its own upgrade — and the first
|
||||
# session after a version bump is exactly when several agent windows restart
|
||||
# together.
|
||||
sentinel = path.with_name(f"upgraded-{memory_core.PLUGIN_VERSION}")
|
||||
try:
|
||||
os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
||||
except FileExistsError:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
state["plugin_version"] = memory_core.PLUGIN_VERSION
|
||||
state["upgraded_at"] = memory_core.utc_now()
|
||||
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(state), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
# Release the claim. The marker still records the old version, so
|
||||
# without this the sentinel makes claim_version_change return early on
|
||||
# every later run and this version's upgrade is never recorded again.
|
||||
for leftover in (sentinel, temporary):
|
||||
try:
|
||||
leftover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
return previous
|
||||
|
||||
|
||||
def record(
|
||||
@@ -168,19 +492,32 @@ def record(
|
||||
except OSError:
|
||||
pass
|
||||
properties = _safe_value(properties)
|
||||
# Stamped in the RECORDING process, beside harness. `source` used to be
|
||||
# read in the sending process from a module global, so whichever process
|
||||
# drained the spool named every event in it. flush() spreads per-event
|
||||
# properties last, so this now wins over any sender's default.
|
||||
properties.update(
|
||||
harness=_harness,
|
||||
source=_source_tag,
|
||||
plugin_version=memory_core.PLUGIN_VERSION,
|
||||
os=sys.platform,
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
# Assigned only when the digest is real. _scoped_digest returns "" when
|
||||
# the salt could not be persisted, and an empty property is worse than an
|
||||
# absent one: it survives the None filter below and reads as a value.
|
||||
if repo is not None:
|
||||
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
|
||||
repo_hash = _scoped_digest(getattr(repo, "identity", ""))
|
||||
if repo_hash:
|
||||
properties["repo_hash"] = repo_hash
|
||||
if session_id:
|
||||
properties["session_hash"] = _digest(session_id)
|
||||
session_hash = _scoped_digest(session_id)
|
||||
if session_hash:
|
||||
properties["session_hash"] = session_hash
|
||||
line = json.dumps(
|
||||
{
|
||||
"event": f"{EVENT_PREFIX}.{event}",
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"timestamp": memory_core.utc_now(),
|
||||
"properties": {
|
||||
key: value for key, value in properties.items() if value is not None
|
||||
@@ -239,38 +576,201 @@ def spawn_flush() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_name(attempt: int = 0) -> str:
|
||||
"""Claim filename. The attempt count rides in the name so the 7-day expiry
|
||||
only ever discards a batch that was actually retried and failed."""
|
||||
return f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}-a{attempt}.sending"
|
||||
|
||||
|
||||
def _claim_attempt(claim: Path) -> int:
|
||||
"""Attempts recorded in a claim filename; 0 for the pre-attempt-count shape.
|
||||
|
||||
Anchored on field position, not on a leading "a": the legacy shape is
|
||||
``telemetry-<pid>-<hex>.sending`` and a hex id such as ``a1234567`` would
|
||||
otherwise parse as attempt 1234567 and be discarded unsent on the first
|
||||
flush after an upgrade.
|
||||
"""
|
||||
stem = claim.name[: -len(".sending")] if claim.name.endswith(".sending") else claim.name
|
||||
parts = stem.split("-")
|
||||
if len(parts) != 4:
|
||||
return 0
|
||||
tail = parts[3]
|
||||
if tail.startswith("a") and tail[1:].isdigit():
|
||||
return int(tail[1:])
|
||||
return 0
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
"""Refresh mtime so a claim's age measures time since it was claimed.
|
||||
|
||||
``Path.replace`` is ``os.rename``, which preserves mtime — so a claim created
|
||||
after a quiet minute inherited the spool's last-write time and looked
|
||||
abandoned the instant it was made. A second sender would then take it over
|
||||
while the first was still posting, and both would deliver the batch.
|
||||
"""
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _claim_spool() -> Path | None:
|
||||
"""Rename the spool aside so exactly one sender owns each batch."""
|
||||
directory = memory_core.data_dir()
|
||||
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
|
||||
claim = directory / _claim_name()
|
||||
spool = _spool_path()
|
||||
try:
|
||||
spool.replace(claim)
|
||||
_touch(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
pass
|
||||
return _claim_parked(directory)
|
||||
|
||||
|
||||
def _sweep_debris(directory: Path) -> None:
|
||||
"""Remove files nothing else will ever pick up again.
|
||||
|
||||
*.partial is a temp file orphaned by a crash between write and rename.
|
||||
*.corrupt is a batch quarantined for undecodable content. No glob in this
|
||||
module matches either, so without this they accumulate on disk for the life
|
||||
of the install.
|
||||
|
||||
Quarantined batches are kept far longer than debris: they are the only
|
||||
evidence left of events that could not be delivered, and someone diagnosing
|
||||
a report of missing telemetry has to be able to find one.
|
||||
"""
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending")):
|
||||
for debris in directory.glob("telemetry-*.partial"):
|
||||
try:
|
||||
if now - debris.stat().st_mtime > CLAIM_STALE_SECONDS:
|
||||
debris.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
for quarantined in directory.glob("telemetry-*.corrupt"):
|
||||
try:
|
||||
if now - quarantined.stat().st_mtime > CLAIM_EXPIRY_SECONDS:
|
||||
quarantined.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
# The same reasoning covers *.tmp. _write_identity and _install_salt both
|
||||
# create one and unlink it in a finally, which a SIGKILL skips, and no glob
|
||||
# in this module matches the leftovers either.
|
||||
for temporary in directory.glob("telemetry-*.tmp"):
|
||||
try:
|
||||
if now - temporary.stat().st_mtime > CLAIM_STALE_SECONDS:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _claim_parked(directory: Path) -> Path | None:
|
||||
"""Take the oldest abandoned claim, if any lease has actually expired.
|
||||
|
||||
Kept separate from the live spool so flush() can drain both in one run.
|
||||
Previously parked batches were only reachable when no spool existed at all,
|
||||
and because sessions keep recording there usually was one — so a batch
|
||||
parked by a failed send waited until the 7-day expiry deleted it unsent,
|
||||
even though its own presence is what started the sender.
|
||||
"""
|
||||
now = time.time()
|
||||
for orphan in sorted(directory.glob("telemetry-*.sending"), key=_safe_mtime):
|
||||
try:
|
||||
age = now - orphan.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if age > CLAIM_EXPIRY_SECONDS:
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
# Someone else holds a live lease on it. This check has to come
|
||||
# first. Claiming a file bumps its attempt count and refreshes its
|
||||
# mtime, so a sender that has just taken the final attempt looks
|
||||
# exhausted to everyone else while it is actively draining. Judging
|
||||
# exhaustion before liveness let a second sender unlink a batch out
|
||||
# from under its owner, losing every event in it.
|
||||
continue
|
||||
# Attempts, not age. Every re-claim touches the mtime and every release
|
||||
# backdates it by a fixed amount, so age is pinned near the stale
|
||||
# threshold and never reaches the expiry. Age stays only as a backstop
|
||||
# for files that never carried an attempt marker.
|
||||
if _claim_attempt(orphan) >= MAX_CLAIM_ATTEMPTS or age > CLAIM_EXPIRY_SECONDS:
|
||||
try:
|
||||
orphan.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if age < CLAIM_STALE_SECONDS:
|
||||
continue
|
||||
claim = orphan.parent / _claim_name(_claim_attempt(orphan) + 1)
|
||||
try:
|
||||
orphan.replace(claim)
|
||||
_touch(claim)
|
||||
return claim
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _safe_mtime(path: Path) -> float:
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _rewrite_claim(claim: Path, remaining: list[dict[str, Any]]) -> bool:
|
||||
"""Persist the unsent remainder, atomically, and refresh the lease.
|
||||
|
||||
Called after every successful batch. Two jobs: a retry resumes where the
|
||||
send stopped instead of re-posting from the top, and the rewrite doubles as
|
||||
the lease heartbeat, so a slow sender does not have its claim stolen
|
||||
mid-flight. Interval is one batch, well inside CLAIM_STALE_SECONDS.
|
||||
"""
|
||||
if not remaining:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
temporary = claim.with_suffix(f".{os.getpid()}.partial")
|
||||
try:
|
||||
payload = "".join(json.dumps(event, separators=(",", ":"), default=str) + "\n" for event in remaining)
|
||||
# fsync before the rename: without it the rename can land while the
|
||||
# bytes have not, and the claim comes back empty or truncated after a
|
||||
# crash. _drain then reads zero events and unlinks it.
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(claim)
|
||||
_touch(claim)
|
||||
return True
|
||||
except OSError:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _release_claim(claim: Path, remaining: list[dict[str, Any]]) -> None:
|
||||
"""Persist the remainder and drop the lease, because this sender has given up.
|
||||
|
||||
Distinct from the per-batch heartbeat: heartbeating on the way out would
|
||||
make an abandoned batch look actively owned for a further
|
||||
CLAIM_STALE_SECONDS, delaying the retry for no reason. Ageing it past the
|
||||
threshold lets the next flush pick it up immediately, while the attempt
|
||||
count in the filename still bounds how many times that can happen.
|
||||
"""
|
||||
if not _rewrite_claim(claim, remaining):
|
||||
return
|
||||
try:
|
||||
# Backdate past the stale threshold so the next flush can pick it up,
|
||||
# minus a cooldown that grows with the attempts already spent. Clamped so
|
||||
# the mtime never lands in the future, which would read as a live lease.
|
||||
cooldown = min(_claim_attempt(claim) * RETRY_COOLDOWN_SECONDS, CLAIM_STALE_SECONDS)
|
||||
released = time.time() - CLAIM_STALE_SECONDS - 1 + cooldown
|
||||
os.utime(claim, (released, released))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_email(key: str) -> str:
|
||||
"""Trade the API key for the account email so events join other Mem0 surfaces."""
|
||||
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
|
||||
@@ -300,34 +800,130 @@ def _post(payload: dict[str, Any], url: str) -> bool:
|
||||
|
||||
|
||||
def resolve_distinct_id() -> tuple[str, str]:
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
|
||||
"""Return the PostHog distinct id and the anonymous id it replaced, if any.
|
||||
|
||||
The second value becomes a PostHog $identify alias. It is ONLY ever an
|
||||
anonymous id: aliasing one account email to another merges two real person
|
||||
profiles and cannot be undone, so a key that now belongs to a different
|
||||
account re-resolves with no alias.
|
||||
"""
|
||||
identity = _read_identity()
|
||||
email = identity.get("email", "")
|
||||
if email:
|
||||
return email, ""
|
||||
key = memory_core.api_key()
|
||||
fingerprint = _digest(key) if key else ""
|
||||
email = identity.get("email", "")
|
||||
|
||||
if email and fingerprint:
|
||||
recorded = identity.get("key_fingerprint", "")
|
||||
if recorded == fingerprint:
|
||||
return email, ""
|
||||
if not recorded:
|
||||
# Rows written before fingerprints existed. Verify rather than
|
||||
# adopt: a key changed before the upgrade would otherwise bind the
|
||||
# new key to the previous account's email, permanently, and the
|
||||
# fingerprint would then agree with itself forever after.
|
||||
verified = _resolve_email(key)
|
||||
if not verified:
|
||||
# Offline, firewalled, or the API is down. Keep the previous
|
||||
# behaviour and retry on the next flush rather than dropping a
|
||||
# real account attribution. Safe because the same network that
|
||||
# failed /v1/ping/ is about to fail the PostHog POST, so nothing
|
||||
# is delivered under the unverified identity in the meantime.
|
||||
return email, ""
|
||||
identity["email"] = verified
|
||||
identity["key_fingerprint"] = fingerprint
|
||||
_write_identity(identity)
|
||||
return verified, ""
|
||||
|
||||
if not key:
|
||||
# No key to verify the account with; do not keep attributing to it.
|
||||
if email:
|
||||
identity.pop("email", None)
|
||||
identity.pop("key_fingerprint", None)
|
||||
return _rotate_anonymous_id(identity), ""
|
||||
return anonymous_id(identity), ""
|
||||
email = _resolve_email(key)
|
||||
if not email:
|
||||
|
||||
resolved = _resolve_email(key)
|
||||
if not resolved:
|
||||
# The key changed and will not resolve (revoked, offline, API down).
|
||||
# Reaching here with an email means the recorded fingerprint disagreed,
|
||||
# so the key really did change. Drop the account and rotate: the stored
|
||||
# anonymous id may already be merged into that account's person, and
|
||||
# reusing it would keep the events on the profile we are trying to
|
||||
# leave.
|
||||
if email:
|
||||
identity.pop("email", None)
|
||||
identity.pop("key_fingerprint", None)
|
||||
return _rotate_anonymous_id(identity), ""
|
||||
return anonymous_id(identity), ""
|
||||
previous = identity.get("anonymous_id", "")
|
||||
identity["email"] = email
|
||||
|
||||
# Alias only when going anonymous -> email for the first time. Once an anon
|
||||
# id has been merged into an account it must never be offered again: an
|
||||
# alias naming an already-identified id is what could link two real people.
|
||||
previous = "" if (email or identity.get("aliased")) else identity.get("anonymous_id", "")
|
||||
if previous:
|
||||
identity["aliased"] = True
|
||||
identity["email"] = resolved
|
||||
identity["key_fingerprint"] = fingerprint
|
||||
_write_identity(identity)
|
||||
return email, previous
|
||||
return resolved, previous
|
||||
|
||||
|
||||
def flush() -> int:
|
||||
"""Drain claimed spools to PostHog and return the number of events sent."""
|
||||
"""Drain the live spool, then any parked claims, and return events sent."""
|
||||
if not is_enabled():
|
||||
return 0
|
||||
claim = _claim_spool()
|
||||
sent, delivered = _drain(_claim_spool())
|
||||
if not delivered:
|
||||
# The network is failing. Retrying other batches now would only burn
|
||||
# their attempt budget against the same broken connection.
|
||||
return sent
|
||||
|
||||
# Parked batches used to starve behind the live spool indefinitely. Bounded
|
||||
# per run so a long backlog cannot turn one flush into an unbounded loop.
|
||||
directory = memory_core.data_dir()
|
||||
_sweep_debris(directory)
|
||||
for _ in range(MAX_PARKED_PER_RUN):
|
||||
parked = _claim_parked(directory)
|
||||
if parked is None:
|
||||
break
|
||||
count, delivered = _drain(parked)
|
||||
sent += count
|
||||
if not delivered:
|
||||
break
|
||||
return sent
|
||||
|
||||
|
||||
def _drain(claim: Path | None) -> tuple[int, bool]:
|
||||
"""Post one claimed batch file, recording progress after every batch.
|
||||
|
||||
Returns (events sent, whether everything was delivered).
|
||||
"""
|
||||
if claim is None:
|
||||
return 0
|
||||
return 0, True
|
||||
try:
|
||||
lines = claim.read_text(encoding="utf-8").splitlines()
|
||||
except ValueError:
|
||||
# UnicodeDecodeError from a torn write: the content is unrecoverable, so
|
||||
# quarantine rather than retry. flush() runs from a bare `finally:` in
|
||||
# flush_worker, so raising here also skips the handoff cleanup, and an
|
||||
# undecodable file would otherwise be re-read on every flush forever.
|
||||
# Reported as delivered because there is nothing left to deliver and the
|
||||
# rest of the run should continue.
|
||||
try:
|
||||
claim.replace(claim.with_suffix(".corrupt"))
|
||||
except OSError:
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0, True
|
||||
except OSError:
|
||||
return 0
|
||||
# Could not read it, which is not the same as having nothing to send.
|
||||
# The file is left exactly where it is: a vanished or briefly unreadable
|
||||
# claim is retryable, and quarantining it here would discard events over
|
||||
# a transient filesystem error. Reported as undelivered so the run stops
|
||||
# instead of counting a batch nothing was posted from as delivered.
|
||||
return 0, False
|
||||
events = []
|
||||
for line in lines:
|
||||
try:
|
||||
@@ -337,11 +933,18 @@ def flush() -> int:
|
||||
if isinstance(value, dict) and value.get("event"):
|
||||
events.append(value)
|
||||
if not events:
|
||||
# Only delete when the file really is empty. A non-empty file that
|
||||
# parses to nothing is a torn write, and its contents are the unsent
|
||||
# remainder — deleting it is the data loss this PR exists to prevent.
|
||||
try:
|
||||
claim.unlink()
|
||||
empty = claim.stat().st_size == 0
|
||||
except OSError:
|
||||
empty = True
|
||||
try:
|
||||
claim.replace(claim.with_suffix(".corrupt")) if not empty else claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
return 0, True
|
||||
|
||||
distinct_id, aliased_anonymous_id = resolve_distinct_id()
|
||||
if aliased_anonymous_id:
|
||||
@@ -360,12 +963,17 @@ def flush() -> int:
|
||||
|
||||
sent = 0
|
||||
for start in range(0, len(events), BATCH_SIZE):
|
||||
chunk = events[start : start + BATCH_SIZE]
|
||||
batch = [
|
||||
{
|
||||
"event": event["event"],
|
||||
"distinct_id": distinct_id,
|
||||
# Carried through from record() so a resend can be collapsed.
|
||||
"uuid": event.get("uuid"),
|
||||
"timestamp": event.get("timestamp"),
|
||||
"properties": {
|
||||
# Fallback only: events recorded by a build before source
|
||||
# moved into record() have none of their own.
|
||||
"source": _source_tag,
|
||||
"language": "python",
|
||||
"$process_person_profile": False,
|
||||
@@ -373,16 +981,24 @@ def flush() -> int:
|
||||
**(event.get("properties") or {}),
|
||||
},
|
||||
}
|
||||
for event in events[start : start + BATCH_SIZE]
|
||||
for event in chunk
|
||||
]
|
||||
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
|
||||
return sent
|
||||
sent += len(batch)
|
||||
try:
|
||||
claim.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return sent
|
||||
# Keep only what has not been delivered, and release the lease.
|
||||
# Previously the whole file was kept and the retry re-posted every
|
||||
# batch, including the ones that had already arrived.
|
||||
_release_claim(claim, events[start:])
|
||||
return sent, False
|
||||
sent += len(chunk)
|
||||
# Record progress and refresh the lease after each successful batch, so
|
||||
# a crash repeats at most one batch instead of the entire file. If the
|
||||
# rewrite fails the claim still holds delivered events, so stop rather
|
||||
# than carry on as though progress were recorded — continuing is how the
|
||||
# duplicate delivery this PR fixes would come back.
|
||||
if not _rewrite_claim(claim, events[start + len(chunk) :]):
|
||||
_release_claim(claim, events[start + len(chunk) :])
|
||||
return sent, False
|
||||
return sent, True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"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:
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""
|
||||
|
||||
HARNESS_ID = "claude-code"
|
||||
SOURCE_TAG = "CLAUDE_CODE_PLUGIN"
|
||||
|
||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||
# plugin family is one source; which editor it runs in is the application.
|
||||
# An empty application means the host is unknown, and memory_core omits
|
||||
# the header entirely rather than sending a placeholder.
|
||||
PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
PLATFORM_APPLICATION = "claude-code"
|
||||
@@ -290,6 +290,11 @@ def run(
|
||||
if args.plugin_data_dir:
|
||||
os.environ[data_dir_env] = args.plugin_data_dir
|
||||
|
||||
# Snapshot BEFORE anything writes to the data dir: cache_plugin_api_key
|
||||
# writes `api-key` and EvidenceStore creates `evidence.sqlite3`, so asking
|
||||
# after them always saw content and every fresh install reported an upgrade.
|
||||
data_dir_was_empty = telemetry.data_dir_was_empty()
|
||||
|
||||
cache_plugin_api_key()
|
||||
if args.action == "session-start":
|
||||
clear_stale_api_key_cache()
|
||||
@@ -305,8 +310,19 @@ def run(
|
||||
return 0
|
||||
|
||||
if args.action == "session-start":
|
||||
if telemetry.is_first_run():
|
||||
# Claims the marker atomically and says which event to record, so a
|
||||
# second session starting alongside this one cannot record it too.
|
||||
first_event = telemetry.claim_install(was_empty=data_dir_was_empty)
|
||||
if first_event == "install":
|
||||
telemetry.record("install")
|
||||
elif first_event == "upgrade":
|
||||
# First run after a build that never wrote the marker; the
|
||||
# predecessor version was never recorded anywhere.
|
||||
telemetry.record("upgrade", from_version="pre-0.3")
|
||||
else:
|
||||
previous = telemetry.claim_version_change()
|
||||
if previous:
|
||||
telemetry.record("upgrade", from_version=previous)
|
||||
recovered = recover_pending_handoffs()
|
||||
record_session_start(store, hook_input)
|
||||
if recovered:
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable
|
||||
import telemetry
|
||||
|
||||
DEFAULT_API_URL = "https://api.mem0.ai"
|
||||
PLUGIN_VERSION = "0.3.1"
|
||||
PLUGIN_VERSION = "0.3.2"
|
||||
|
||||
_harness_name: str = "generic"
|
||||
_harness_env_prefix: str = "MEM0_PLUGIN"
|
||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
||||
return batches
|
||||
|
||||
|
||||
# Platform surface attribution. Read from the generated per-host module so a new
|
||||
# entrypoint is correct without remembering to configure anything.
|
||||
try: # pragma: no cover - absent only in the un-built shared source tree
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
|
||||
def platform_headers(key: str) -> dict[str, str]:
|
||||
"""Auth plus the three surface-identity headers.
|
||||
|
||||
X-Mem0-Source and X-Application are set-once by contract: this is the
|
||||
outermost layer, so it sets them, and nothing below may overwrite them.
|
||||
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": _PLATFORM_SOURCE,
|
||||
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
|
||||
}
|
||||
if _PLATFORM_APPLICATION:
|
||||
headers["X-Application"] = _PLATFORM_APPLICATION
|
||||
return headers
|
||||
|
||||
|
||||
def _request_json(
|
||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=raw,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
||||
"user_id": write_user,
|
||||
"app_id": repo.app_id,
|
||||
"run_id": session_id,
|
||||
# Top level, not metadata: the backend reads `source` from the body or
|
||||
# the query string, never from metadata, which is where this used to
|
||||
# sit. The X-Mem0-Source header is also read, but only from the
|
||||
# platform release that ships alongside this change, so the body value
|
||||
# is what makes attribution work on both. The harness tag stays in
|
||||
# metadata as hook provenance.
|
||||
"source": _PLATFORM_SOURCE,
|
||||
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
|
||||
@@ -2523,7 +2558,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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user