Compare commits

..
78 changed files with 480 additions and 843 deletions
@@ -32,9 +32,6 @@ jobs:
- name: Type check
run: bun run type-check
- name: Test
run: bun test
- name: Build
run: bun run build
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "API Reference Overview"
sidebarTitle: "Overview"
title: "Overview"
seo:
title: "API Reference Overview - Mem0"
icon: "terminal"
iconType: "solid"
description: "REST APIs for memory management, search, and entity operations"
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Delete Memory API Endpoint"
sidebarTitle: "Delete Memory"
title: 'Delete Memory'
seo:
title: "Delete Memory API Endpoint - Mem0"
description: "Delete a single memory by its unique memory ID from the Mem0 platform using the DELETE endpoint."
openapi: delete /v1/memories/{memory_id}/
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Update Memory API Endpoint"
sidebarTitle: "Update Memory"
title: 'Update Memory'
seo:
title: "Update Memory API Endpoint - Mem0"
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,6 +1,7 @@
---
title: "Add Organization Member API Endpoint"
sidebarTitle: "Add Member"
title: 'Add Member'
seo:
title: "Add Organization Member API Endpoint - Mem0"
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,6 +1,7 @@
---
title: "Get Organization Members API Endpoint"
sidebarTitle: "Get Members"
title: 'Get Members'
seo:
title: "Get Organization Members API Endpoint - Mem0"
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,6 +1,7 @@
---
title: "Add Project Member API Endpoint"
sidebarTitle: "Add Member"
title: 'Add Member'
seo:
title: "Add Project Member API Endpoint - Mem0"
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,6 +1,7 @@
---
title: "Get Project Members API Endpoint"
sidebarTitle: "Get Members"
title: 'Get Members'
seo:
title: "Get Project Members API Endpoint - Mem0"
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/
---
+17
View File
@@ -3106,6 +3106,23 @@ Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skill
<Tab title="DeepSeek Harness">
<Update label="2026-09-11" description="deepseek-plugin v0.3.1">
**Fixed:**
- Automatic recall uses the claimed human inbox message before Harness appends it to session history, so the first request in a fresh session can recall memories and later turns use the current prompt.
- Native `dsh.bundle` activation loads Mem0 when the package is installed into a Harness profile. `pnpm pack` builds the plugin and includes its activation patch.
- Automatic recall guidance points to the available memory search tool for deeper searches.
**Added:**
- Optional `memoryScope: workspace` applies the same canonical workspace filter to automatic recall, capture, and both memory tools. Missing workspace paths skip automatic operations and reject explicit tools without falling back to shared memory.
**Upgrade notes:**
- `memoryScope: user` remains the default and shares memories across workspaces. Existing memories are not migrated into workspace scopes; separate clones and worktrees have separate scopes when workspace mode is enabled.
- Export `MEM0_USER_ID` alongside `MEM0_API_KEY`. Remove the old manual `insert: mem0` overlay; customize the bundle's existing row with an `id: mem0` override. See [DeepSeek Harness setup](/integrations/deepseek-plugin).
- Capture extraction remains asynchronous. This patch does not change extraction policy or guarantee that every preference or numeric constraint becomes a memory.
</Update>
<Update label="2026-09-08" description="deepseek-plugin v0.3.0">
**Added:**
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Embedder Configuration Reference"
sidebarTitle: "Configurations"
title: Configurations
seo:
title: "Embedder Configuration Reference - Mem0"
description: "Reference for embedder configuration options in Mem0, including provider selection and model settings."
---
@@ -1,6 +1,7 @@
---
title: "AWS Bedrock as Embedding Provider"
sidebarTitle: "AWS Bedrock"
title: AWS Bedrock
seo:
title: "AWS Bedrock as Embedding Provider - Mem0"
description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication."
---
@@ -1,6 +1,7 @@
---
title: "Azure OpenAI as Embedding Provider"
sidebarTitle: "Azure OpenAI"
title: Azure OpenAI
seo:
title: "Azure OpenAI as Embedding Provider - Mem0"
description: "Configure Azure OpenAI as an embedding provider in Mem0 with API key, deployment, and endpoint settings."
---
@@ -1,6 +1,7 @@
---
title: "Google AI as Embedding Provider"
sidebarTitle: "Google AI"
title: Google AI
seo:
title: "Google AI as Embedding Provider - Mem0"
description: "Configure Google AI as an embedding provider in Mem0 using Gemini models and the GOOGLE_API_KEY variable."
---
@@ -1,6 +1,7 @@
---
title: "LangChain as Embedding Provider"
sidebarTitle: "LangChain"
title: LangChain
seo:
title: "LangChain as Embedding Provider - Mem0"
description: "Use LangChain as an embedding provider in Mem0 to access a wide range of models through a unified interface."
---
@@ -1,6 +1,7 @@
---
title: "LM Studio as Embedding Provider"
sidebarTitle: "LM Studio"
title: "LM Studio"
seo:
title: "LM Studio as Embedding Provider - Mem0"
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.
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Ollama as Embedding Provider"
sidebarTitle: "Ollama"
title: "Ollama"
seo:
title: "Ollama as Embedding Provider - Mem0"
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.
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "OpenAI as Embedding Provider"
sidebarTitle: "OpenAI"
title: OpenAI
seo:
title: "OpenAI as Embedding Provider - Mem0"
description: "Configure OpenAI as an embedding provider in Mem0 using models like text-embedding-3-large for vector generation."
---
@@ -1,6 +1,7 @@
---
title: "Together AI as Embedding Provider"
sidebarTitle: "Together"
title: Together
seo:
title: "Together AI as Embedding Provider - Mem0"
description: "Configure Together AI as an embedding provider in Mem0 with support for 1024-dimensional embedding models."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Embedding Providers Overview"
sidebarTitle: Overview
title: Overview
seo:
title: "Embedding Providers Overview - Mem0"
description: "Overview of all supported embedding model providers in Mem0, including OpenAI, Azure, Ollama, and more."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "LLM Configuration Reference"
sidebarTitle: "Configurations"
title: Configurations
seo:
title: "LLM Configuration Reference - Mem0"
description: "Reference for LLM configuration options in Mem0 for Python and TypeScript, including value precedence rules."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "AWS Bedrock as LLM Provider"
sidebarTitle: "AWS Bedrock"
title: AWS Bedrock
seo:
title: "AWS Bedrock as LLM Provider - Mem0"
description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authentication and Claude model support."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Azure OpenAI as LLM Provider"
sidebarTitle: "Azure OpenAI"
title: Azure OpenAI
seo:
title: "Azure OpenAI as LLM Provider - Mem0"
description: "Configure Azure OpenAI as an LLM provider in Mem0 with Azure Identity authentication and deployment settings."
---
+1 -1
View File
@@ -3,7 +3,7 @@ title: DeepSeek
description: "Configure DeepSeek as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
---
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to `https://api.deepseek.com`).
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to "https://api.deepseek.com").
## Usage
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Google AI as LLM Provider"
sidebarTitle: "Google AI"
title: Google AI
seo:
title: "Google AI as LLM Provider - Mem0"
description: "Configure Google Gemini as an LLM provider in Mem0 using the google.genai SDK and GOOGLE_API_KEY variable."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "LangChain as LLM Provider"
sidebarTitle: "LangChain"
title: LangChain
seo:
title: "LangChain as LLM Provider - Mem0"
description: "Use LangChain as an LLM provider in Mem0 to integrate with various chat models through a unified interface."
---
+4 -3
View File
@@ -1,6 +1,7 @@
---
title: "LM Studio as LLM Provider"
sidebarTitle: "LM Studio"
title: LM Studio
seo:
title: "LM Studio as LLM Provider - Mem0"
description: "Configure LM Studio as an LLM provider in Mem0 for running local language models via an OpenAI-compatible API."
---
@@ -77,7 +78,7 @@ m.add(messages, user_id="alice123", metadata={"category": "movies"})
To use LM Studio, you need to:
1. Download and install [LM Studio](https://lmstudio.ai/)
2. Start a local server from the "Server" tab
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually `http://localhost:1234/v1`)
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually http://localhost:1234/v1)
</Note>
## Config
+1 -1
View File
@@ -3,7 +3,7 @@ title: MiniMax
description: "Configure MiniMax as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
---
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to `https://api.minimax.io/v1`).
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to "https://api.minimax.io/v1").
## Usage
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Ollama as LLM Provider"
sidebarTitle: "Ollama"
title: Ollama
seo:
title: "Ollama as LLM Provider - Mem0"
description: "Configure Ollama as an LLM provider in Mem0 for running local language models with tool-calling support."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "OpenAI as LLM Provider"
sidebarTitle: "OpenAI"
title: OpenAI
seo:
title: "OpenAI as LLM Provider - Mem0"
description: "Configure OpenAI as an LLM provider in Mem0 with support for GPT models and Openrouter compatibility."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Together AI as LLM Provider"
sidebarTitle: "Together"
title: Together
seo:
title: "Together AI as LLM Provider - Mem0"
description: "Configure Together AI as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "xAI Grok as LLM Provider"
sidebarTitle: "xAI"
title: xAI
seo:
title: "xAI Grok as LLM Provider - Mem0"
description: "Configure xAI Grok models as an LLM provider in Mem0 with API key setup and usage examples."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "LLM Providers Overview"
sidebarTitle: Overview
title: Overview
seo:
title: "LLM Providers Overview - Mem0"
description: "Overview of all supported LLM providers in Mem0, including OpenAI, Anthropic, Groq, Ollama, and more."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Reranker Providers Overview"
sidebarTitle: "Overview"
title: Overview
seo:
title: "Reranker Providers Overview - Mem0"
description: 'Pick the right reranker path to boost Mem0 search relevance.'
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Vector Store Configuration Reference"
sidebarTitle: "Configurations"
title: Configurations
seo:
title: "Vector Store Configuration Reference - Mem0"
description: "Reference for vector database configuration options in Mem0, including provider selection and connection settings."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "LangChain as Vector Store Provider"
sidebarTitle: "LangChain"
title: LangChain
seo:
title: "LangChain as Vector Store Provider - Mem0"
description: "Use LangChain as a unified vector store provider in Mem0 to access multiple vector databases through one interface."
---
@@ -102,7 +102,7 @@ Here are the parameters available for configuring Upstash Vector:
| `url` | URL for the Upstash Vector index | `None` |
| `token` | Token for the Upstash Vector index | `None` |
| `client` | An `upstash_vector.Index` instance | `None` |
| `collection_name` | The default namespace used | `"mem0"` |
| `collection_name` | The default namespace used | `""` |
| `enable_embeddings` | Whether to use Upstash embeddings | `False` |
<Note>
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Vector Store Providers Overview"
sidebarTitle: "Overview"
title: Overview
seo:
title: "Vector Store Providers Overview - Mem0"
description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Cookbooks and Tutorials"
sidebarTitle: "Overview"
title: Overview
seo:
title: "Cookbooks and Tutorials - Mem0"
description: "Browse cookbook examples and tutorials for building AI applications with Mem0, from companion chatbots to AI agents."
---
@@ -1,6 +1,7 @@
---
title: "Delete Memory Operation"
sidebarTitle: "Delete Memory"
title: Delete Memory
seo:
title: "Delete Memory Operation - Mem0"
description: Remove memories from Mem0 either individually, in bulk, or via filters.
icon: "trash"
iconType: "solid"
@@ -1,6 +1,7 @@
---
title: "Update Memory Operation"
sidebarTitle: "Update Memory"
title: Update Memory
seo:
title: "Update Memory Operation - Mem0"
description: Modify an existing memory by updating its content or metadata.
icon: "pen-to-square"
iconType: "solid"
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Integrations Overview"
sidebarTitle: "Overview"
title: Overview
seo:
title: "Integrations Overview - Mem0"
description: "Overview of Mem0 integrations with popular AI frameworks and tools for persistent memory and context management."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "AWS Bedrock Integration"
sidebarTitle: "AWS Bedrock"
title: AWS Bedrock
seo:
title: "AWS Bedrock Integration with Mem0"
description: "Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage."
---
+1 -1
View File
@@ -3,7 +3,7 @@ title: Codex
description: "Add persistent memory to OpenAI Codex with automatic capture, automatic recall, a search tool, and six memory skills."
---
Add persistent memory to [**OpenAI Codex**](https://openai.com/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/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.
<Info>Current plugin version: `0.3.1`.</Info>
+39 -23
View File
@@ -5,7 +5,7 @@ description: "Add persistent memory to DeepSeek Harness with automatic recall, a
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/deepseek-plugin`. The plugin recalls relevant context before a model request, captures completed turns, and provides explicit Mem0 tools when the agent needs them.
<Info>Current package version: `0.3.0`.</Info>
<Info>Current package version: `0.3.1`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
@@ -32,7 +32,7 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This plugin waits fo
Cordis removes the listeners and tools when the plugin unmounts. Memory failures are fail-open, so a Mem0 outage does not stop the agent from completing its normal work.
DeepSeek Harness provides subagents through separate host-composition packages. This Mem0 package does not register a named Sidekick or claim child filesystem isolation. A Harness child uses Mem0 only when its own agent preset includes the Mem0 plugin.
DeepSeek Harness provides subagents through separate host-composition packages. This Mem0 package does not register a named Sidekick or claim child filesystem isolation. Memory availability in children depends on the Harness composition and service scope.
## Prerequisites
@@ -40,25 +40,27 @@ DeepSeek Harness provides subagents through separate host-composition packages.
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-deepseek-plugin">Sign up at app.mem0.ai</a>
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-deepseek-plugin">Get your API key</a> (starts with `m0-`)
2. The DeepSeek Harness installed.
2. The DeepSeek Harness installed and a funded model-provider credential (`DEEPSEEK_API_KEY` for the official DeepSeek provider). This is separate from your Mem0 key.
3. Your API key exported in your shell:
3. Your Mem0 key and a stable user identity exported in the shell that launches Harness:
<CodeGroup>
```bash zsh
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
echo 'export MEM0_USER_ID="your-user-id"' >> ~/.zshrc
source ~/.zshrc
```
```bash bash
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
echo 'export MEM0_USER_ID="your-user-id"' >> ~/.bashrc
source ~/.bashrc
```
</CodeGroup>
## Try it locally
1. Build and pack the plugin:
1. Build and pack the plugin from the full repository (the source imports the sibling shared core):
```sh
cd integrations/deepseek-plugin
pnpm install --frozen-lockfile
@@ -70,33 +72,32 @@ source ~/.bashrc
2. Install it into a disposable Harness profile so Harness supplies its peer dependencies:
```sh
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
plugin --profile web add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.1.tgz
```
3. Copy `cordis.example.yml`, set its installed package path and your `userId`, then load it with the same profile:
3. Launch the same profile. The package's `dsh.bundle` activates Mem0 automatically:
```sh
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
web --patch ./integrations/deepseek-plugin/cordis.example.yml
web
```
4. Open the web UI and ask the agent to remember something, then recall it in a later turn.
4. Open the web UI, select a workspace, and state a synthetic preference without mentioning Mem0. Allow extraction to finish, start a fresh session, and ask for that preference without tools. Inspect the `mem0:recall` context to confirm automatic recall.
The `cordis.yml` entry looks like this:
`pnpm pack` builds the package automatically and ships its activation patch. To customize it, copy `cordis.example.yml` and launch with `web --patch /absolute/path/to/cordis.yml`. The override updates the existing bundle row:
```yaml
- name: "@deepseek-ai/dsh-system-prompt"
- name: "@deepseek-ai/dsh-tools"
- insert:
- id: mem0
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
config:
# apiKey is read from MEM0_API_KEY when omitted here.
userId: "your-user-id"
autoRecall: true
autoCapture: true
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
- id: mem0
config:
# A config override replaces the whole config, so restate userId.
userId: !!js process.env.MEM0_USER_ID
memoryScope: workspace
autoRecall: true
autoCapture: true
# host: "https://your-onprem.mem0.ai" # optional: Platform dedicated base URL
```
When upgrading from a manual installation, remove the old patch that inserts a `mem0` row; the bundle now inserts it. Keep custom settings as an override by `id`.
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` is a Platform base-URL override, not a switch to self-hosted Mem0 OSS.
## Configuration
@@ -105,12 +106,27 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|---|---|---|---|
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
| `userId` | yes | | Default entity that owns the memories |
| `memoryScope` | no | `user` | `user` shares memory across workspaces; `workspace` isolates automatic and explicit memory operations |
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
| `autoCapture` | no | `true` | Store completed human and assistant turns |
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; `userId` defaults to the configured user, while `agentId` and `runId` are omitted unless supplied. Automatic recall and capture use the configured user without an agent, repository, or session filter. Automatic capture preserves full redacted user and assistant message text without a per-message character cutoff.
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; `userId` defaults to the configured user, while `agentId` and `runId` are omitted unless supplied. Automatic capture preserves full redacted user and assistant message text without a per-message character cutoff.
## Workspace scope
By default, automatic capture and recall use the configured user across all workspaces. This is intentional compatibility behavior. To isolate workspaces, set `memoryScope: workspace` as shown above. All writes then attach an `appId` derived from the canonical absolute session workspace path; all searches require its matching `app_id`. The model cannot override that workspace filter.
Symlink aliases share a scope. Different directories, clones, or worktrees have different scopes; moving a directory changes its scope. Missing or invalid workspace paths skip automatic memory operations and reject explicit tools without falling back to user-wide access. Existing user-only memories are not migrated into workspace scopes.
User scope searches all memories belonging to the user, including workspace-tagged memories. Workspace isolation applies only when workspace scope is configured; it is application filtering, not a separate Mem0 account or credential.
## Extraction and recall limits
Capture sends the full redacted turn to Mem0 for asynchronous extraction. A queued write, or even an initially nonempty memory list, does not mean every extracted fact is ready. Allow processing to finish and inspect stored memories before diagnosing lost preferences or missing filenames and numeric constraints. Exact extraction remains model-dependent; a one-off request should not automatically become a standing preference.
Automatic recall is a shallow first pass: up to five results, 4,000 context characters, and a two-second wait. The explicit `search_memory` tool can use a more focused query and returns up to ten results by default.
## Telemetry
@@ -124,6 +140,6 @@ Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to thi
- **`MISSING_CREDENTIAL` for `deepseek-official`**: Configure `DEEPSEEK_API_KEY` through Harness's Models page or export it in the shell that launches Harness.
- **`EMFILE: too many open files, watch` on macOS**: Launch Harness with `CHOKIDAR_USEPOLLING=1`.
- **Mem0 tools do not appear**: Run Harness with `--dump-config` and confirm the final composition contains the `mem0` row and the installed `dist/index.js` path.
- **Mem0 tools do not appear**: Run Harness with `--dump-config` for the same profile used at installation and confirm it contains a `mem0` row naming `@mem0/deepseek-plugin`. Export `MEM0_USER_ID` and `MEM0_API_KEY` before launching.
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
+1 -1
View File
@@ -23,7 +23,7 @@ npm install -g flowise
npx flowise start
```
2. Access to the Flowise UI at `http://localhost:3000`
2. Access to the Flowise UI at http://localhost:3000
3. Basic familiarity with [Flowise's LLM orchestration](https://flowiseai.com/#features) concepts
## Setup and Configuration
+26 -61
View File
@@ -1,35 +1,39 @@
---
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 with Mem0, on managed Mem0 Cloud or fully self-hosted (OSS), with automatic background sync and zero-latency prefetch."
---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones for the current question, without slowing down the chat.
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. 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 before each turn, without slowing down the chat.
You can run Mem0 in three ways:
You can run Mem0 in two ways:
- **Platform mode** (default): managed Mem0 Cloud. Add your API key and you are ready.
- **Self-hosted server mode**: point the plugin at a Mem0 server you run yourself (the Docker-shipped server). The plugin only talks HTTP to your server.
- **OSS mode**: run Mem0 in-process with your own LLM, embedder, and vector store. No Mem0 server required.
- **OSS mode**: fully self-hosted with your own LLM, embedder, and vector store. No data leaves your machine.
## How It Works
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at two points in every conversation turn.
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three points in every conversation turn.
### 1. Current-turn recall (bounded wait)
### 1. Before the agent responds (prefetch)
When you send a message, Hermes searches your stored memories for the current question and waits up to 3 seconds for results. If they arrive in time, they are injected into the system prompt so the model can see them. If the backend is slower, Hermes skips the injection and the model can still call `mem0_search` itself — so a slow backend never blocks a turn.
When you send a message, Hermes checks for cached Mem0 search results from the previous turn. If they exist, those memories are injected into the system prompt so the model can see them. This is zero-latency, with no waiting on an API call.
### 2. Background fact extraction (sync)
### 2. After the agent responds (sync)
Once the model finishes, Hermes sends the `(user message, assistant response)` pair to Mem0 in a background thread. Mem0 extracts facts automatically (for example, "user prefers Python" or "user works at Acme Corp"), so you never have to tell it what to remember. Each write is tagged with the gateway channel it came from.
### 3. Background prefetch for the next turn
At the same time, Hermes runs a background search to pre-load relevant memories for your next message. By the time you type, the results are already cached.
## Agent Tools
When Mem0 is active, the model gets four tools it can call during a conversation:
When Mem0 is active, the model gets five tools it can call during a conversation:
| Tool | Description | Parameters |
|------|-------------|------------|
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `false`, Platform mode only) |
| `mem0_list` | List all stored memories, for a full overview | `page`, `page_size` (default 100, max 200) |
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `true`, Platform mode only) |
| `mem0_add` | Store a fact verbatim, with no LLM extraction | `content` (required) |
| `mem0_update` | Update a memory's text by ID | `memory_id`, `text` (both required) |
| `mem0_delete` | Delete a memory by ID | `memory_id` (required) |
@@ -75,36 +79,6 @@ memory:
That's it. Mem0 runs automatically from here.
## Self-Hosted Server Setup
Run the [Mem0 server](https://github.com/mem0ai/mem0/tree/main/server) (FastAPI + pgvector) from its Docker image and point the plugin at it. Unlike OSS mode, the plugin just talks HTTP to your server.
### Interactive
```bash
hermes memory setup
# Select "mem0", then "Self-hosted server", and enter the server URL
```
### With flags
```bash
hermes memory setup mem0 --mode selfhosted \
--host http://localhost:8888 \
--api-key your-admin-api-key
```
### With environment variables
```bash
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
```
Then start a fresh Hermes session and call `mem0_search` — it connects to your server. The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. The API key is optional only for servers running with `AUTH_DISABLED`.
<Note>Setting `host` routes to the self-hosted server automatically. Don't combine it with `mode: oss` — OSS takes precedence and ignores `host`.</Note>
## OSS (Self-Hosted) Setup
OSS mode runs Mem0 entirely on your own infrastructure: your LLM, your embedder, and your vector store. No data is sent to Mem0 Cloud, and no Mem0 API key is required.
@@ -137,20 +111,15 @@ hermes memory setup mem0 --mode oss \
| Flag | Description |
|------|-------------|
| `--mode` | `platform`, `selfhosted`, or `oss` |
| `--api-key` | Platform API key, or the admin key of a self-hosted server |
| `--host` | Self-hosted server URL (with `--mode selfhosted`) |
| `--mode` | `platform` or `oss` |
| `--oss-llm` | LLM provider (`openai` or `ollama`, default `openai`) |
| `--oss-llm-key` | LLM API key (for `openai`) |
| `--oss-llm-model` | Override the LLM model |
| `--oss-llm-url` | LLM base URL (for `ollama` or a custom endpoint) |
| `--oss-embedder` | Embedder provider (default `openai`) |
| `--oss-embedder-key` | Embedder API key |
| `--oss-embedder-model` | Override the embedder model |
| `--oss-embedder-url` | Embedder base URL (for `ollama` or a custom endpoint) |
| `--oss-vector` | Vector store (`qdrant` or `pgvector`, default `qdrant`) |
| `--oss-vector-path` | Local Qdrant storage path |
| `--oss-vector-url` | Qdrant server URL |
| `--oss-vector-host`, `--oss-vector-port` | PGVector or remote Qdrant host and port |
| `--oss-vector-user`, `--oss-vector-password`, `--oss-vector-dbname` | PGVector connection details |
| `--user-id` | Canonical user identifier |
@@ -158,7 +127,7 @@ hermes memory setup mem0 --mode oss \
## Switching Modes
You can move between the three modes at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
You can move between Platform and OSS at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
```bash
# Platform to OSS
@@ -167,9 +136,6 @@ hermes memory setup mem0 --mode oss --oss-llm-key sk-...
# OSS to Platform
hermes memory setup mem0 --mode platform --api-key sk-...
# Platform to a self-hosted server
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888
# Preview without writing anything
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
```
@@ -180,7 +146,7 @@ A self-hosted `~/.hermes/mem0.json` looks like this:
{
"mode": "oss",
"oss": {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini", "is_reasoning_model": true}},
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {"path": "~/.hermes/mem0_qdrant"}}
}
@@ -193,12 +159,11 @@ Behavioral settings live in `~/.hermes/mem0.json` and are written for you by `he
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process). Self-hosted server routing is set via `host` |
| `host` | none | Self-hosted Mem0 server URL. When set, the plugin talks HTTP to your server instead of the cloud |
| `api_key` | none | Mem0 Platform API key, or the admin key of a self-hosted server. Stored in `.env` as `MEM0_API_KEY` |
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
| `api_key` | none | Mem0 Platform API key, required in Platform mode. Stored in `.env` as `MEM0_API_KEY` |
| `user_id` | `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `agent_id` | `hermes` | Agent identifier attached to writes |
| `rerank` | `false` | Rerank search results for relevance (Platform mode only) |
| `rerank` | `true` | Rerank search results for relevance (Platform mode only) |
### Cross-channel memories
@@ -209,11 +174,12 @@ Hermes can run from the CLI and from gateways like Telegram, Slack, and Discord.
Either way, every write is tagged with `metadata.channel` (for example `telegram` or `cli`), so per-channel views are still possible at query time.
## Reliability
- **Circuit breaker**: if Mem0 fails five times in a row, Hermes pauses calls for two minutes, then retries. The agent keeps working without memory during that window. Expected client errors, like a 404 on a missing memory id, do not count toward tripping the breaker.
- **Non-blocking**: fact extraction runs in a background daemon thread, and current-turn recall waits at most 3 seconds, so a slow or failed call never blocks your conversation.
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and recall threads are guarded so concurrent gateway messages cannot produce duplicate memories.
- **Non-blocking**: every Mem0 call runs in a background daemon thread, so a slow or failed call never blocks your conversation.
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and prefetch threads are guarded so concurrent gateway messages cannot produce duplicate memories.
## Troubleshooting
@@ -222,7 +188,6 @@ Either way, every write is tagged with `metadata.channel` (for example `telegram
The circuit breaker tripped after five consecutive failures and resets after two minutes.
- **Platform mode**: check your API key and internet connection.
- **Self-hosted server mode**: check that the server is running and reachable at the configured `host` URL.
- **OSS mode**: make sure your vector store (Qdrant or PGVector) is running and reachable.
### OSS: vector store connection refused
@@ -252,8 +217,8 @@ curl http://localhost:11434/api/tags
## Key Features
1. **Three ways to run**: managed Platform, a self-hosted server, or fully local OSS, switchable at any time.
2. **Current-turn recall**: memories for the current question are injected within a 3-second window, with `mem0_search` as the model's own backstop.
1. **Two ways to run**: managed Platform or fully self-hosted OSS, switchable at any time.
2. **Zero-latency recall**: memories are prefetched in the background and cached before you type.
3. **Automatic extraction**: Mem0 extracts and deduplicates facts from each exchange for you.
4. **Non-blocking and fault tolerant**: background threads plus a circuit breaker keep the agent responsive even when Mem0 is unreachable.
5. **Additive memory**: works alongside Hermes' built-in file memory (`MEMORY.md`, `USER.md`).
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "LangChain Integration"
sidebarTitle: "Langchain"
title: Langchain
seo:
title: "LangChain Integration with Mem0"
description: "Build personalized AI agents using LangChain for conversation flow and Mem0 for long-term memory retention."
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "n8n Integration"
sidebarTitle: "n8n"
title: n8n
seo:
title: "n8n Integration with Mem0"
description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required."
---
@@ -1,6 +1,7 @@
---
title: "Open Source Custom Instructions"
sidebarTitle: "Custom Instructions"
title: Custom Instructions
seo:
title: "Open Source Custom Instructions - Mem0"
description: Tailor fact extraction so Mem0 stores only the details you care about.
icon: "wand-magic-sparkles"
---
@@ -1,6 +1,7 @@
---
title: "Open Source Multimodal Support"
sidebarTitle: "Multimodal Support"
title: Multimodal Support
seo:
title: "Open Source Multimodal Support - Mem0"
description: Capture and recall memories from both text and images.
icon: "image"
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Open Source Features Overview"
sidebarTitle: "Overview"
title: "Overview"
seo:
title: "Open Source Features Overview - Mem0"
description: "Self-hosting features that extend Mem0 beyond basic memory storage"
icon: "list"
---
+1 -1
View File
@@ -56,7 +56,7 @@ The Mem0 REST API server exposes every OSS memory operation over HTTP. Run it al
make bootstrap # starts Compose, creates an admin, issues the first API key
```
Or to start the stack only and finish setup via the browser wizard at `http://localhost:3000`:
Or to start the stack only and finish setup via the browser wizard at http://localhost:3000:
```bash
cd server
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Open Source Overview"
sidebarTitle: "Overview"
title: "Overview"
seo:
title: "Mem0 Open Source Overview"
description: "Self-host Mem0 with full control over your infrastructure and data"
icon: "house"
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "CLI for Terminal Memory Management"
sidebarTitle: "CLI"
title: CLI
seo:
title: "Mem0 CLI for Terminal Memory Management"
description: "Manage memories from your terminal, for both humans and AI agents."
icon: "terminal"
iconType: "solid"
@@ -1,6 +1,7 @@
---
title: "Platform Custom Instructions"
sidebarTitle: "Custom Instructions"
title: Custom Instructions
seo:
title: "Platform Custom Instructions - Mem0"
description: 'Control how Mem0 extracts and stores memories using natural language guidelines'
---
@@ -1,6 +1,7 @@
---
title: "Platform Multimodal Support"
sidebarTitle: "Multimodal Support"
title: Multimodal Support
seo:
title: "Platform Multimodal Support - Mem0"
description: Integrate images and documents into your interactions with Mem0
---
+3 -2
View File
@@ -1,6 +1,7 @@
---
title: "Platform Overview"
sidebarTitle: "Overview"
title: "Overview"
seo:
title: "Mem0 Platform Overview"
description: "Managed memory layer for AI agents, production-ready in minutes"
icon: "cloud"
---
@@ -149,7 +149,7 @@ export async function buildRecallContext(
if (!unseen.length) return "";
const prefix =
"<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — search mem0_memory for more if you need it.\n";
"<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — use the available memory search tool for more if you need it.\n";
const suffix = "\n</mem0-relevant-memories>";
const maxChars = options.maxChars ?? DEFAULT_MAX_CONTEXT_CHARS;
const lines: string[] = [];
@@ -1,5 +1,3 @@
import { randomUUID } from "node:crypto";
import { redactSecrets } from "./lifecycle.ts";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
@@ -76,108 +74,34 @@ 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>[]) => {
const response = await fetch(POSTHOG_BATCH_URL, {
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(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;
async function flush(): Promise<void> {
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 {
// 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;
// Telemetry must never affect plugin behavior.
}
}
function beforeExit(): void {
// 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);
void flush();
}
function build(event: string, properties: Record<string, unknown> = {}): Record<string, unknown> | null {
@@ -188,15 +112,6 @@ 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 ?? {}),
@@ -219,10 +134,8 @@ 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?.();
@@ -236,8 +149,6 @@ export function createTelemetry(config: TelemetryConfig) {
function resetForTesting(): void {
queue = [];
consecutiveFailures = 0;
retryNotBefore = 0;
if (timer) clearInterval(timer);
timer = undefined;
process.off("beforeExit", beforeExit);
@@ -122,250 +122,3 @@ 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();
});
+23 -6
View File
@@ -13,7 +13,7 @@ It gives a Harness agent automatic long-term memory plus two explicit memory too
Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed backend: server-side extraction, semantic dedup and conflict resolution, and memories that other agents can retrieve when their user and entity filters match.
Current package version: `0.3.0`.
Current package version: `0.3.1`.
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
@@ -47,21 +47,27 @@ Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic
mkdir -p /tmp/mem0-deepseek-plugin
pnpm pack --pack-destination /tmp/mem0-deepseek-plugin
```
2. Set your Mem0 key:
2. Set your Mem0 key, a stable user identity, and a funded model API key:
```sh
export MEM0_API_KEY=...
export MEM0_USER_ID=your-user-id
export DEEPSEEK_API_KEY=...
```
3. Install it into a disposable Harness profile:
```sh
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
plugin --profile web add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.1.tgz
```
4. Copy `cordis.example.yml`, set its installed package path and your `userId`, then run Harness with the same profile:
4. Run Harness with the same profile. The installed bundle activates Mem0 automatically:
```sh
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
web --patch ./integrations/deepseek-plugin/cordis.example.yml
web
```
5. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
5. Open http://127.0.0.1:3080, select a workspace, and state a synthetic preference without mentioning Mem0. Wait for asynchronous extraction to finish, then start a fresh session and ask for that preference without tools. Inspect the `mem0:recall` context to verify automatic recall.
Build from the full repository: the source imports the sibling shared core. `pnpm pack` runs the build and includes the activation patch. An installed tarball is self-contained. For custom settings, copy `cordis.example.yml` and pass its absolute path with `--patch`. When upgrading from the old manual setup, remove the old patch that **inserts** a `mem0` row; the bundle now inserts it.
On macOS, set `CHOKIDAR_USEPOLLING=1` if Harness reports `EMFILE`. The Mem0 key and model-provider key are separate credentials.
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` overrides the Platform base URL. It does not support the self-hosted Mem0 OSS API.
@@ -71,6 +77,7 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|---|---|---|---|
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
| `userId` | yes | | Entity that owns the memories |
| `memoryScope` | no | `user` | `user` shares memory across workspaces; `workspace` isolates all automatic and explicit operations by the session workspace |
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
@@ -80,10 +87,20 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
Automatic capture and recall use the configured `userId` across sessions. Automatic writes do not attach a repository ID or `runId`.
This cross-workspace sharing is intentional compatibility behavior. Set `memoryScope: workspace` in the profile patch to isolate memory. The plugin then adds an `appId` derived from the canonical absolute session workspace path to every write and the matching `app_id` to every search. Symlink aliases share a scope; different directories (including separate clones or worktrees) do not. Moving a workspace changes its scope. Tools cannot override it. Missing or invalid workspace paths skip automatic memory operations and reject explicit tools, without falling back to user-wide access.
Workspace scope does not migrate existing user-only memories. User scope still searches all memories for that user, including workspace-tagged memories; isolation applies when the plugin is configured with workspace scope. These filters are application-level separation, not separate Mem0 credentials.
Both `search_memory` and `add_memory` accept optional `agentId` and `runId`. On search, these narrow the returned memories; on add, they attach those identities to the stored memory. Pass a known `runId` to search memories explicitly saved with that session ID. This does not include automatically captured user-only memories or identify the session making the request.
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
## Extraction and recall limits
The plugin sends full redacted completed turns to Mem0; Mem0 extracts the memories asynchronously. A queued write is not proof that every extracted fact is already searchable. Even an initial nonempty memory list can be incomplete. Check the stored memories after processing before diagnosing extraction loss.
Automatic recall is a bounded first pass (up to five results, 4,000 context characters, and a two-second wait). `search_memory` uses a focused query and returns up to ten results by default. Exact preference extraction remains model-dependent; include filenames and numeric constraints in release tests, and distinguish standing preferences from one-off requests.
## Telemetry
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0's backend can attribute usage to this integration. For it to surface by name (rather than bucketing into `OTHERS`), `DEEPSEEK_HARNESS` must be present in the backend's `KNOWN_EVENT_SOURCES` allowlist, a one-line platform change matching the existing `ZAPIER` / `STRANDS` sources.
+11 -17
View File
@@ -1,17 +1,11 @@
# Example DeepSeek Harness config that loads deepseek-plugin alongside the built-in
# tools plugin. Load with:
#
# DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
# web --patch ./integrations/deepseek-plugin/cordis.example.yml
#
- insert:
- id: mem0
# Install the packed plugin into this disposable profile first; loading
# raw dist bypasses Harness's peer dependency installation.
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
config:
# apiKey is read from the MEM0_API_KEY env var when omitted here.
userId: "your-user-id"
# autoRecall: true # optional; enabled by default
# autoCapture: true # optional; enabled by default
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
# Optional override for the mem0 row installed by dsh.bundle.
# Copy this file, then use: dsh web --patch /absolute/path/to/cordis.yml
- id: mem0
config:
# Replacing a row's config requires restating its userId.
# apiKey is read from MEM0_API_KEY when omitted here.
userId: !!js process.env.MEM0_USER_ID
memoryScope: workspace # optional; default is user (shared across workspaces)
# autoRecall: true
# autoCapture: true
# host: "https://your-onprem.mem0.ai" # Mem0 Platform dedicated deployment
@@ -0,0 +1,6 @@
- insert:
- id: mem0
name: "@mem0/deepseek-plugin"
config:
# Credentials stay in the process environment, not the bundle.
userId: !!js process.env.MEM0_USER_ID
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/deepseek-plugin",
"version": "0.3.0",
"version": "0.3.1",
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
"type": "module",
"license": "Apache-2.0",
@@ -29,13 +29,21 @@
"publishConfig": {
"access": "public"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"files": [
"dist",
"cordis.patch.yml",
"cordis.example.yml",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsup",
"prepack": "pnpm build",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
+33 -10
View File
@@ -18,7 +18,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
import { MemoryClient } from "mem0ai";
import { formatMemoryList, formatAddResult } from "./formatting.ts";
import { truncateOutput } from "./output.ts";
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
import { resolveSearchFilters, resolveAddParams, resolveWorkspaceId } from "./scoping.ts";
import { captureEvent, errorKind } from "./telemetry.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
@@ -44,6 +44,7 @@ interface HarnessMessage {
interface SessionState {
lifecycle: ReturnType<typeof createMemoryLifecycle>;
messages: HarnessMessage[];
currentPrompt?: string;
}
export interface Config {
@@ -51,6 +52,8 @@ export interface Config {
apiKey?: string;
/** Default entity that owns the memories (Mem0 user scope). */
userId: string;
/** Share user memory across workspaces (default), or isolate by session workspace. */
memoryScope?: "user" | "workspace";
/** Explicitly allow model-selected cross-user access. Defaults to false. */
allowUserOverride?: boolean;
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
@@ -98,6 +101,19 @@ export function apply(ctx: Context, config: Config): void {
if (!userId || /^\*+$/.test(userId)) {
throw new Error("deepseek-plugin: config.userId is required");
}
if (config.memoryScope !== undefined && !["user", "workspace"].includes(config.memoryScope)) {
throw new Error("deepseek-plugin: config.memoryScope must be user or workspace");
}
const workspaceId = (session?: { header: { cwd?: string } }) =>
config.memoryScope === "workspace" ? resolveWorkspaceId(session?.header.cwd) : undefined;
const searchFilters = (params: Parameters<typeof resolveSearchFilters>[0], session?: { header: { cwd?: string } }) => {
const appId = workspaceId(session);
return { ...resolveSearchFilters(params, userId), ...(appId ? { app_id: appId } : {}) };
};
const addParams = (params: Parameters<typeof resolveAddParams>[0], session?: { header: { cwd?: string } }) => {
const appId = workspaceId(session);
return { ...resolveAddParams(params, userId), ...(appId ? { appId } : {}) };
};
const client = new MemoryClient({
apiKey,
@@ -123,13 +139,20 @@ export function apply(ctx: Context, config: Config): void {
}, client);
if (config.autoRecall !== false) {
// Harness assembles the prompt before appending claimed messages to history.
ctx.on("agent/inbox/claimed", ({ agent, message }) => {
if (message.source.kind !== "user") return;
const state = stateFor(agent.session);
state.currentPrompt = state.lifecycle.prepareConversation([message]).at(-1)?.content;
});
ctx.on("system-prompt/assemble", async (_input, context, next): Promise<PromptAssembly> => {
const assembly = await next();
const { agent, signal } = context;
if (!agent || signal?.aborted) return assembly;
const state = stateFor(agent.session);
const prompt = state.lifecycle.prepareConversation(
const prompt = state.currentPrompt ?? state.lifecycle.prepareConversation(
agent.session
.deriveMessages()
.filter((message) => message.role === "user" && message.source?.kind === "user"),
@@ -140,7 +163,7 @@ export function apply(ctx: Context, config: Config): void {
const started = Date.now();
try {
const result = await client.search(query, {
filters: resolveSearchFilters({}, userId),
filters: searchFilters({}, agent.session),
topK: AUTO_RECALL_LIMIT,
});
captureEvent("deepseek.recall.auto", {
@@ -179,8 +202,8 @@ export function apply(ctx: Context, config: Config): void {
const conversation = state.lifecycle.prepareConversation(state.messages);
state.messages = [];
if (event.data.reason.kind !== "completed" || conversation.length === 0) return;
void client
.add(conversation, { userId, source: SOURCE })
void Promise.resolve()
.then(() => client.add(conversation, { ...addParams({}, session), source: SOURCE }))
.then(() => captureEvent("deepseek.capture.auto", {
success: true,
message_count: conversation.length,
@@ -209,12 +232,12 @@ export function apply(ctx: Context, config: Config): void {
...scopeParams,
},
output: textOutput,
async execute({ query, limit, userId: u, agentId, runId }) {
async execute({ query, limit, userId: u, agentId, runId }, exec) {
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
}
const safeQuery = toolLifecycle.prepareUserText(query);
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
const filters = searchFilters({ userId: u, agentId, runId }, exec.agent?.session);
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
const started = Date.now();
try {
@@ -262,17 +285,17 @@ export function apply(ctx: Context, config: Config): void {
...scopeParams,
},
output: textOutput,
async execute({ text, userId: u, agentId, runId }) {
async execute({ text, userId: u, agentId, runId }, exec) {
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
}
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
const params = addParams({ userId: u, agentId, runId }, exec.agent?.session);
const started = Date.now();
try {
const result = await client.add(
[{ role: "user", content: toolLifecycle.prepareUserText(text) }],
{
...addParams,
...params,
source: SOURCE,
},
);
@@ -1,5 +1,16 @@
import { createHash } from "node:crypto";
import { realpathSync } from "node:fs";
import { isAbsolute } from "node:path";
export {
entityAddParams as resolveAddParams,
entitySearchFilters as resolveSearchFilters,
} from "../../agent-plugin-core/typescript/src/identity.ts";
export type { EntityParams } from "../../agent-plugin-core/typescript/src/identity.ts";
export function resolveWorkspaceId(cwd: string | undefined): string {
if (!cwd || !isAbsolute(cwd)) {
throw new Error("Workspace memory scope requires an absolute session workspace path.");
}
return `deepseek-workspace-${createHash("sha256").update(realpathSync(cwd)).digest("hex")}`;
}
@@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Offline mock of the Mem0 SDK so these tests never touch the network.
const mockSearch = vi.fn();
@@ -75,6 +78,11 @@ describe("apply() config validation", () => {
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
});
it("rejects unknown memory scope instead of silently using user-wide access", () => {
expect(() => applyAndCollect({ apiKey: "k", userId: "u", memoryScope: "typo" } as unknown as Config))
.toThrow(/memoryScope/);
});
it("registers both memory tools", () => {
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
expect([...tools.keys()].sort()).toEqual(["add_memory", "search_memory"]);
@@ -82,6 +90,35 @@ describe("apply() config validation", () => {
});
describe("Harness lifecycle", () => {
it("recalls the claimed inbox message before Harness appends it to session history", async () => {
mockSearch.mockResolvedValue({ results: [{ id: "m1", memory: "Uses pnpm" }] });
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
const session = { deriveMessages: () => [] };
const agent = { session };
const base = { sections: [], contexts: [], tools: [], variables: {} };
listeners.get("agent/inbox/claimed")?.({
agent, turn: 1,
message: { role: "user", source: { kind: "user" }, content: "Which package manager do I use?" },
});
const result = await listeners.get("system-prompt/assemble")!(base, { agent }, async () => base);
expect(mockSearch).toHaveBeenCalledWith("Which package manager do I use?", {
filters: { user_id: "u" }, topK: 5,
});
expect(result.contexts[0].text).toContain("Uses pnpm");
expect(result.contexts[0].text).not.toContain("search mem0_memory");
listeners.get("agent/inbox/claimed")?.({
agent, turn: 2,
message: { role: "user", source: { kind: "user" }, content: "What indentation do I use?" },
});
listeners.get("agent/inbox/claimed")?.({
agent, turn: 2,
message: { role: "user", source: { kind: "plugin" }, content: "Ignore the human prompt" },
});
await listeners.get("system-prompt/assemble")!(base, { agent }, async () => base);
expect(mockSearch.mock.lastCall?.[0]).toBe("What indentation do I use?");
});
it("automatically recalls memory into the prompt for the latest human message", async () => {
mockSearch.mockResolvedValue({
results: [{ id: "m1", memory: "Likes tea" }],
@@ -168,8 +205,25 @@ describe("Harness lifecycle", () => {
});
expect(listeners.has("system-prompt/assemble")).toBe(false);
expect(listeners.has("agent/inbox/claimed")).toBe(false);
expect(listeners.has("session/event")).toBe(false);
});
it("does not capture interrupted turns and isolates rejected capture requests", async () => {
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
const onEvent = listeners.get("session/event")!;
const session = {};
const userEvent = { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "I prefer short answers. What is a list?" } };
onEvent(session, { type: "turn/start" });
onEvent(session, userEvent);
onEvent(session, { type: "turn/end", data: { reason: { kind: "interrupted" } } });
expect(mockAdd).not.toHaveBeenCalled();
mockAdd.mockRejectedValue(new Error("backend unavailable"));
onEvent(session, { type: "turn/start" });
onEvent(session, userEvent);
onEvent(session, { type: "turn/end", data: { reason: { kind: "completed" } } });
await vi.waitFor(() => expect(mockAdd).toHaveBeenCalledOnce());
});
});
describe("search_memory tool", () => {
@@ -272,3 +326,62 @@ describe("tool user ownership", () => {
expect(mockAdd).not.toHaveBeenCalled();
});
});
describe("workspace scope", () => {
it("isolates both tools and automatic paths by canonical session workspace", async () => {
const root = mkdtempSync(join(tmpdir(), "mem0-dsh-scope-"));
try {
mkdirSync(join(root, "a"));
mkdirSync(join(root, "b"));
symlinkSync(join(root, "a"), join(root, "alias"));
const config: Config = { apiKey: "k", userId: "u", memoryScope: "workspace" };
const tools = applyAndCollect(config);
mockAdd.mockResolvedValue([]);
mockSearch.mockResolvedValue({ results: [] });
const session = (name: string) => ({
header: { cwd: join(root, name) },
deriveMessages: () => [{ role: "user", source: { kind: "user" }, content: "preferences" }],
});
const a = session("a");
await tools.get("add_memory")!.execute({ text: "Uses pnpm" }, { agent: { session: a } });
const appId = mockAdd.mock.lastCall?.[1].appId;
expect(appId).toMatch(/^deepseek-workspace-[a-f0-9]{64}$/);
await tools.get("search_memory")!.execute({ query: "preferences" }, { agent: { session: session("alias") } });
expect(mockSearch.mock.lastCall?.[1].filters).toEqual({ user_id: "u", app_id: appId });
await tools.get("search_memory")!.execute({ query: "preferences" }, { agent: { session: session("b") } });
expect(mockSearch.mock.lastCall?.[1].filters.app_id).not.toBe(appId);
const listeners = applyAndCollectListeners(config);
const base = { sections: [], contexts: [], tools: [], variables: {} };
await listeners.get("system-prompt/assemble")!(base, { agent: { session: a } }, async () => base);
expect(mockSearch.mock.lastCall?.[1].filters).toEqual({ user_id: "u", app_id: appId });
const onEvent = listeners.get("session/event")!;
mockAdd.mockClear();
onEvent(a, { type: "turn/start" });
onEvent(a, { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "I prefer short answers. What is a list?" } });
onEvent(a, { type: "turn/end", data: { reason: { kind: "completed" } } });
await vi.waitFor(() => expect(mockAdd.mock.lastCall?.[1]).toMatchObject({ userId: "u", appId }));
expect(mockAdd).toHaveBeenCalledOnce();
expect(mockAdd.mock.lastCall?.[0]).toEqual([
{ role: "user", content: "I prefer short answers. What is a list?" },
]);
} finally { rmSync(root, { recursive: true, force: true }); }
});
it("fails closed without a workspace while automatic paths leave the agent running", async () => {
const config: Config = { apiKey: "k", userId: "u", memoryScope: "workspace" };
const tools = applyAndCollect(config);
await expect(tools.get("add_memory")!.execute({ text: "x" }, {})).rejects.toThrow(/workspace/);
await expect(tools.get("search_memory")!.execute({ query: "x" }, {})).rejects.toThrow(/workspace/);
const listeners = applyAndCollectListeners(config);
const session = { header: {}, deriveMessages: () => [{ role: "user", source: { kind: "user" }, content: "hello" }] };
const base = { sections: [], contexts: [], tools: [], variables: {} };
expect(await listeners.get("system-prompt/assemble")!(base, { agent: { session } }, async () => base)).toBe(base);
const onEvent = listeners.get("session/event")!;
onEvent(session, { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "hello" } });
expect(() => onEvent(session, { type: "turn/end", data: { reason: { kind: "completed" } } })).not.toThrow();
await new Promise((resolve) => setImmediate(resolve));
expect(mockAdd).not.toHaveBeenCalled();
expect(mockSearch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,14 @@
import { readFileSync, existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("installable Harness bundle", () => {
it("ships a native activation patch and builds before packing", () => {
const root = new URL("../", import.meta.url);
const manifest = JSON.parse(readFileSync(new URL("package.json", root), "utf8"));
expect(manifest.scripts.prepack).toBe("pnpm build");
expect(manifest.dsh.bundle.patch).toBe("./cordis.patch.yml");
expect(manifest.files).toContain("cordis.patch.yml");
expect(existsSync(new URL(manifest.dsh.bundle.patch, root))).toBe(true);
expect(readFileSync(new URL(manifest.dsh.bundle.patch, root), "utf8")).toContain("@mem0/deepseek-plugin");
});
});
-20
View File
@@ -35,8 +35,6 @@ export interface PluginAuthConfig {
autoCapture?: boolean;
topK?: number;
anonymousTelemetryId?: string;
/** SHA-256 prefix of the API key userEmail was resolved for. */
keyFingerprint?: string;
}
// ============================================================================
@@ -137,9 +135,6 @@ export function readPluginAuth(): PluginAuthConfig {
autoCapture: cfg.autoCapture as boolean | undefined,
topK: cfg.topK as number | undefined,
anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined,
// Without this the reader silently drops it, every fingerprint comparison
// fails against undefined, and the resolved email is never used again.
keyFingerprint: cfg.keyFingerprint as string | undefined,
};
}
@@ -241,21 +236,6 @@ export function getBaseUrl(): string {
return auth.baseUrl || DEFAULT_BASE_URL;
}
/** Forget the resolved account, so the next capture re-resolves for the current key. */
export function clearResolvedAccount(): void {
const full = readFullConfig() as any;
const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
if (!cfg) return;
let changed = false;
for (const key of ["userEmail", "keyFingerprint"]) {
if (key in cfg) {
delete cfg[key];
changed = true;
}
}
if (changed) writeFullConfig(full);
}
/** Remove anonymousTelemetryId from config (after PostHog aliasing) */
export function clearAnonymousTelemetryId(): void {
const full = readFullConfig() as any;
-1
View File
@@ -148,7 +148,6 @@ const ALLOWED_KEYS = [
"mode",
"apiKey",
"anonymousTelemetryId",
"keyFingerprint",
"baseUrl",
"userId",
"userEmail",
@@ -209,10 +209,6 @@
"type": "string",
"description": "Persistent anonymous telemetry identifier"
},
"keyFingerprint": {
"type": "string",
"description": "Digest of the API key userEmail was resolved for. Set automatically; a mismatch re-resolves the account."
},
"oss": {
"type": "object",
"properties": {
+11 -53
View File
@@ -1,20 +1,14 @@
import { createHash, randomUUID } from "node:crypto";
import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts";
import {
clearAnonymousTelemetryId,
clearResolvedAccount,
getBaseUrl,
readPluginAuth,
writePluginAuth,
} from "./cli/config-file.ts";
import { clearAnonymousTelemetryId, getBaseUrl, readPluginAuth, writePluginAuth } from "./cli/config-file.ts";
declare const __OPENCLAW_PLUGIN_VERSION__: string;
export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__;
let cachedAnonymousId: string | undefined;
let aliasCheckDone = false;
let resolutionAttemptedFor = "";
let emailResolutionAttempted = false;
let currentDistinctId = "";
function enabled(): boolean {
@@ -39,35 +33,10 @@ function anonymousId(): string {
return (cachedAnonymousId = created);
}
/** SHA-256 prefix of the key an account was resolved for. */
function keyFingerprint(apiKey?: string): string {
return apiKey ? createHash("sha256").update(apiKey).digest("hex").slice(0, 16) : "";
}
function distinctId(apiKey?: string): string {
try {
const auth = readPluginAuth();
if (auth.userEmail) {
// Only when it belongs to the key in hand. Without this check a cached
// email was used forever: switch to a different account and every event
// kept reporting under the previous one, with nothing to notice it by.
if (auth.keyFingerprint === keyFingerprint(apiKey)) {
return createHash("sha256").update(auth.userEmail).digest("hex");
}
// Only a REAL key that disagrees means the account changed. Without the
// apiKey guard the comparison is `undefined === ""` for any call that
// simply omits the key, so a capture with no context wiped a perfectly
// good account out of openclaw.json.
//
// A row with an email and NO fingerprint is the legacy shape, from an
// install predating this field. Clearing it here deleted a real account
// before anything had replaced it, and if the re-resolve then failed
// because the user was offline the email was gone from disk for good. The
// Python core refuses the same trade: verify, and keep what you have until
// the verification succeeds. resolveEmail below overwrites both fields
// when it does, so there is nothing to clear first.
if (apiKey && auth.keyFingerprint) clearResolvedAccount();
}
const email = readPluginAuth().userEmail;
if (email) return createHash("sha256").update(email).digest("hex");
} catch {
// Fall through to the API key or anonymous identity.
}
@@ -97,16 +66,8 @@ function identifyAnonymous(id: string): void {
}
function resolveEmail(apiKey: string): void {
// Latched per key, not once per process. A single boolean meant a key changed
// mid-session was never looked up, so the fallback identity stuck until restart.
const fingerprint = keyFingerprint(apiKey);
if (resolutionAttemptedFor === fingerprint) return;
resolutionAttemptedFor = fingerprint;
const releaseLatch = () => {
// A failed lookup must not pin the fallback identity for the rest of the
// process. Released so the next capture tries again.
if (resolutionAttemptedFor === fingerprint) resolutionAttemptedFor = "";
};
if (emailResolutionAttempted) return;
emailResolutionAttempted = true;
fetch(`${getBaseUrl().replace(/\/+$/, "")}/v1/ping/`, {
method: "GET",
headers: { Authorization: `Token ${apiKey}`, "Content-Type": "application/json" },
@@ -115,7 +76,7 @@ function resolveEmail(apiKey: string): void {
.then((response) => response.json())
.then((data: any) => {
if (!data?.user_email) return;
writePluginAuth({ userEmail: data.user_email, keyFingerprint: fingerprint });
writePluginAuth({ userEmail: data.user_email });
const oldId = createHash("sha256").update(apiKey).digest("hex");
const newId = createHash("sha256").update(data.user_email).digest("hex");
for (const event of telemetry.queueForTesting()) {
@@ -123,8 +84,7 @@ function resolveEmail(apiKey: string): void {
}
})
.catch(() => {
// The API-key hash remains a stable fallback, and the next capture retries.
releaseLatch();
// The API-key hash remains a stable fallback.
});
}
@@ -136,15 +96,13 @@ export function captureEvent(
if (!enabled()) return;
try {
currentDistinctId = distinctId(context?.apiKey);
let resolvedForThisKey = false;
let hasEmail = false;
try {
const auth = readPluginAuth();
resolvedForThisKey =
Boolean(auth.userEmail) && auth.keyFingerprint === keyFingerprint(context?.apiKey);
hasEmail = Boolean(readPluginAuth().userEmail);
} catch {
// Resolve it below when possible.
}
if (context?.apiKey && !resolvedForThisKey) resolveEmail(context.apiKey);
if (context?.apiKey && !hasEmail) resolveEmail(context.apiKey);
identifyAnonymous(currentDistinctId);
telemetry.capture(eventName, {
mode: context?.mode,
@@ -237,43 +237,3 @@ describe("getBaseUrl", () => {
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
});
});
// ---------------------------------------------------------------------------
// keyFingerprint round trip
// ---------------------------------------------------------------------------
describe("keyFingerprint survives a write and read", () => {
it("readPluginAuth returns a persisted keyFingerprint", () => {
// It did not. readPluginAuth builds its result field by field, and this one
// was missing, so every fingerprint comparison ran against undefined, the
// resolved email was never used again, and telemetry silently fell back to
// the API key hash. The telemetry tests could not see it because they mock
// this module and their mock returned the field the real reader dropped.
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
config: {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
},
},
},
},
});
expect(readPluginAuth().keyFingerprint).toBe("0123456789abcdef");
});
it("writePluginAuth persists it where readPluginAuth looks", () => {
setConfigFile({ plugins: { entries: { "openclaw-mem0": { config: {} } } } });
writePluginAuth({ userEmail: "person@example.com", keyFingerprint: "abc123" });
const written = JSON.parse(mockWriteText.mock.calls.at(-1)![1] as string);
const cfg = written.plugins.entries["openclaw-mem0"].config;
expect(cfg.keyFingerprint).toBe("abc123");
expect(cfg.userEmail).toBe("person@example.com");
});
});
@@ -485,18 +485,3 @@ describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
expect(cfg.needsSetup).toBe(false);
});
});
describe("telemetry fingerprint round trip", () => {
it("a config carrying keyFingerprint is accepted by the real schema", () => {
// What writePluginAuth persists after a successful lookup. It was not in
// ALLOWED_KEYS, and assertAllowedKeys throws, so the first successful
// resolve wrote a config that broke every subsequent load of the plugin.
const persisted = {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
};
expect(() => mem0ConfigSchema.parse(persisted)).not.toThrow();
});
});
+4 -76
View File
@@ -3,30 +3,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock config-file before importing telemetry
vi.mock("../cli/config-file.ts", () => ({
readPluginAuth: vi.fn().mockReturnValue({}),
writePluginAuth: vi.fn(),
clearAnonymousTelemetryId: vi.fn(),
clearResolvedAccount: vi.fn(),
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
}));
import { captureEvent } from "../telemetry.ts";
import { clearResolvedAccount, readPluginAuth } from "../cli/config-file.ts";
/** sha256(key).slice(0, 16), the shape telemetry.ts stores. */
async function fingerprintOf(apiKey: string): Promise<string> {
const { createHash } = await import("node:crypto");
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
}
import { readPluginAuth } from "../cli/config-file.ts";
describe("telemetry", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Call history has to be cleared per test, not just restored: the mocks are
// module-level vi.fn()s, so without this one test's calls are visible to the
// next and assertions on "was not called" pass or fail by ordering.
vi.clearAllMocks();
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
// Reset telemetry enabled state
(globalThis as any).__mem0_telemetry_override = undefined;
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
@@ -55,31 +40,11 @@ describe("telemetry", () => {
expect(() => captureEvent("test_event")).not.toThrow();
});
it("uses userEmail as distinct ID when it belongs to the current key", async () => {
// Previously asserted only not.toThrow(), which passed whatever the identity
// turned out to be, and under the fingerprint gate the no-context path does
// not use the email at all. Pin the real condition instead.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
it("uses userEmail as distinct ID when available", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
userEmail: "test@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-a" });
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("a capture with no apiKey leaves a resolved account alone", async () => {
// `undefined === ""` made every keyless capture look like a key change, so
// one context-free call wiped a good account out of openclaw.json.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "test@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event");
expect(clearResolvedAccount).not.toHaveBeenCalled();
expect(() => captureEvent("test_event")).not.toThrow();
});
it("falls back to a generated anonymous id when no apiKey", () => {
@@ -87,43 +52,6 @@ describe("telemetry", () => {
expect(() => captureEvent("test_event", {}, {})).not.toThrow();
});
it("keeps using a cached email only while it belongs to the current key", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-a" });
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("forgets the account when the API key changes", async () => {
// The defect: the cached email was used forever, so events after an account
// switch kept reporting under the previous account.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-b" });
expect(clearResolvedAccount).toHaveBeenCalled();
});
it("re-resolves for a key it has not looked up before", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-c" });
// The resolution latch is per key, not once per process, so a key changed
// mid-session is actually looked up instead of sticking to the fallback.
expect(fetchSpy).toHaveBeenCalled();
});
it("handles readPluginAuth errors gracefully", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
throw new Error("config read failed");
@@ -38,7 +38,6 @@
"build": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext]",
"dev": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext] --watch",
"type-check": "tsc --noEmit",
"test": "bun test",
"prepack": "bun run build",
"postpack": ""
},
+4 -47
View File
@@ -1,5 +1,3 @@
import { createHash } from "node:crypto";
import { afterEach, describe, expect, test } from "bun:test";
import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry";
@@ -15,10 +13,7 @@ describe("opencode telemetry", () => {
expect(payload).not.toBeNull();
const props = payload!.properties as Record<string, unknown>;
expect(payload!.event).toBe("plugin.session_start");
// Was "plugin", which named no particular plugin and matched no vocabulary.
// Now shaped like every other surface. Any saved PostHog insight filtering
// source = "plugin" needs repointing; historical data is untouched.
expect(props.source).toBe("OPENCODE_PLUGIN");
expect(props.source).toBe("plugin");
expect(props.platform).toBe("opencode");
expect(props.memory_count).toBe(5);
expect(props.$process_person_profile).toBe(false);
@@ -35,7 +30,7 @@ describe("opencode telemetry", () => {
const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)!
.properties as Record<string, unknown>;
expect(props.platform).toBe("opencode");
expect(props.source).toBe("OPENCODE_PLUGIN");
expect(props.source).toBe("plugin");
});
test("returns null without an API key (no anonymous events)", () => {
@@ -61,12 +56,9 @@ describe("opencode telemetry", () => {
expect(typeof props.os_version).toBe("string");
});
test("project_hash is a salted digest of the project id", async () => {
// Previously asserted the bare sha256(projectId), which is the defect: that
// digest is reversible by anyone who can guess a project id. Salted with the
// API key, which is already in play here and is high entropy.
test("project_hash is sha256(projectId) when a project id is supplied", async () => {
const { createHash } = await import("node:crypto");
const expected = createHash("sha256").update(`${KEY}:acme-repo`).digest("hex");
const expected = createHash("sha256").update("acme-repo").digest("hex");
const props = buildEvent("session_start", {}, KEY, "acme-repo")!
.properties as Record<string, unknown>;
expect(props.project_hash).toBe(expected);
@@ -84,38 +76,3 @@ describe("opencode telemetry", () => {
}
});
});
describe("project_hash salting", () => {
const PROJECT = "my-project";
test("is not a bare digest of the project id", () => {
// The defect: an unsalted SHA-256 over a guessable identifier is reversible
// by anyone who can enumerate project ids.
const unsalted = createHash("sha256").update(PROJECT).digest("hex");
const payload = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
expect(payload.properties.project_hash).toBeDefined();
expect(payload.properties.project_hash).not.toBe(unsalted);
});
test("differs per account for the same project", () => {
const a = buildEvent("session_start", {}, "m0-account-a", PROJECT) as Record<string, any>;
const b = buildEvent("session_start", {}, "m0-account-b", PROJECT) as Record<string, any>;
expect(a.properties.project_hash).not.toBe(b.properties.project_hash);
});
test("is stable for one account, so joins still work", () => {
const first = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
const second = buildEvent("session_end", {}, KEY, PROJECT) as Record<string, any>;
expect(first.properties.project_hash).toBe(second.properties.project_hash);
});
test("is omitted rather than unsalted when there is no key", () => {
const payload = buildEvent("session_start", {}, undefined, PROJECT);
// No key means no event at all, so there is no unsalted hash to leak.
expect(payload).toBeNull();
});
});
+5 -22
View File
@@ -25,9 +25,7 @@ const PLUGIN_VERSION = (() => {
let currentDistinctId = "";
const telemetry = createTelemetry({
host: "opencode",
// Shaped like the platform's EventSource values, as every other surface is.
// "plugin" said nothing about which plugin and matched no vocabulary.
source: "OPENCODE_PLUGIN",
source: "plugin",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
eventName: (event) => `plugin.${event}`,
@@ -42,23 +40,8 @@ function distinctId(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
}
/**
* Salted so the hash is not enumerable.
*
* An unsalted SHA-256 of a project id is reversible by anyone who can guess the
* id, which for a project identifier is a small space. The API key is the salt:
* it is already in play here (distinctId is a digest of it), it is high entropy,
* and using it needs no per-install file and so no write race to get wrong. The
* hash is therefore per account rather than per machine, which also keeps joins
* working for one user across machines. It resets when the key rotates, which is
* consistent, because distinctId resets with it.
*
* Both are omitted without a key. An event cannot be built without a distinctId
* anyway, so this costs nothing.
*/
function projectHash(projectId?: string, apiKey?: string): Record<string, string> {
if (!projectId || !apiKey) return {};
return { project_hash: createHash("sha256").update(`${apiKey}:${projectId}`).digest("hex") };
function projectHash(projectId?: string): Record<string, string> {
return projectId ? { project_hash: createHash("sha256").update(projectId).digest("hex") } : {};
}
export function buildEvent(
@@ -68,7 +51,7 @@ export function buildEvent(
projectId?: string,
): Record<string, unknown> | null {
currentDistinctId = apiKey ? distinctId(apiKey) : "";
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId, apiKey) });
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId) });
return event ? { api_key: POSTHOG_API_KEY, ...event } : null;
}
@@ -79,5 +62,5 @@ export function captureEvent(
projectId?: string,
): void {
currentDistinctId = apiKey ? distinctId(apiKey) : "";
telemetry.capture(eventType, { ...properties, ...projectHash(projectId, apiKey) });
telemetry.capture(eventType, { ...properties, ...projectHash(projectId) });
}