Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d3486ca56 | ||
|
|
ebb9bb2b15 | ||
|
|
594b4e65d6 | ||
|
|
1b95c99db4 | ||
|
|
b66cf0f272 | ||
|
|
72dca1cdf5 | ||
|
|
ece7ff6b84 |
@@ -4,6 +4,36 @@ description: "Release notes for the OpenClaw plugin and agent harness."
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-04-29" description="v1.0.11">
|
||||
|
||||
**New Features:**
|
||||
- **Skills-mode auto-setup:** `enableSkillsConfig()` now runs automatically after onboarding — enables triage, recall (with reranking + keyword search), and dream consolidation with `tools.profile = "full"` and disables the built-in session-memory hook to avoid conflicts
|
||||
- **Memory runtime capability:** Plugin now exposes `runtime.getMemorySearchManager()` and `resolveMemoryBackendConfig()` on the registered memory capability, enabling OpenClaw gateway to query memory status and backend config directly
|
||||
- **Dimension-aware collections:** OSS wizard detects embedder dimension changes and creates a new collection (`mem0_<dims>d`) automatically, with a warning about old memories being inaccessible under the new embedder
|
||||
- **Tool documentation in skills:** Both `memory-triage` and `memory-dream` SKILL.md files now include full tool reference sections listing all available tools with parameters
|
||||
|
||||
**Improvements:**
|
||||
- **Auto-capture and auto-recall default to enabled:** `autoCapture` and `autoRecall` now default to `true` (was `false`). Manifest descriptions updated accordingly. Ignored in skills mode
|
||||
- **`memory_update` over delete+add:** Skills now prefer `memory_update` for in-place edits — atomic and preserves edit history. Consolidation pattern updated: update best memory, delete redundant ones
|
||||
- **Search threshold lowered:** Default `searchThreshold` reduced from `0.5` to `0.1` for broader recall. Removed hardcoded `0.6` recall-specific override — all searches now use the configured threshold
|
||||
- **Embedder dimension propagation:** Vector store config auto-resolves dimensions from embedder config when not explicitly set. Syncs `dimension` and `embeddingModelDims` fields for Qdrant/PGVector compatibility
|
||||
- **Config file write safety:** `writeFullConfig()` now re-reads and deep-merges the `plugins` section before writing, preserving `installs` and `slots` written by the OpenClaw gateway
|
||||
- **Additional embedder models:** Added `mxbai-embed-large` (1024), `all-minilm` (384), and `snowflake-arctic-embed` (1024) to known embedder dimensions
|
||||
|
||||
**Security:**
|
||||
- Bumped `protobufjs` to `>=7.5.5` via pnpm overrides (GHSA-xq3m-2v4x-88gg) ([#5012](https://github.com/mem0ai/mem0/pull/5012))
|
||||
|
||||
**Fixes:**
|
||||
- Moved `bootstrapTelemetryFlag()` and removed `ensureInstallRecord()` from module-level side effects — both now run inside `register()` to avoid crashes when loaded outside OpenClaw gateway
|
||||
- Fixed OSS history DB path resolution: absolute paths no longer passed through `resolvePath()`, preventing double-prefix bugs
|
||||
- Manifest `providerAuthEnvVars` replaced with spec-compliant `setup.providers` format using `id` + `envVars`
|
||||
|
||||
**Dependencies:**
|
||||
- Bumped `mem0ai` from `3.0.1` to `3.0.2`
|
||||
- Bumped `pluginApi` and `minGatewayVersion` compat to `>=2026.4.24`
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-23" description="v1.0.10">
|
||||
|
||||
**Security:**
|
||||
|
||||
+81
-68
@@ -30,91 +30,100 @@ export MEM0_API_KEY="m0-your-api-key"
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A — Repo Marketplace (Recommended for Teams)
|
||||
### Option A — Direct MCP (Recommended)
|
||||
|
||||
Add a `.agents/plugins/marketplace.json` to your repository root:
|
||||
The fastest way to connect Codex to Mem0 — no downloads, no marketplace. Codex reads MCP servers from `~/.codex/config.toml` as TOML. Add:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/mem0"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
```toml
|
||||
[mcp_servers.mem0]
|
||||
url = "https://mcp.mem0.ai/mcp"
|
||||
bearer_token_env_var = "MEM0_API_KEY"
|
||||
```
|
||||
|
||||
Then in Codex, browse the repo's plugin directory and install Mem0.
|
||||
Make sure `MEM0_API_KEY` is exported in the shell you launch Codex from, then restart Codex.
|
||||
|
||||
### Option B — Personal Marketplace
|
||||
<Info>
|
||||
Codex's `codex mcp add` CLI only supports stdio MCP servers. Because Mem0's MCP is HTTP/streamable, you configure it by editing `config.toml` directly (or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app).
|
||||
</Info>
|
||||
|
||||
Add to `~/.agents/plugins/marketplace.json`:
|
||||
### Option B — Sideload the Plugin (Advanced)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "/path/to/mem0-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
For the full plugin experience — MCP server **plus** the Mem0 SDK skill, memory protocol skill, and opt-in lifecycle hooks — sideload the plugin from a local clone. The Mem0 repo already ships a marketplace manifest at [`.agents/plugins/marketplace.json`](https://github.com/mem0ai/mem0/blob/main/.agents/plugins/marketplace.json), so there's no JSON to author by hand. This follows the Codex [build-plugins](https://developers.openai.com/codex/plugins/build) local-testing workflow.
|
||||
|
||||
<Info>
|
||||
Don't combine Option B with Option A. The plugin manifest declares its MCP server via [`.codex-mcp.json`](https://github.com/mem0ai/mem0/blob/main/mem0-plugin/.codex-mcp.json), so Codex auto-registers the `mem0` MCP server when the plugin loads. Adding the same `[mcp_servers.mem0]` block to `~/.codex/config.toml` will create a duplicate registration.
|
||||
</Info>
|
||||
|
||||
**Step 1.** Clone the Mem0 repository anywhere on disk:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mem0ai/mem0.git ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
### Option C — Manual MCP Configuration
|
||||
**Step 2.** Register the bundled marketplace with Codex's CLI:
|
||||
|
||||
Add to your Codex MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
codex plugin marketplace add ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
This points Codex at the repo's `.agents/plugins/marketplace.json`. The bundled file uses `path: "./mem0-plugin"`, which Codex resolves relative to the clone root.
|
||||
|
||||
<Info>
|
||||
**Why we recommend this over hand-authoring `~/.agents/plugins/marketplace.json`:** Codex requires `source.path` in any marketplace manifest to be **relative** (starting with `./`) and **inside the marketplace root**. The repo's bundled manifest already satisfies this — the marketplace root is the clone directory, and `mem0-plugin/` lives inside it. With a personal `~/.agents/plugins/marketplace.json`, the root is `~/` and the clone has to live under `~/` too. The CLI form sidesteps that constraint.
|
||||
</Info>
|
||||
|
||||
**Step 3.** Restart Codex, run `/plugins`, browse the `Mem0 Plugins` marketplace, and install **Mem0**.
|
||||
|
||||
**Step 4 (optional) — enable lifecycle hooks.** Codex doesn't auto-wire hooks from plugin manifests; it only reads them from `~/.codex/hooks.json` (or `<repo>/.codex/hooks.json`). Run the bundled installer once to merge the Mem0 entries into your global hooks file:
|
||||
|
||||
```bash
|
||||
python3 ~/codex-plugins/mem0-source/mem0-plugin/scripts/install_codex_hooks.py
|
||||
```
|
||||
|
||||
Then enable the hooks feature flag in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
codex_hooks = true
|
||||
```
|
||||
|
||||
Restart Codex. The installer registers three hooks pointing at scripts inside your clone:
|
||||
|
||||
| Event | Behavior |
|
||||
|-------|----------|
|
||||
| `SessionStart` | Loads prior memories as bootstrap context |
|
||||
| `UserPromptSubmit` | Injects relevant memories before each prompt |
|
||||
| `Stop` | Reminds the agent to persist learnings at turn end |
|
||||
|
||||
Re-running the installer is idempotent. To remove the hooks: `python3 ~/codex-plugins/mem0-source/mem0-plugin/scripts/install_codex_hooks.py --uninstall`.
|
||||
|
||||
<Warning>
|
||||
The hooks file stores absolute paths into your clone (e.g. `~/codex-plugins/mem0-source/mem0-plugin/scripts/...`). If you move or delete the clone, the hooks will break silently — re-run the installer from the new location, or run `--uninstall` first.
|
||||
</Warning>
|
||||
|
||||
### Managing the Plugin
|
||||
|
||||
Codex provides CLI commands for managing marketplaces after install:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace upgrade # pull latest plugin versions
|
||||
codex plugin marketplace remove mem0-plugins # unregister the marketplace
|
||||
```
|
||||
|
||||
To pull updates to the plugin source itself, `git pull` inside your clone (`~/codex-plugins/mem0-source`) and then run `codex plugin marketplace upgrade` to refresh Codex's plugin cache. Plugins are cached at `~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/`.
|
||||
|
||||
<Info icon="check">
|
||||
Start a new Codex task and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set.
|
||||
After either option, start a new Codex task and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set.
|
||||
</Info>
|
||||
|
||||
## What's Included
|
||||
|
||||
| Component | Plugin Install | MCP Only |
|
||||
|-----------|:--------------:|:--------:|
|
||||
| Component | Sideloaded Plugin | Direct MCP |
|
||||
|-----------|:-----------------:|:----------:|
|
||||
| MCP Server (9 memory tools) | Yes | Yes |
|
||||
| Memory Protocol Skill | Yes | No |
|
||||
| Mem0 SDK Skill | Yes | No |
|
||||
| Lifecycle Hooks (opt-in) | Yes | No |
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
@@ -134,7 +143,7 @@ Once installed, the following tools are available in every Codex session:
|
||||
|
||||
## Memory Protocol Skill
|
||||
|
||||
Codex uses a skill-based approach instead of lifecycle hooks. When installed via the plugin marketplace, the memory protocol skill instructs the agent to:
|
||||
When the plugin is sideloaded, the memory protocol skill instructs the agent to:
|
||||
|
||||
### On Every New Task
|
||||
1. Call `search_memories` with a query related to the current task to load relevant context
|
||||
@@ -199,8 +208,12 @@ You: Add WebSocket support for real-time notification delivery.
|
||||
|
||||
- **"Connection failed"** — Verify `MEM0_API_KEY` is set in your shell: `echo $MEM0_API_KEY`
|
||||
- **No tools appearing** — Restart your Codex session after plugin installation
|
||||
- **Plugin not found** — Ensure `.agents/plugins/marketplace.json` is at the repository root and `source.path` points to the correct plugin directory
|
||||
- **Skills not loading** — Verify the `skills` field in `plugin.json` points to a valid directory containing `SKILL.md` files
|
||||
- **Duplicate `mem0` MCP server / "tool collision" errors** — You combined Option A (Direct MCP) with Option B (sideload). The sideloaded plugin auto-registers `mem0` from `.codex-mcp.json`, so remove the `[mcp_servers.mem0]` block from `~/.codex/config.toml`.
|
||||
- **`plugin/read failed in TUI`** — Codex can't find the plugin directory the marketplace points at. If you used `codex plugin marketplace add <path>`, confirm the path is your clone root and that `<clone>/.agents/plugins/marketplace.json` exists. If you hand-authored `~/.agents/plugins/marketplace.json`, `source.path` must be relative (start with `./`), inside the marketplace root (`~/` for personal installs), and end in `mem0-plugin` — e.g. `"./codex-plugins/mem0-source/mem0-plugin"`.
|
||||
- **Plugin not found in `/plugins`** — Run `codex plugin marketplace add ~/path/to/clone` again, or confirm the marketplace was registered with `codex plugin marketplace remove mem0-plugins` then re-add.
|
||||
- **Skills not loading** — Verify the `skills` field in `plugin.json` points to a valid directory containing `SKILL.md` files.
|
||||
- **Hooks not firing** — Confirm `codex_hooks = true` is in `~/.codex/config.toml` under `[features]`, and that `~/.codex/hooks.json` contains the Mem0 entries (re-run the installer if not). Restart Codex after enabling the flag.
|
||||
- **Hooks broke after moving the clone** — The installer bakes absolute paths into `~/.codex/hooks.json` pointing at scripts inside your clone. If you moved or renamed the clone directory, run `python3 <new-clone>/mem0-plugin/scripts/install_codex_hooks.py` from the new location — the installer is idempotent and replaces the old entries.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: OpenClaw
|
||||
description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with auto-recall and auto-capture support."
|
||||
description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with skills-based memory extraction and recall."
|
||||
---
|
||||
|
||||
Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents with the `@mem0/openclaw-mem0` plugin. Your agent forgets everything between sessions — this plugin fixes that by automatically watching conversations, extracting what matters, and bringing it back when relevant.
|
||||
@@ -12,11 +12,12 @@ Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents
|
||||
</Frame>
|
||||
|
||||
The plugin provides:
|
||||
1. **Auto-Recall** — Before the agent responds, memories matching the current message are injected into context
|
||||
2. **Auto-Capture** — After the agent responds, the exchange is sent to Mem0 which decides what's worth keeping
|
||||
3. **Agent Tools** — Eight tools for explicit memory operations during conversations
|
||||
1. **Triage** — The agent extracts durable facts from conversations using a structured protocol with importance gates and domain overlays
|
||||
2. **Recall** — Before each turn, relevant memories are retrieved with reranking and injected into context
|
||||
3. **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, prunes stale entries
|
||||
4. **Agent Tools** — Eight tools for explicit memory operations during conversations
|
||||
|
||||
Both auto-recall and auto-capture are opt-in (`autoRecall: true`, `autoCapture: true` in config). Once enabled, they run silently with no manual intervention required.
|
||||
Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -24,12 +25,12 @@ Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.15 (041266a)
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 2026.4.15` | Fully supported |
|
||||
| `>= 2026.4.25` | Fully supported |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -100,9 +101,9 @@ You no longer need manual config editing to get started. Everything happens insi
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
That's it. No API key, no config file editing, no environment variables. The plugin is now active and auto-capture and auto-recall are running on every turn.
|
||||
That's it. No API key, no config file editing, no environment variables. The plugin is now active with skills-based memory (triage, recall, and dream) running automatically.
|
||||
|
||||
<Note>The chat flow uses the same underlying config as manual setup — it writes `apiKey` and `userId` into `openclaw.json` for you. You can still open the file to inspect or override values afterward.</Note>
|
||||
<Note>The chat flow uses the same underlying config as manual setup — it writes `apiKey`, `userId`, and `skills` config into `openclaw.json` for you. You can still open the file to inspect or override values afterward.</Note>
|
||||
|
||||
#### Option 2: Manual Config
|
||||
|
||||
@@ -131,7 +132,19 @@ That's it. No API key, no config file editing, no environment variables. The plu
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
"userId": "alice", // any unique identifier you choose for this user
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,8 +341,8 @@ openclaw mem0 status --json
|
||||
|-----|------|---------|-------------|
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
|
||||
| `userId` | `string` | OS username | Scope memories per user |
|
||||
| `autoRecall` | `boolean` | `false` | Inject memories before each turn (opt-in) |
|
||||
| `autoCapture` | `boolean` | `false` | Store facts after each turn (opt-in) |
|
||||
| `autoRecall` | `boolean` | `true` | Inject memories before each turn. Ignored when `skills` is configured. |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts after each turn. Ignored when `skills` is configured. |
|
||||
| `topK` | `number` | `5` | Max memories per recall |
|
||||
| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
|
||||
|
||||
@@ -426,9 +439,11 @@ If `openclaw plugins update` fails:
|
||||
| **Platform** | Conversations sent to `api.mem0.ai` for extraction and storage | Mem0 cloud |
|
||||
| **Open-source** | Embeddings generated via configured provider (default: OpenAI API). Vectors stored locally. | `~/.mem0/vector_store.db` (SQLite) |
|
||||
|
||||
### Enabling Auto-Capture and Auto-Recall
|
||||
### Auto-Capture and Auto-Recall
|
||||
|
||||
Auto-capture and auto-recall are disabled by default (opt-in). To enable either or both:
|
||||
Auto-capture and auto-recall are **enabled by default**. When skills mode is configured (the default after `openclaw mem0 init`), these are ignored in favor of the skills-based triage/recall/dream protocol.
|
||||
|
||||
To disable either:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -436,8 +451,8 @@ Auto-capture and auto-recall are disabled by default (opt-in). To enable either
|
||||
"entries": {
|
||||
"openclaw-mem0": {
|
||||
"config": {
|
||||
"autoCapture": true, // send conversations to Mem0 for extraction
|
||||
"autoRecall": true // inject relevant memories into context
|
||||
"autoCapture": false, // disable automatic fact extraction
|
||||
"autoRecall": false // disable automatic memory injection
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,7 +460,7 @@ Auto-capture and auto-recall are disabled by default (opt-in). To enable either
|
||||
}
|
||||
```
|
||||
|
||||
Without these enabled, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly — only the automatic background behavior is off.
|
||||
The agent can always use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
|
||||
### Credential Protection
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ For detailed per-client instructions, see the [Mem0 MCP Quickstart](/platform/me
|
||||
|
||||
## Available tools
|
||||
|
||||
The MCP server exposes 9 memory tools to your AI client:
|
||||
The MCP server exposes 11 memory tools to your AI client:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
@@ -66,6 +66,8 @@ The MCP server exposes 9 memory tools to your AI client:
|
||||
| `delete_entities` | Remove user/agent/app entities |
|
||||
| `get_memory` | Retrieve single memory by ID |
|
||||
| `list_entities` | View stored entities |
|
||||
| `list_events` | List memory operation events with filters and pagination |
|
||||
| `get_event_status` | Check the status of an async memory operation by `event_id` |
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ estimatedTime: "~2 minutes"
|
||||
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Sign up here</a>)
|
||||
- API key (<a href="https://app.mem0.ai/settings/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Get one from dashboard</a>)
|
||||
- Node.js 14+ (for npx)
|
||||
- An MCP-compatible client (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode)
|
||||
- An MCP-compatible client (Claude, Claude Code, Codex, Cursor, Windsurf, VS Code, OpenCode)
|
||||
</Info>
|
||||
|
||||
## What is Mem0 MCP?
|
||||
@@ -46,6 +46,8 @@ The MCP server exposes these memory tools to your AI client:
|
||||
| `delete_all_memories` | Bulk delete all memories in scope |
|
||||
| `delete_entities` | Delete a user/agent/app/run entity and its memories |
|
||||
| `list_entities` | Enumerate users/agents/apps/runs stored in Mem0 |
|
||||
| `list_events` | List memory operation events with filters and pagination |
|
||||
| `get_event_status` | Check the status of an async memory operation by `event_id` |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +88,33 @@ You can also configure individual clients:
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Codex">
|
||||
**Direct MCP (fastest, MCP only).** Codex reads MCP servers from `~/.codex/config.toml` as TOML (not JSON). Add:
|
||||
|
||||
```toml
|
||||
[mcp_servers.mem0]
|
||||
url = "https://mcp.mem0.ai/mcp"
|
||||
bearer_token_env_var = "MEM0_API_KEY"
|
||||
```
|
||||
|
||||
Export `MEM0_API_KEY` in the shell you launch Codex from, then restart Codex. `codex mcp add` only supports stdio servers, so HTTP servers must be added via `config.toml` directly — or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app.
|
||||
|
||||
<Note>
|
||||
Codex uses the server name `mem0` (not `mem0-mcp` like the other clients on this page) so it matches the name the bundled plugin registers if you ever sideload it later.
|
||||
</Note>
|
||||
|
||||
**Sideloaded plugin (full experience).** If you want the memory protocol skill, Mem0 SDK skill, and opt-in lifecycle hooks alongside the MCP server, sideload the plugin from a clone of `mem0ai/mem0`. The repo ships a marketplace manifest at `.agents/plugins/marketplace.json`, so you can register it with one CLI call:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mem0ai/mem0.git ~/codex-plugins/mem0-source
|
||||
codex plugin marketplace add ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
Then run `codex` and `/plugins`, browse the **Mem0 Plugins** marketplace, and install **Mem0**. Don't combine this with the Direct MCP setup above — the sideloaded plugin auto-registers `mem0` via `.codex-mcp.json`, so a manual `[mcp_servers.mem0]` block would create a duplicate.
|
||||
|
||||
See the [Codex integration guide](/integrations/codex) for full details, lifecycle-hook setup, and management commands (`codex plugin marketplace upgrade` / `remove`).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Cursor">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"url": "https://mcp.mem0.ai/mcp",
|
||||
"bearer_token_env_var": "MEM0_API_KEY"
|
||||
}
|
||||
}
|
||||
|
||||
+37
-64
@@ -49,72 +49,38 @@ This installs the full plugin including the MCP server, lifecycle hooks (automat
|
||||
|
||||
### Codex
|
||||
|
||||
**Option A — Repo marketplace** (recommended for teams):
|
||||
**Option A — Direct MCP** (fastest, MCP only):
|
||||
|
||||
Add the plugin marketplace to your repo root (already included in this repository):
|
||||
Codex reads MCP servers from `~/.codex/config.toml` as TOML. Add:
|
||||
|
||||
```
|
||||
.agents/plugins/marketplace.json
|
||||
```toml
|
||||
[mcp_servers.mem0]
|
||||
url = "https://mcp.mem0.ai/mcp"
|
||||
bearer_token_env_var = "MEM0_API_KEY"
|
||||
```
|
||||
|
||||
Then in Codex, browse the repo's plugin directory and install Mem0.
|
||||
Export `MEM0_API_KEY` in your shell and restart Codex. `codex mcp add` only supports stdio servers, so HTTP servers like Mem0's must be added via `config.toml` directly (or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app).
|
||||
|
||||
**Option B — Personal marketplace**:
|
||||
**Option B — Sideload the plugin** (full experience: MCP + skills + opt-in hooks):
|
||||
|
||||
Add to `~/.agents/plugins/marketplace.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "/path/to/mem0/mem0-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Option C — Manual MCP configuration**:
|
||||
|
||||
Add to your Codex MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Options A, B, and C above install the MCP server and the Mem0 SDK skill.
|
||||
|
||||
**Optional — enable lifecycle hooks**
|
||||
|
||||
Codex only discovers hooks at `~/.codex/hooks.json` or `<repo>/.codex/hooks.json` ([docs](https://developers.openai.com/codex/hooks)) — there is no plugin-host mechanism that auto-wires hooks from an installed plugin. To opt in, run the installer once after installing the plugin:
|
||||
Clone the repo and register the bundled marketplace with one CLI call:
|
||||
|
||||
```bash
|
||||
python3 /path/to/mem0-plugin/scripts/install_codex_hooks.py
|
||||
git clone https://github.com/mem0ai/mem0.git ~/codex-plugins/mem0-source
|
||||
codex plugin marketplace add ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
This merges three entries into `~/.codex/hooks.json` with absolute paths pointing into the plugin directory:
|
||||
This points Codex at the repo's `.agents/plugins/marketplace.json`, which references `mem0-plugin/` as the local source. Restart Codex, run `/plugins`, and install **Mem0** from the **Mem0 Plugins** marketplace.
|
||||
|
||||
> **Don't combine with Option A.** The plugin manifest auto-registers `mem0` as an MCP server via `mem0-plugin/.codex-mcp.json` — adding a manual `[mcp_servers.mem0]` block would duplicate the registration.
|
||||
|
||||
**Optional — enable lifecycle hooks.** Codex doesn't auto-wire hooks from plugin manifests; it only reads `~/.codex/hooks.json` (or `<repo>/.codex/hooks.json`) ([docs](https://developers.openai.com/codex/hooks)). Run the bundled installer once to merge Mem0's entries:
|
||||
|
||||
```bash
|
||||
python3 ~/codex-plugins/mem0-source/mem0-plugin/scripts/install_codex_hooks.py
|
||||
```
|
||||
|
||||
This merges three entries into `~/.codex/hooks.json` with absolute paths pointing into your clone:
|
||||
|
||||
| Event | What it does |
|
||||
|-------|--------------|
|
||||
@@ -122,7 +88,7 @@ This merges three entries into `~/.codex/hooks.json` with absolute paths pointin
|
||||
| `UserPromptSubmit` | Injects relevant memories into the prompt |
|
||||
| `Stop` | Reminds the agent to persist learnings at turn end |
|
||||
|
||||
Re-running the installer is idempotent (replaces the Mem0 entries rather than duplicating) and preserves any other hooks you have. To remove: `python3 scripts/install_codex_hooks.py --uninstall`.
|
||||
Re-running the installer is idempotent (replaces the Mem0 entries rather than duplicating) and preserves any other hooks you have. To remove: `python3 .../install_codex_hooks.py --uninstall`. If you move or delete the clone directory, re-run the installer from the new location — the hooks file stores absolute paths.
|
||||
|
||||
Codex hooks also require the `codex_hooks` feature flag in `~/.codex/config.toml`:
|
||||
|
||||
@@ -133,6 +99,13 @@ codex_hooks = true
|
||||
|
||||
The installer prints a reminder if the flag isn't set. Restart Codex after editing the config.
|
||||
|
||||
**Managing the plugin:**
|
||||
|
||||
```bash
|
||||
codex plugin marketplace upgrade # pull latest plugin versions
|
||||
codex plugin marketplace remove mem0-plugins # unregister the marketplace
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
> **Already have `mem0` configured as an MCP server?** Remove the existing entry from your Cursor MCP settings before installing to avoid duplicate tools.
|
||||
@@ -172,17 +145,17 @@ After installing, confirm the MCP server is connected:
|
||||
|
||||
## What's included
|
||||
|
||||
| Component | Claude Code / Cowork | Cursor (Marketplace) | Cursor (Deeplink/Manual) | Codex |
|
||||
|-----------|:--------------------:|:--------------------:|:------------------------:|:-----:|
|
||||
| MCP Server | Yes | Yes | Yes | Yes |
|
||||
| Lifecycle Hooks | Yes | Yes | No | Opt-in |
|
||||
| Mem0 SDK Skill | Yes | Yes | No | Yes |
|
||||
| Memory Protocol Skill | No | No | No | Yes |
|
||||
| Component | Claude Code / Cowork | Cursor (Marketplace) | Cursor (Deeplink/Manual) | Codex (Sideload) | Codex (Direct MCP) |
|
||||
|-----------|:--------------------:|:--------------------:|:------------------------:|:----------------:|:------------------:|
|
||||
| MCP Server | Yes | Yes | Yes | Yes | Yes |
|
||||
| Lifecycle Hooks | Yes | Yes | No | Opt-in | No |
|
||||
| Mem0 SDK Skill | Yes | Yes | No | Yes | No |
|
||||
| Memory Protocol Skill | No | No | No | Yes | No |
|
||||
|
||||
- **MCP Server** — Connects to the Mem0 remote MCP server (`mcp.mem0.ai`), providing tools to add, search, update, and delete memories. No local dependencies required.
|
||||
- **Lifecycle Hooks** — Automatic memory capture at key points. Claude Code and Cursor wire hooks up natively when the plugin is installed (session start, context compaction, task completion, session end). Codex hooks are opt-in via a one-time installer (`scripts/install_codex_hooks.py`) that writes entries into `~/.codex/hooks.json` for `SessionStart`, `UserPromptSubmit`, and `Stop`.
|
||||
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
|
||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Replaces lifecycle hooks on platforms that don't support them.
|
||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import type { Client as ClientType } from "pg";
|
||||
import pkg from "pg";
|
||||
const { Client } = pkg;
|
||||
const { Client, escapeIdentifier } = pkg;
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
|
||||
const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,127}$/;
|
||||
|
||||
function validateIdentifier(
|
||||
name: string,
|
||||
label: string = "identifier",
|
||||
): string {
|
||||
if (!SAFE_IDENTIFIER_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid ${label} '${name}': only letters, digits, and underscores are allowed, ` +
|
||||
`must start with a letter or underscore, and be at most 128 characters.`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function escapeFilterKey(key: string): string {
|
||||
if (!SAFE_IDENTIFIER_RE.test(key)) {
|
||||
throw new Error(
|
||||
`Invalid filter key '${key}': only letters, digits, and underscores are allowed.`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
interface PGVectorConfig extends VectorStoreConfig {
|
||||
dbname?: string;
|
||||
user: string;
|
||||
@@ -25,10 +49,13 @@ export class PGVector implements VectorStore {
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: PGVectorConfig) {
|
||||
this.collectionName = config.collectionName || "memories";
|
||||
this.collectionName = validateIdentifier(
|
||||
config.collectionName || "memories",
|
||||
"collectionName",
|
||||
);
|
||||
this.useDiskann = config.diskann || false;
|
||||
this.useHnsw = config.hnsw || false;
|
||||
this.dbName = config.dbname || "vector_store";
|
||||
this.dbName = validateIdentifier(config.dbname || "vector_store", "dbname");
|
||||
this.config = config;
|
||||
|
||||
this.client = new Client({
|
||||
@@ -41,6 +68,10 @@ export class PGVector implements VectorStore {
|
||||
this.initialize().catch(console.error);
|
||||
}
|
||||
|
||||
private col(): string {
|
||||
return escapeIdentifier(this.collectionName);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
@@ -102,31 +133,28 @@ export class PGVector implements VectorStore {
|
||||
}
|
||||
|
||||
private async createDatabase(dbName: string): Promise<void> {
|
||||
// Create database (cannot be parameterized)
|
||||
await this.client.query(`CREATE DATABASE ${dbName}`);
|
||||
await this.client.query(`CREATE DATABASE ${escapeIdentifier(dbName)}`);
|
||||
}
|
||||
|
||||
private async createCol(embeddingModelDims: number): Promise<void> {
|
||||
// Create the table
|
||||
const dims = Math.floor(embeddingModelDims);
|
||||
await this.client.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.collectionName} (
|
||||
CREATE TABLE IF NOT EXISTS ${this.col()} (
|
||||
id UUID PRIMARY KEY,
|
||||
vector vector(${embeddingModelDims}),
|
||||
vector vector(${dims}),
|
||||
payload JSONB
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes based on configuration
|
||||
if (this.useDiskann && embeddingModelDims < 2000) {
|
||||
try {
|
||||
// Check if vectorscale extension is available
|
||||
const result = await this.client.query(
|
||||
"SELECT * FROM pg_extension WHERE extname = 'vectorscale'",
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
await this.client.query(`
|
||||
CREATE INDEX IF NOT EXISTS ${this.collectionName}_diskann_idx
|
||||
ON ${this.collectionName}
|
||||
CREATE INDEX IF NOT EXISTS ${escapeIdentifier(this.collectionName + "_diskann_idx")}
|
||||
ON ${this.col()}
|
||||
USING diskann (vector);
|
||||
`);
|
||||
}
|
||||
@@ -136,8 +164,8 @@ export class PGVector implements VectorStore {
|
||||
} else if (this.useHnsw) {
|
||||
try {
|
||||
await this.client.query(`
|
||||
CREATE INDEX IF NOT EXISTS ${this.collectionName}_hnsw_idx
|
||||
ON ${this.collectionName}
|
||||
CREATE INDEX IF NOT EXISTS ${escapeIdentifier(this.collectionName + "_hnsw_idx")}
|
||||
ON ${this.col()}
|
||||
USING hnsw (vector vector_cosine_ops);
|
||||
`);
|
||||
} catch (error) {
|
||||
@@ -153,16 +181,15 @@ export class PGVector implements VectorStore {
|
||||
): Promise<void> {
|
||||
const values = vectors.map((vector, i) => ({
|
||||
id: ids[i],
|
||||
vector: `[${vector.join(",")}]`, // Format vector as string with square brackets
|
||||
vector: `[${vector.join(",")}]`,
|
||||
payload: payloads[i],
|
||||
}));
|
||||
|
||||
const query = `
|
||||
INSERT INTO ${this.collectionName} (id, vector, payload)
|
||||
INSERT INTO ${this.col()} (id, vector, payload)
|
||||
VALUES ($1, $2::vector, $3::jsonb)
|
||||
`;
|
||||
|
||||
// Execute inserts in parallel using Promise.all
|
||||
await Promise.all(
|
||||
values.map((value) =>
|
||||
this.client.query(query, [value.id, value.vector, value.payload]),
|
||||
@@ -182,7 +209,8 @@ export class PGVector implements VectorStore {
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${filterIndex}`);
|
||||
filterValues.push(value);
|
||||
filterIndex++;
|
||||
}
|
||||
@@ -195,7 +223,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const searchQuery = `
|
||||
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'textLemmatized'), plainto_tsquery('simple', $1)) AS score, payload
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
WHERE to_tsvector('simple', payload->>'textLemmatized') @@ plainto_tsquery('simple', $1)
|
||||
${filterClause}
|
||||
ORDER BY score DESC
|
||||
@@ -221,13 +249,14 @@ export class PGVector implements VectorStore {
|
||||
filters?: SearchFilters,
|
||||
): Promise<VectorStoreResult[]> {
|
||||
const filterConditions: string[] = [];
|
||||
const queryVector = `[${query.join(",")}]`; // Format query vector as string with square brackets
|
||||
const queryVector = `[${query.join(",")}]`;
|
||||
const filterValues: any[] = [queryVector, topK];
|
||||
let filterIndex = 3;
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${filterIndex}`);
|
||||
filterValues.push(value);
|
||||
filterIndex++;
|
||||
}
|
||||
@@ -240,7 +269,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const searchQuery = `
|
||||
SELECT id, vector <=> $1::vector AS distance, payload
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
${filterClause}
|
||||
ORDER BY distance
|
||||
LIMIT $2
|
||||
@@ -251,13 +280,13 @@ export class PGVector implements VectorStore {
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
payload: row.payload,
|
||||
score: row.distance,
|
||||
score: Math.max(0, Math.min(1, 1 - Number(row.distance))),
|
||||
}));
|
||||
}
|
||||
|
||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||
const result = await this.client.query(
|
||||
`SELECT id, payload FROM ${this.collectionName} WHERE id = $1`,
|
||||
`SELECT id, payload FROM ${this.col()} WHERE id = $1`,
|
||||
[vectorId],
|
||||
);
|
||||
|
||||
@@ -274,10 +303,10 @@ export class PGVector implements VectorStore {
|
||||
vector: number[],
|
||||
payload: Record<string, any>,
|
||||
): Promise<void> {
|
||||
const vectorStr = `[${vector.join(",")}]`; // Format vector as string with square brackets
|
||||
const vectorStr = `[${vector.join(",")}]`;
|
||||
await this.client.query(
|
||||
`
|
||||
UPDATE ${this.collectionName}
|
||||
UPDATE ${this.col()}
|
||||
SET vector = $1::vector, payload = $2::jsonb
|
||||
WHERE id = $3
|
||||
`,
|
||||
@@ -286,14 +315,13 @@ export class PGVector implements VectorStore {
|
||||
}
|
||||
|
||||
async delete(vectorId: string): Promise<void> {
|
||||
await this.client.query(
|
||||
`DELETE FROM ${this.collectionName} WHERE id = $1`,
|
||||
[vectorId],
|
||||
);
|
||||
await this.client.query(`DELETE FROM ${this.col()} WHERE id = $1`, [
|
||||
vectorId,
|
||||
]);
|
||||
}
|
||||
|
||||
async deleteCol(): Promise<void> {
|
||||
await this.client.query(`DROP TABLE IF EXISTS ${this.collectionName}`);
|
||||
await this.client.query(`DROP TABLE IF EXISTS ${this.col()}`);
|
||||
}
|
||||
|
||||
private async listCols(): Promise<string[]> {
|
||||
@@ -315,7 +343,8 @@ export class PGVector implements VectorStore {
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
filterConditions.push(`payload->>'${key}' = $${paramIndex}`);
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${paramIndex}`);
|
||||
filterValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
@@ -328,14 +357,14 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const listQuery = `
|
||||
SELECT id, payload
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
${filterClause}
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
${filterClause}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/// <reference types="jest" />
|
||||
|
||||
const searchRows = [
|
||||
{
|
||||
id: "a",
|
||||
payload: { data: "exactly x-axis" },
|
||||
distance: "0",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
payload: { data: "close to x-axis" },
|
||||
distance: "0.006116251198662548",
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
payload: { data: "y-axis" },
|
||||
distance: "1",
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
payload: { data: "opposite x-axis" },
|
||||
distance: "2",
|
||||
},
|
||||
];
|
||||
|
||||
function mockPgQuery(sql: string) {
|
||||
if (sql.includes("SELECT 1 FROM pg_database")) {
|
||||
return { rows: [{ "?column?": 1 }] };
|
||||
}
|
||||
|
||||
if (sql.includes("FROM information_schema.tables")) {
|
||||
return { rows: [{ table_name: "memories" }] };
|
||||
}
|
||||
|
||||
if (sql.includes("vector <=> $1::vector AS distance")) {
|
||||
return { rows: searchRows };
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
jest.mock("pg", () => {
|
||||
const clients: any[] = [];
|
||||
|
||||
const Client = jest.fn().mockImplementation((config: any) => {
|
||||
const client = {
|
||||
config,
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
end: jest.fn().mockResolvedValue(undefined),
|
||||
query: jest
|
||||
.fn()
|
||||
.mockImplementation(async (sql: string) => mockPgQuery(sql)),
|
||||
};
|
||||
|
||||
clients.push(client);
|
||||
return client;
|
||||
});
|
||||
|
||||
const escapeIdentifier = (str: string) => `"${str.replace(/"/g, '""')}"`;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { Client, escapeIdentifier },
|
||||
Client,
|
||||
escapeIdentifier,
|
||||
__mock: { Client, clients },
|
||||
};
|
||||
});
|
||||
|
||||
import { PGVector } from "../src/vector_stores/pgvector";
|
||||
|
||||
describe("PGVector - search()", () => {
|
||||
beforeEach(() => {
|
||||
const pg = require("pg");
|
||||
pg.__mock.Client.mockClear();
|
||||
pg.__mock.clients.length = 0;
|
||||
});
|
||||
|
||||
test("returns similarity score (1 - distance) clamped to [0, 1]", async () => {
|
||||
const store = new PGVector({
|
||||
collectionName: "memories",
|
||||
user: "postgres",
|
||||
password: "postgres",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
embeddingModelDims: 3,
|
||||
dimension: 3,
|
||||
} as any);
|
||||
|
||||
await store.initialize();
|
||||
|
||||
const results = await store.search([1, 0, 0], 4);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
id: "a",
|
||||
payload: { data: "exactly x-axis" },
|
||||
score: 1,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
payload: { data: "close to x-axis" },
|
||||
score: 0.9938837488013375,
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
payload: { data: "y-axis" },
|
||||
score: 0,
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
payload: { data: "opposite x-axis" },
|
||||
score: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const pg = require("pg");
|
||||
expect(pg.__mock.Client).toHaveBeenCalledTimes(2);
|
||||
|
||||
const activeClient = pg.__mock.clients[1];
|
||||
expect(activeClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("vector <=> $1::vector AS distance"),
|
||||
["[1,0,0]", 4],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -58,24 +58,35 @@ class LLMReranker(BaseReranker):
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
def _get_default_prompt(self) -> str:
|
||||
"""Get the default scoring prompt template."""
|
||||
return """You are a relevance scoring assistant. Given a query and a document, you need to score how relevant the document is to the query.
|
||||
# Honor custom scoring_prompt from config if provided
|
||||
custom_prompt = getattr(self.config, 'scoring_prompt', None)
|
||||
if custom_prompt:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"LLMRerankerConfig.scoring_prompt is deprecated and will be removed in a future version. "
|
||||
"The prompt is now used as the system message.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._system_prompt = custom_prompt
|
||||
else:
|
||||
self._system_prompt = self._SYSTEM_PROMPT
|
||||
|
||||
Score the relevance on a scale from 0.0 to 1.0, where:
|
||||
- 1.0 = Perfectly relevant and directly answers the query
|
||||
- 0.8-0.9 = Highly relevant with good information
|
||||
- 0.6-0.7 = Moderately relevant with some useful information
|
||||
- 0.4-0.5 = Slightly relevant with limited useful information
|
||||
- 0.0-0.3 = Not relevant or no useful information
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a relevance scoring assistant. "
|
||||
"Given a query and a document, score how relevant the document is to the query.\n\n"
|
||||
"Score the relevance on a scale from 0.0 to 1.0, where:\n"
|
||||
"- 1.0 = Perfectly relevant and directly answers the query\n"
|
||||
"- 0.8-0.9 = Highly relevant with good information\n"
|
||||
"- 0.6-0.7 = Moderately relevant with some useful information\n"
|
||||
"- 0.4-0.5 = Slightly relevant with limited useful information\n"
|
||||
"- 0.0-0.3 = Not relevant or no useful information\n\n"
|
||||
"Respond with only a single numerical score between 0.0 and 1.0. "
|
||||
"Do not include any explanation or additional text."
|
||||
)
|
||||
|
||||
Query: "{query}"
|
||||
Document: "{document}"
|
||||
|
||||
Provide only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text."""
|
||||
# Maximum character length for query and document inputs to prevent prompt flooding.
|
||||
_MAX_INPUT_LEN = 4000
|
||||
|
||||
def _extract_score(self, response_text: str) -> float:
|
||||
"""Extract numerical score from LLM response."""
|
||||
@@ -119,12 +130,17 @@ Provide only a single numerical score between 0.0 and 1.0. Do not include any ex
|
||||
doc_text = str(doc)
|
||||
|
||||
try:
|
||||
# Generate scoring prompt
|
||||
prompt = self.scoring_prompt.format(query=query, document=doc_text)
|
||||
|
||||
# Get LLM response
|
||||
# Truncate inputs to prevent prompt flooding, then send as separate
|
||||
# system/user messages so instructions cannot be overridden by user data.
|
||||
safe_query = query[: self._MAX_INPUT_LEN]
|
||||
safe_doc = doc_text[: self._MAX_INPUT_LEN]
|
||||
user_message = f"Query: {safe_query}\n\nDocument: {safe_doc}"
|
||||
|
||||
response = self.llm.generate_response(
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
messages=[
|
||||
{"role": "system", "content": self._system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
)
|
||||
|
||||
# Extract score from response
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -25,6 +26,17 @@ from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$')
|
||||
|
||||
|
||||
def _validate_identifier(name: str, label: str = "identifier") -> str:
|
||||
if not _SAFE_IDENTIFIER_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid {label} '{name}': only letters, digits, and underscores are allowed, "
|
||||
"must start with a letter or underscore, and be at most 128 characters."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
@@ -72,7 +84,7 @@ class AzureMySQL(VectorStoreBase):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.database = database
|
||||
self.collection_name = collection_name
|
||||
self.collection_name = _validate_identifier(collection_name, "collection_name")
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.use_azure_credential = use_azure_credential
|
||||
self.ssl_ca = ssl_ca
|
||||
@@ -174,7 +186,7 @@ class AzureMySQL(VectorStoreBase):
|
||||
vector_size (int, optional): Vector dimension (uses self.embedding_model_dims if not provided)
|
||||
distance (str): Distance metric (cosine, euclidean, dot_product)
|
||||
"""
|
||||
table_name = name or self.collection_name
|
||||
table_name = _validate_identifier(name, "table_name") if name else self.collection_name
|
||||
dims = vector_size or self.embedding_model_dims
|
||||
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -19,6 +20,17 @@ from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$')
|
||||
|
||||
|
||||
def _validate_identifier(name: str, label: str = "identifier") -> str:
|
||||
if not _SAFE_IDENTIFIER_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid {label} '{name}': only letters, digits, and underscores are allowed, "
|
||||
"must start with a letter or underscore, and be at most 128 characters."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
@@ -59,8 +71,8 @@ class CassandraDB(VectorStoreBase):
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.keyspace = keyspace
|
||||
self.collection_name = collection_name
|
||||
self.keyspace = _validate_identifier(keyspace, "keyspace")
|
||||
self.collection_name = _validate_identifier(collection_name, "collection_name")
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.secure_connect_bundle = secure_connect_bundle
|
||||
self.protocol_version = protocol_version
|
||||
@@ -156,7 +168,7 @@ class CassandraDB(VectorStoreBase):
|
||||
vector_size (int, optional): Vector dimension (uses self.embedding_model_dims if not provided)
|
||||
distance (str): Distance metric (cosine, euclidean, dot_product)
|
||||
"""
|
||||
table_name = name or self.collection_name
|
||||
table_name = _validate_identifier(name, "table_name") if name else self.collection_name
|
||||
dims = vector_size or self.embedding_model_dims
|
||||
|
||||
try:
|
||||
@@ -375,12 +387,10 @@ class CassandraDB(VectorStoreBase):
|
||||
List[str]: List of collection names
|
||||
"""
|
||||
try:
|
||||
query = f"""
|
||||
SELECT table_name
|
||||
FROM system_schema.tables
|
||||
WHERE keyspace_name = '{self.keyspace}'
|
||||
"""
|
||||
rows = self.session.execute(query)
|
||||
prepared = self.session.prepare(
|
||||
"SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?"
|
||||
)
|
||||
rows = self.session.execute(prepared, (self.keyspace,))
|
||||
return [row.table_name for row in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list collections: {e}")
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel
|
||||
|
||||
# Try to import psycopg (psycopg3) first, then fall back to psycopg2
|
||||
try:
|
||||
from psycopg import sql
|
||||
from psycopg.types.json import Json
|
||||
from psycopg_pool import ConnectionPool
|
||||
PSYCOPG_VERSION = 3
|
||||
@@ -14,6 +15,7 @@ try:
|
||||
logger.info("Using psycopg (psycopg3) with ConnectionPool for PostgreSQL connections")
|
||||
except ImportError:
|
||||
try:
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extras import Json, execute_values
|
||||
from psycopg2.pool import ThreadedConnectionPool as ConnectionPool
|
||||
PSYCOPG_VERSION = 2
|
||||
@@ -144,6 +146,10 @@ class PGVector(VectorStoreBase):
|
||||
cur.close()
|
||||
self.connection_pool.putconn(conn)
|
||||
|
||||
def _col(self) -> "sql.Identifier":
|
||||
"""Return a safely-quoted SQL identifier for the collection table."""
|
||||
return sql.Identifier(self.collection_name)
|
||||
|
||||
def create_col(self) -> None:
|
||||
"""
|
||||
Create a new collection (table in PostgreSQL).
|
||||
@@ -152,39 +158,45 @@ class PGVector(VectorStoreBase):
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.collection_name} (
|
||||
sql.SQL("""
|
||||
CREATE TABLE IF NOT EXISTS {} (
|
||||
id UUID PRIMARY KEY,
|
||||
vector vector({self.embedding_model_dims}),
|
||||
vector vector({}),
|
||||
payload JSONB
|
||||
);
|
||||
"""
|
||||
""").format(self._col(), sql.Literal(self.embedding_model_dims))
|
||||
)
|
||||
if self.use_diskann and self.embedding_model_dims < 2000:
|
||||
cur.execute("SELECT * FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
if cur.fetchone():
|
||||
# Create DiskANN index if extension is installed for faster search
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_diskann_idx
|
||||
ON {self.collection_name}
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
USING diskann (vector);
|
||||
"""
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_diskann_idx"),
|
||||
self._col(),
|
||||
)
|
||||
)
|
||||
elif self.use_hnsw:
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_hnsw_idx
|
||||
ON {self.collection_name}
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
USING hnsw (vector vector_cosine_ops)
|
||||
"""
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_hnsw_idx"),
|
||||
self._col(),
|
||||
)
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_text_lemmatized_idx
|
||||
ON {self.collection_name}
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
USING gin(to_tsvector('simple', payload->>'text_lemmatized'));
|
||||
"""
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_text_lemmatized_idx"),
|
||||
self._col(),
|
||||
)
|
||||
)
|
||||
|
||||
def insert(self, vectors: list[list[float]], payloads=None, ids=None) -> None:
|
||||
@@ -195,14 +207,14 @@ class PGVector(VectorStoreBase):
|
||||
if PSYCOPG_VERSION == 3:
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.executemany(
|
||||
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES (%s, %s, %s)",
|
||||
sql.SQL("INSERT INTO {} (id, vector, payload) VALUES (%s, %s, %s)").format(self._col()),
|
||||
data,
|
||||
)
|
||||
else:
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
execute_values(
|
||||
cur,
|
||||
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES %s",
|
||||
sql.SQL("INSERT INTO {} (id, vector, payload) VALUES %s").format(self._col()),
|
||||
data,
|
||||
)
|
||||
|
||||
@@ -233,17 +245,17 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = "WHERE " + " AND ".join(filter_conditions) if filter_conditions else ""
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
sql.SQL("""
|
||||
SELECT id, vector <=> %s::vector AS distance, payload
|
||||
FROM {self.collection_name}
|
||||
{filter_clause}
|
||||
FROM {}
|
||||
{}
|
||||
ORDER BY distance
|
||||
LIMIT %s
|
||||
""",
|
||||
""").format(self._col(), filter_clause),
|
||||
(vectors, *filter_params, top_k),
|
||||
)
|
||||
|
||||
@@ -270,21 +282,19 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = ""
|
||||
if filter_conditions:
|
||||
filter_clause = "AND " + " AND ".join(filter_conditions)
|
||||
filter_clause = sql.SQL("AND " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
try:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
sql.SQL("""
|
||||
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', %s)) AS score, payload
|
||||
FROM {self.collection_name}
|
||||
FROM {}
|
||||
WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', %s)
|
||||
{filter_clause}
|
||||
{}
|
||||
ORDER BY score DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
""").format(self._col(), filter_clause),
|
||||
(query, query, *filter_params, top_k),
|
||||
)
|
||||
|
||||
@@ -302,7 +312,7 @@ class PGVector(VectorStoreBase):
|
||||
vector_id (str): ID of the vector to delete.
|
||||
"""
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(f"DELETE FROM {self.collection_name} WHERE id = %s", (vector_id,))
|
||||
cur.execute(sql.SQL("DELETE FROM {} WHERE id = %s").format(self._col()), (vector_id,))
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -321,7 +331,7 @@ class PGVector(VectorStoreBase):
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
if vector:
|
||||
cur.execute(
|
||||
f"UPDATE {self.collection_name} SET vector = %s WHERE id = %s",
|
||||
sql.SQL("UPDATE {} SET vector = %s WHERE id = %s").format(self._col()),
|
||||
(vector, vector_id),
|
||||
)
|
||||
if payload:
|
||||
@@ -329,13 +339,13 @@ class PGVector(VectorStoreBase):
|
||||
if PSYCOPG_VERSION == 3:
|
||||
# psycopg3 uses psycopg.types.json.Json
|
||||
cur.execute(
|
||||
f"UPDATE {self.collection_name} SET payload = %s WHERE id = %s",
|
||||
sql.SQL("UPDATE {} SET payload = %s WHERE id = %s").format(self._col()),
|
||||
(Json(payload), vector_id),
|
||||
)
|
||||
else:
|
||||
# psycopg2 uses psycopg2.extras.Json
|
||||
cur.execute(
|
||||
f"UPDATE {self.collection_name} SET payload = %s WHERE id = %s",
|
||||
sql.SQL("UPDATE {} SET payload = %s WHERE id = %s").format(self._col()),
|
||||
(Json(payload), vector_id),
|
||||
)
|
||||
|
||||
@@ -352,7 +362,7 @@ class PGVector(VectorStoreBase):
|
||||
"""
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT id, vector, payload FROM {self.collection_name} WHERE id = %s",
|
||||
sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()),
|
||||
(vector_id,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
@@ -374,7 +384,7 @@ class PGVector(VectorStoreBase):
|
||||
def delete_col(self) -> None:
|
||||
"""Delete a collection."""
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {self.collection_name}")
|
||||
cur.execute(sql.SQL("DROP TABLE IF EXISTS {}").format(self._col()))
|
||||
|
||||
def col_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -385,14 +395,14 @@ class PGVector(VectorStoreBase):
|
||||
"""
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
sql.SQL("""
|
||||
SELECT
|
||||
table_name,
|
||||
(SELECT COUNT(*) FROM {self.collection_name}) as row_count,
|
||||
(SELECT pg_size_pretty(pg_total_relation_size('{self.collection_name}'))) as total_size
|
||||
(SELECT COUNT(*) FROM {}) as row_count,
|
||||
(SELECT pg_size_pretty(pg_total_relation_size({}::regclass))) as total_size
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
""").format(self._col(), sql.Literal(self.collection_name)),
|
||||
(self.collection_name,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
@@ -421,17 +431,18 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = "WHERE " + " AND ".join(filter_conditions) if filter_conditions else ""
|
||||
|
||||
query = f"""
|
||||
SELECT id, vector, payload
|
||||
FROM {self.collection_name}
|
||||
{filter_clause}
|
||||
LIMIT %s
|
||||
"""
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(query, (*filter_params, top_k))
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
SELECT id, vector, payload
|
||||
FROM {}
|
||||
{}
|
||||
LIMIT %s
|
||||
""").format(self._col(), filter_clause),
|
||||
(*filter_params, top_k),
|
||||
)
|
||||
results = cur.fetchall()
|
||||
return [[OutputData(id=str(r[0]), score=None, payload=r[2]) for r in results]]
|
||||
|
||||
|
||||
+59
-15
@@ -2,7 +2,9 @@
|
||||
|
||||
Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, powered by [Mem0](https://mem0.ai).
|
||||
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant. Enable `autoRecall` and `autoCapture` in config to run this automatically, or use agent tools for explicit control.
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant.
|
||||
|
||||
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -10,12 +12,12 @@ Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.15 (041266a)
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 2026.4.15` | Fully supported |
|
||||
| `>= 2026.4.25` | Fully supported |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -50,7 +52,19 @@ openclaw --version
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice"
|
||||
"userId": "alice",
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,11 +196,24 @@ All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
**Auto-Recall** (`autoRecall: true`) — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
### Skills Mode (Default)
|
||||
|
||||
**Auto-Capture** (`autoCapture: true`) — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
Enabled automatically during `openclaw mem0 init`. The agent controls memory through three skills:
|
||||
|
||||
Both are opt-in. Once enabled, they run silently — no prompting, no manual calls required. Without them, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly.
|
||||
- **Triage** — Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
|
||||
- **Recall** — Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
|
||||
- **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, and prunes stale entries.
|
||||
|
||||
When skills mode is active, the skills handle memory operations. `autoRecall` and `autoCapture` remain `true` by default alongside skills mode. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
### Auto-Recall & Auto-Capture
|
||||
|
||||
When skills mode is not configured, the plugin uses `autoRecall` and `autoCapture` (both enabled by default):
|
||||
|
||||
- **Auto-Recall** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
- **Auto-Capture** — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Set `autoRecall: false` or `autoCapture: false` to disable individually. The agent can also use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
|
||||
### Memory Scopes
|
||||
|
||||
@@ -260,10 +287,25 @@ openclaw mem0 help --json # discover all comma
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
|
||||
| `userId` | `string` | OS username | User identifier. All memories scoped to this value. |
|
||||
| `autoRecall` | `boolean` | `false` | Inject relevant memories before each turn |
|
||||
| `autoCapture` | `boolean` | `false` | Extract and store facts after each turn |
|
||||
| `autoRecall` | `boolean` | `true` | Inject relevant memories before each turn. Ignored when `skills` is set. |
|
||||
| `autoCapture` | `boolean` | `true` | Extract and store facts after each turn. Ignored when `skills` is set. |
|
||||
| `topK` | `number` | `5` | Max memories returned per recall |
|
||||
| `searchThreshold` | `number` | `0.3` | Minimum similarity score (0-1) |
|
||||
| `searchThreshold` | `number` | `0.1` | Minimum similarity score (0-1) |
|
||||
|
||||
### Skills Mode (Recommended)
|
||||
|
||||
Enabled by default during `openclaw mem0 init`. `autoRecall` and `autoCapture` are also `true` by default and work alongside skills mode.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `skills.triage.enabled` | `boolean` | `true` | Enable fact extraction from conversations |
|
||||
| `skills.recall.enabled` | `boolean` | `true` | Enable memory recall before each turn |
|
||||
| `skills.recall.tokenBudget` | `number` | `1500` | Max tokens for injected memories |
|
||||
| `skills.recall.rerank` | `boolean` | `true` | Rerank search results for relevance |
|
||||
| `skills.recall.keywordSearch` | `boolean` | `true` | Augment with keyword-based search |
|
||||
| `skills.recall.identityAlwaysInclude` | `boolean` | `true` | Always include identity memories |
|
||||
| `skills.dream.enabled` | `boolean` | `true` | Enable periodic memory consolidation |
|
||||
| `skills.domain` | `string` | `"companion"` | Domain overlay for triage rules |
|
||||
|
||||
### Platform Mode
|
||||
|
||||
@@ -306,13 +348,15 @@ To avoid plaintext credentials:
|
||||
- Use env var references: `"apiKey": "${MEM0_API_KEY}"`
|
||||
- Use SecretRef: `"apiKey": {"source": "env", "provider": "default", "id": "MEM0_API_KEY"}`
|
||||
|
||||
### Auto-Capture & Auto-Recall
|
||||
### Memory Processing
|
||||
|
||||
Both are **disabled by default** (`false`). When enabled:
|
||||
- `autoCapture`: sends conversation content to your configured backend (cloud or local) after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into agent context
|
||||
In **skills mode** (default after `openclaw mem0 init`), the agent uses structured protocols (triage, recall, dream) to decide what to store and recall. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
Do not enable `autoCapture` in platform mode if your conversations contain sensitive data you do not want stored on Mem0 cloud.
|
||||
Without skills, `autoCapture` and `autoRecall` are both enabled by default:
|
||||
- `autoCapture`: sends conversation content to your configured backend after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into context
|
||||
|
||||
In platform mode, conversation content is sent to `api.mem0.ai` for processing. Do not use with sensitive data you do not want stored on Mem0 cloud.
|
||||
|
||||
### Persistence Locations
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
writePluginConfigField,
|
||||
enableSkillsConfig,
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
} from "./config-file.ts";
|
||||
import { jsonOut, jsonErr, redactSecrets } from "./json-helpers.ts";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
LLM_PROVIDERS, EMBEDDER_PROVIDERS, VECTOR_PROVIDERS,
|
||||
buildOssLlmConfig, buildOssEmbedderConfig, buildOssVectorConfig,
|
||||
validateOssFlags, checkQdrantConnectivity, checkOllamaConnectivity, checkPgConnectivity,
|
||||
collectionNameForDims,
|
||||
} from "./oss-wizard.ts";
|
||||
|
||||
// ============================================================================
|
||||
@@ -213,10 +215,11 @@ function saveLoginConfig(
|
||||
const userId = resolveUserId(userIdFlag, existingAuth.userId);
|
||||
|
||||
writePluginAuth({ apiKey, userId, mode: "platform", ...(userEmail && { userEmail }) });
|
||||
enableSkillsConfig(userId);
|
||||
|
||||
if (!silent) {
|
||||
console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`);
|
||||
console.log(` Mode: platform`);
|
||||
console.log(` Mode: platform (skills enabled)`);
|
||||
console.log(` User ID: ${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -226,10 +229,11 @@ function saveOssConfig(userIdFlag?: string, silent?: boolean): void {
|
||||
const userId = resolveUserId(userIdFlag, existingAuth.userId);
|
||||
|
||||
writePluginAuth({ apiKey: "", userId, mode: "open-source" });
|
||||
enableSkillsConfig(userId);
|
||||
|
||||
if (!silent) {
|
||||
console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`);
|
||||
console.log(` Mode: open-source`);
|
||||
console.log(` Mode: open-source (skills enabled)`);
|
||||
console.log(` User ID: ${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -350,6 +354,17 @@ async function runOssWizardInteractive(
|
||||
}
|
||||
|
||||
const vecCfg = buildOssVectorConfig(vecDef.id, vecInput as any);
|
||||
|
||||
// Warn if switching embedder dimensions — old collection will have wrong vector size
|
||||
const existingVecCfg = existingAuth as any;
|
||||
const oldDims = existingVecCfg?.oss?.vectorStore?.config?.dimension as number | undefined;
|
||||
if (oldDims && dims && oldDims !== dims) {
|
||||
console.log(`\n ⚠ Dimension change detected: ${oldDims} → ${dims}`);
|
||||
console.log(` Old collection had ${oldDims}-dim vectors. New embedder produces ${dims}-dim vectors.`);
|
||||
console.log(` A new collection "${collectionNameForDims(dims)}" will be created.`);
|
||||
console.log(` Old memories in the previous collection will NOT be accessible with the new embedder.\n`);
|
||||
}
|
||||
|
||||
writePluginConfigField(["oss", "vectorStore"], vecCfg);
|
||||
|
||||
// === Step 4: User ID ===
|
||||
@@ -370,6 +385,8 @@ async function runOssWizardInteractive(
|
||||
console.log(` LLM: ${llmDef.id} (${llmCfg.config.model})`);
|
||||
console.log(` Embedder: ${embDef.id} (${embCfg.config.model})`);
|
||||
console.log(` Vector: ${vecDef.id} (${vecDef.id === "qdrant" ? vecCfg.config.url : vecCfg.config.host})`);
|
||||
console.log(` Dims: ${dims ?? "unknown"}`);
|
||||
console.log(` Collection:${dims ? " " + collectionNameForDims(dims) : " (default)"}`);
|
||||
console.log(` User ID: ${userIdValue}`);
|
||||
console.log("");
|
||||
console.log(" Run: openclaw gateway restart");
|
||||
@@ -539,6 +556,15 @@ export function registerCliCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on dimension change
|
||||
const prevAuth = readPluginAuth() as any;
|
||||
const prevDims = prevAuth?.oss?.vectorStore?.config?.dimension as number | undefined;
|
||||
const newDims = dims;
|
||||
let dimWarning: string | undefined;
|
||||
if (prevDims && newDims && prevDims !== newDims) {
|
||||
dimWarning = `Dimension change: ${prevDims} → ${newDims}. New collection "${collectionNameForDims(newDims)}" will be used. Old memories not accessible with new embedder.`;
|
||||
}
|
||||
|
||||
writePluginConfigField(["oss", "llm"], llmCfg);
|
||||
writePluginConfigField(["oss", "embedder"], { provider: embCfg.provider, config: embCfg.config });
|
||||
writePluginConfigField(["oss", "vectorStore"], vecCfg);
|
||||
@@ -550,9 +576,10 @@ export function registerCliCommands(
|
||||
mode: "open-source",
|
||||
config: {
|
||||
llm: { provider: llmCfg.provider, model: llmCfg.config.model },
|
||||
embedder: { provider: embCfg.provider, model: embCfg.config.model },
|
||||
vectorStore: { provider: vecCfg.provider, ...(vecId === "qdrant" ? { url: vecCfg.config.url } : { host: vecCfg.config.host }) },
|
||||
embedder: { provider: embCfg.provider, model: embCfg.config.model, dims: newDims },
|
||||
vectorStore: { provider: vecCfg.provider, ...(vecId === "qdrant" ? { url: vecCfg.config.url } : { host: vecCfg.config.host }), collectionName: newDims ? collectionNameForDims(newDims) : undefined },
|
||||
},
|
||||
...(dimWarning && { warning: dimWarning }),
|
||||
userId: resolveUserId(opts.userId, existingAuth.userId),
|
||||
message: "Open-source mode configured. Restart the gateway: openclaw gateway restart",
|
||||
};
|
||||
@@ -889,7 +916,7 @@ export function registerCliCommands(
|
||||
runId?: string,
|
||||
): SearchOptions => {
|
||||
const base = buildSearchOptions(userIdOverride, lim, runId);
|
||||
base.threshold = 0.3;
|
||||
base.threshold = 0.1;
|
||||
return base;
|
||||
};
|
||||
|
||||
|
||||
+78
-78
@@ -19,7 +19,6 @@ export const OPENCLAW_CONFIG_FILE = join(OPENCLAW_CONFIG_DIR, "openclaw.json");
|
||||
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
|
||||
|
||||
const PLUGIN_ID = "openclaw-mem0";
|
||||
const NPM_PACKAGE = "@mem0/openclaw-mem0";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -76,11 +75,44 @@ function readFullConfig(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full ~/.openclaw/openclaw.json (preserves all non-plugin config) */
|
||||
/**
|
||||
* Write the full ~/.openclaw/openclaw.json.
|
||||
*
|
||||
* Re-reads the file immediately before writing and deep-merges the
|
||||
* `plugins` section so that fields written by other processes (e.g.
|
||||
* OpenClaw gateway adding `installs`, `slots`) are not clobbered.
|
||||
*/
|
||||
function writeFullConfig(config: Record<string, unknown>): void {
|
||||
if (!exists(OPENCLAW_CONFIG_DIR)) {
|
||||
mkdirp(OPENCLAW_CONFIG_DIR, 0o700);
|
||||
}
|
||||
|
||||
if (exists(OPENCLAW_CONFIG_FILE)) {
|
||||
try {
|
||||
const diskText = readText(OPENCLAW_CONFIG_FILE);
|
||||
if (diskText.trim()) {
|
||||
const disk = JSON.parse(diskText) as Record<string, unknown>;
|
||||
const diskPlugins = disk.plugins as Record<string, unknown> | undefined;
|
||||
const ourPlugins = config.plugins as Record<string, unknown> | undefined;
|
||||
if (diskPlugins && ourPlugins) {
|
||||
const OPENCLAW_MANAGED = ["installs", "slots"];
|
||||
for (const key of OPENCLAW_MANAGED) {
|
||||
if (key in diskPlugins) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(diskPlugins)) {
|
||||
if (!(key in ourPlugins)) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// disk unreadable — write our version as-is
|
||||
}
|
||||
}
|
||||
|
||||
writeText(
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
JSON.stringify(config, null, 2),
|
||||
@@ -122,82 +154,6 @@ export function writePluginAuth(auth: PluginAuthConfig): void {
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the plugin has a valid install record and is in plugins.allow.
|
||||
*
|
||||
* OpenClaw's `plugins update` command requires a `plugins.installs.<id>`
|
||||
* record with `source: "npm"` and `spec` to know how to update. Without
|
||||
* this, `openclaw plugins update` prints "No install record" and skips.
|
||||
*
|
||||
* Similarly, if `plugins.allow` exists as an array, the plugin ID must
|
||||
* be in it or OpenClaw treats the plugin as untrusted.
|
||||
*
|
||||
* This is safe to call multiple times — it only writes missing fields.
|
||||
*/
|
||||
export function ensureInstallRecord(): void {
|
||||
try {
|
||||
const full = readFullConfig() as any;
|
||||
|
||||
const entry = full?.plugins?.entries?.[PLUGIN_ID];
|
||||
const record = full?.plugins?.installs?.[PLUGIN_ID];
|
||||
const allow = full?.plugins?.allow;
|
||||
const specPinned = record?.spec && /\d+\.\d+\.\d+/.test(record.spec);
|
||||
if (
|
||||
entry?.enabled === true &&
|
||||
record?.source &&
|
||||
record?.spec &&
|
||||
!specPinned &&
|
||||
Array.isArray(allow) &&
|
||||
allow.includes(PLUGIN_ID)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensurePluginStructure(full);
|
||||
|
||||
let changed = false;
|
||||
|
||||
// Ensure install record exists for `openclaw plugins update` support
|
||||
if (!full.plugins.installs) full.plugins.installs = {};
|
||||
if (!full.plugins.installs[PLUGIN_ID]) {
|
||||
full.plugins.installs[PLUGIN_ID] = {
|
||||
source: "npm",
|
||||
spec: `${NPM_PACKAGE}@latest`,
|
||||
resolvedName: NPM_PACKAGE,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
changed = true;
|
||||
} else {
|
||||
const record = full.plugins.installs[PLUGIN_ID];
|
||||
if (!record.source) {
|
||||
record.source = "npm";
|
||||
changed = true;
|
||||
}
|
||||
if (!record.spec || /\d+\.\d+\.\d+/.test(record.spec)) {
|
||||
record.spec = record.source === "clawhub"
|
||||
? `clawhub:${NPM_PACKAGE}`
|
||||
: `${NPM_PACKAGE}@latest`;
|
||||
changed = true;
|
||||
}
|
||||
if (!record.resolvedName) {
|
||||
record.resolvedName = NPM_PACKAGE;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(full.plugins.allow)) {
|
||||
full.plugins.allow = [PLUGIN_ID];
|
||||
changed = true;
|
||||
} else if (!full.plugins.allow.includes(PLUGIN_ID)) {
|
||||
full.plugins.allow.push(PLUGIN_ID);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) writeFullConfig(full);
|
||||
} catch {
|
||||
// Best-effort — don't break plugin loading if config is unreadable
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the nested plugin entry structure exists in the config object. */
|
||||
function ensurePluginStructure(full: any): void {
|
||||
@@ -231,6 +187,50 @@ export function writePluginConfigField(
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default skills configuration — matches configure.py output.
|
||||
* Enables triage, recall (with reranking), and dream consolidation.
|
||||
*/
|
||||
const DEFAULT_SKILLS_CONFIG = {
|
||||
triage: { enabled: true },
|
||||
recall: {
|
||||
enabled: true,
|
||||
tokenBudget: 1500,
|
||||
rerank: true,
|
||||
keywordSearch: true,
|
||||
identityAlwaysInclude: true,
|
||||
},
|
||||
dream: { enabled: true },
|
||||
domain: "companion",
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable skills-mode config after onboarding.
|
||||
*
|
||||
* Sets skills config on the plugin entry, tools.profile = "full",
|
||||
* and disables the built-in session-memory hook to avoid conflicts.
|
||||
* Preserves any existing skills config if already set.
|
||||
*/
|
||||
export function enableSkillsConfig(userId: string): void {
|
||||
const full = readFullConfig() as any;
|
||||
ensurePluginStructure(full);
|
||||
|
||||
const cfg = full.plugins.entries[PLUGIN_ID].config;
|
||||
if (!cfg.skills) {
|
||||
cfg.skills = { ...DEFAULT_SKILLS_CONFIG };
|
||||
}
|
||||
|
||||
if (!full.tools) full.tools = {};
|
||||
full.tools.profile = "full";
|
||||
|
||||
if (!full.hooks) full.hooks = {};
|
||||
if (!full.hooks.internal) full.hooks.internal = {};
|
||||
if (!full.hooks.internal.entries) full.hooks.internal.entries = {};
|
||||
full.hooks.internal.entries["session-memory"] = { enabled: false };
|
||||
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/** Get the configured base URL from openclaw.json or default */
|
||||
export function getBaseUrl(): string {
|
||||
const auth = readPluginAuth();
|
||||
|
||||
@@ -56,8 +56,15 @@ export const KNOWN_EMBEDDER_DIMS: Record<string, number> = {
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"nomic-embed-text": 768,
|
||||
"mxbai-embed-large": 1024,
|
||||
"all-minilm": 384,
|
||||
"snowflake-arctic-embed": 1024,
|
||||
};
|
||||
|
||||
export function collectionNameForDims(dims: number): string {
|
||||
return `mem0_${dims}d`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config builders
|
||||
// ============================================================================
|
||||
@@ -105,7 +112,8 @@ export function buildOssEmbedderConfig(
|
||||
config.url = input.url || def.defaultUrl;
|
||||
}
|
||||
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? undefined;
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? def.defaultDims;
|
||||
if (dims) config.embeddingDims = dims;
|
||||
return { provider: providerId, config, dims };
|
||||
}
|
||||
|
||||
@@ -138,7 +146,11 @@ export function buildOssVectorConfig(
|
||||
config.dbname = input.dbname || "postgres";
|
||||
}
|
||||
|
||||
if (input.dims) config.dimension = input.dims;
|
||||
if (input.dims) {
|
||||
config.dimension = input.dims;
|
||||
config.embeddingModelDims = input.dims;
|
||||
config.collectionName = collectionNameForDims(input.dims);
|
||||
}
|
||||
return { provider: providerId, config };
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -231,8 +231,8 @@ export const mem0ConfigSchema = {
|
||||
return "default";
|
||||
}
|
||||
})(),
|
||||
autoCapture: cfg.autoCapture === true,
|
||||
autoRecall: cfg.autoRecall === true,
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
// v3.0.0: customPrompt renamed to customInstructions (backwards-compat: accept either)
|
||||
customInstructions:
|
||||
typeof cfg.customInstructions === "string"
|
||||
@@ -247,7 +247,7 @@ export const mem0ConfigSchema = {
|
||||
? (cfg.customCategories as Record<string, string>)
|
||||
: DEFAULT_CUSTOM_CATEGORIES,
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.1,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
needsSetup,
|
||||
oss: ossConfig,
|
||||
|
||||
@@ -561,3 +561,52 @@ What is the deployment plan?`,
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-recall threshold filtering
|
||||
// The recall hook in index.ts filters search results using cfg.searchThreshold.
|
||||
// These tests verify the threshold is honored and no hardcoded floor overrides it.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("auto-recall threshold respects cfg.searchThreshold", () => {
|
||||
const typicalV3Results = [
|
||||
{ id: "1", score: 0.553, memory: "User prefers dark mode" },
|
||||
{ id: "2", score: 0.496, memory: "User works on mem0 project" },
|
||||
{ id: "3", score: 0.471, memory: "User likes TypeScript" },
|
||||
{ id: "4", score: 0.45, memory: "User's timezone is PST" },
|
||||
{ id: "5", score: 0.42, memory: "User uses VS Code" },
|
||||
{ id: "6", score: 0.35, memory: "User mentioned family trip" },
|
||||
];
|
||||
|
||||
function applyThresholdFilter(
|
||||
results: typeof typicalV3Results,
|
||||
searchThreshold: number,
|
||||
) {
|
||||
return results.filter((r) => (r.score ?? 0) >= searchThreshold);
|
||||
}
|
||||
|
||||
it("default 0.5 threshold returns results scoring >= 0.5", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.5);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("threshold 0.4 returns results scoring >= 0.4", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.4);
|
||||
expect(filtered).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("threshold 0.3 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.3);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("threshold 0.6 correctly filters everything below", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.6);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("threshold 0 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
+55
-11
@@ -54,15 +54,12 @@ import {
|
||||
import { PlatformBackend } from "./backend/platform.ts";
|
||||
import type { Backend } from "./backend/base.ts";
|
||||
import { registerCliCommands } from "./cli/commands.ts";
|
||||
import { readPluginAuth, ensureInstallRecord } from "./cli/config-file.ts";
|
||||
import { readPluginAuth } from "./cli/config-file.ts";
|
||||
import { registerAllTools } from "./tools/index.ts";
|
||||
import type { ToolDeps } from "./tools/index.ts";
|
||||
import { captureEvent } from "./telemetry.ts";
|
||||
import { bootstrapTelemetryFlag } from "./fs-safe.ts";
|
||||
|
||||
bootstrapTelemetryFlag();
|
||||
ensureInstallRecord();
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports (for tests and external consumers)
|
||||
// ============================================================================
|
||||
@@ -100,6 +97,8 @@ const memoryPlugin = definePluginEntry({
|
||||
description: "Mem0 memory backend — Mem0 platform or self-hosted open-source",
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
bootstrapTelemetryFlag();
|
||||
|
||||
// Read auth from openclaw.json plugin config (picks up post-startup login).
|
||||
// This is the single source of truth — set via `openclaw mem0 login`.
|
||||
const pluginAuth = readPluginAuth();
|
||||
@@ -207,8 +206,57 @@ const memoryPlugin = definePluginEntry({
|
||||
},
|
||||
effectiveUserId: _effectiveUserId,
|
||||
}),
|
||||
runtime: {
|
||||
async getMemorySearchManager(_params: any) {
|
||||
try {
|
||||
const userId = _effectiveUserId();
|
||||
let memoryCount = 0;
|
||||
try {
|
||||
const memories = await provider.getAll({
|
||||
user_id: userId,
|
||||
page_size: 1,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
memoryCount = Array.isArray(memories) ? memories.length : 0;
|
||||
} catch {
|
||||
// Non-fatal: status still works without count
|
||||
}
|
||||
return {
|
||||
manager: {
|
||||
status() {
|
||||
return {
|
||||
backend: cfg.mode,
|
||||
files: 0,
|
||||
chunks: memoryCount,
|
||||
dirty: false,
|
||||
workspaceDir: pluginStateDir ?? "",
|
||||
userId,
|
||||
};
|
||||
},
|
||||
async probeEmbeddingAvailability() {
|
||||
return { ok: true };
|
||||
},
|
||||
async close() {},
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
manager: null,
|
||||
error: `mem0 ${cfg.mode} backend unavailable: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
resolveMemoryBackendConfig(_params: any) {
|
||||
return {
|
||||
backend: cfg.mode,
|
||||
baseUrl: cfg.baseUrl ?? "https://api.mem0.ai",
|
||||
userId: cfg.userId,
|
||||
};
|
||||
},
|
||||
async closeAllMemorySearchManagers() {},
|
||||
},
|
||||
});
|
||||
api.logger.debug("openclaw-mem0: publicArtifacts capability registered");
|
||||
api.logger.debug("openclaw-mem0: memory capability + runtime registered");
|
||||
}
|
||||
|
||||
// Helper: build add options
|
||||
@@ -681,12 +729,8 @@ function registerHooks(
|
||||
),
|
||||
);
|
||||
|
||||
// Client-side threshold filter for auto-recall — use a stricter
|
||||
// threshold (0.6) than explicit tool searches (0.5) to avoid
|
||||
// injecting irrelevant memories into agent context
|
||||
const recallThreshold = Math.max(cfg.searchThreshold, 0.6);
|
||||
longTermResults = longTermResults.filter(
|
||||
(r) => (r.score ?? 0) >= recallThreshold,
|
||||
(r) => (r.score ?? 0) >= cfg.searchThreshold,
|
||||
);
|
||||
|
||||
// Dynamic thresholding: drop memories scoring less than 50% of
|
||||
@@ -709,7 +753,7 @@ function registerHooks(
|
||||
undefined,
|
||||
recallSessionKey,
|
||||
);
|
||||
broadOpts.threshold = 0.5;
|
||||
broadOpts.threshold = cfg.searchThreshold;
|
||||
const broadResults = await provider.search(
|
||||
"recent decisions, preferences, active projects, and configuration",
|
||||
broadOpts,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "openclaw-mem0",
|
||||
"name": "Memory (Mem0)",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"commandAliases": [
|
||||
@@ -17,9 +17,17 @@
|
||||
"memory_update", "memory_delete", "memory_event_list", "memory_event_status"
|
||||
]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"mem0": ["MEM0_API_KEY"],
|
||||
"openclaw-mem0-oss": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "mem0",
|
||||
"envVars": ["MEM0_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "openclaw-mem0-oss",
|
||||
"envVars": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
@@ -171,13 +179,13 @@
|
||||
},
|
||||
"autoCapture": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Opt-in. When true, extracts durable facts after each agent turn. Disabled by default."
|
||||
"default": true,
|
||||
"description": "When true, extracts durable facts after each agent turn. Enabled by default. Ignored in skills mode."
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Opt-in. When true, injects relevant memories before each agent turn. Disabled by default."
|
||||
"default": true,
|
||||
"description": "When true, injects relevant memories before each agent turn. Enabled by default. Ignored in skills mode."
|
||||
},
|
||||
"customInstructions": {
|
||||
"type": "string"
|
||||
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
@@ -35,19 +35,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"mem0ai": "3.0.1"
|
||||
"mem0ai": "3.0.2"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.3.28",
|
||||
"minGatewayVersion": ">=2026.3.28"
|
||||
"pluginApi": ">=2026.4.24",
|
||||
"minGatewayVersion": ">=2026.4.24"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.1",
|
||||
"pluginSdkVersion": "2026.4.1"
|
||||
"openclawVersion": "2026.4.24",
|
||||
"pluginSdkVersion": "2026.4.24"
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@mem0/openclaw-mem0"
|
||||
@@ -59,5 +59,10 @@
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"protobufjs@<7.5.5": "^7.5.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+25
-22
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
protobufjs@<7.5.5: ^7.5.5
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -12,8 +15,8 @@ importers:
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
mem0ai:
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0)
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.15.0
|
||||
@@ -363,8 +366,8 @@ packages:
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.4':
|
||||
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0':
|
||||
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||
@@ -375,8 +378,8 @@ packages:
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.0':
|
||||
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
|
||||
'@protobufjs/inquire@1.1.1':
|
||||
resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
@@ -384,8 +387,8 @@ packages:
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
|
||||
'@qdrant/js-client-rest@1.13.0':
|
||||
resolution: {integrity: sha512-bewMtnXlGvhhnfXsp0sLoLXOGvnrCM15z9lNlG0Snp021OedNAnRtKkerjk5vkOcbQWUmJHXYCuxDfcT93aSkA==}
|
||||
@@ -1505,8 +1508,8 @@ packages:
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
mem0ai@3.0.1:
|
||||
resolution: {integrity: sha512-6phM544/3NRcCg7n5DBNRc9uUEMqGTe7sksULM2KM/FTihH27yTl5nPms8cnNpSKM5/ZKJ9jnkpzwGlvYYbtBQ==}
|
||||
mem0ai@3.0.2:
|
||||
resolution: {integrity: sha512-smB9q27jrJu2D5WZje65+zMVptdS/WsqALxd2kjiZhEpThd/qkS8T3bumatcTu6Zb+LsT28FRpeWjXGTJjyQXQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@anthropic-ai/sdk': ^0.40.1
|
||||
@@ -1845,8 +1848,8 @@ packages:
|
||||
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
protobufjs@7.5.6:
|
||||
resolution: {integrity: sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
@@ -2536,7 +2539,7 @@ snapshots:
|
||||
dependencies:
|
||||
google-auth-library: 10.6.1
|
||||
p-retry: 4.6.2
|
||||
protobufjs: 7.5.4
|
||||
protobufjs: 7.5.6
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -2634,24 +2637,24 @@ snapshots:
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.4': {}
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
'@protobufjs/inquire': 1.1.1
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.0': {}
|
||||
'@protobufjs/inquire@1.1.1': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
'@protobufjs/utf8@1.1.1': {}
|
||||
|
||||
'@qdrant/js-client-rest@1.13.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
@@ -3687,7 +3690,7 @@ snapshots:
|
||||
crypt: 0.0.2
|
||||
is-buffer: 1.1.6
|
||||
|
||||
mem0ai@3.0.1(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0):
|
||||
mem0ai@3.0.2(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0):
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.40.1
|
||||
'@azure/identity': 4.13.0
|
||||
@@ -4020,18 +4023,18 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 18.3.1
|
||||
|
||||
protobufjs@7.5.4:
|
||||
protobufjs@7.5.6:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.4
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.0
|
||||
'@protobufjs/fetch': 1.1.0
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
'@protobufjs/inquire': 1.1.1
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.1
|
||||
'@types/node': 22.19.15
|
||||
long: 5.3.2
|
||||
|
||||
|
||||
+21
-5
@@ -280,13 +280,29 @@ class OSSProvider implements Mem0Provider {
|
||||
config.llm = defaultLlm;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.vectorStore)
|
||||
config.vectorStore = { ...this.ossConfig.vectorStore };
|
||||
if (this.ossConfig?.vectorStore) {
|
||||
const vs = { ...this.ossConfig.vectorStore } as Record<string, unknown>;
|
||||
const vsCfg = (vs.config ?? {}) as Record<string, unknown>;
|
||||
// Resolve dims from embedder config if vector store doesn't have them
|
||||
const embedderDims = (config.embedder as any)?.config?.embeddingDims;
|
||||
if (!vsCfg.dimension && embedderDims) {
|
||||
vsCfg.dimension = embedderDims;
|
||||
}
|
||||
// Sync both dimension fields — Qdrant reads dimension, PGVector reads embeddingModelDims
|
||||
if (vsCfg.dimension && !vsCfg.embeddingModelDims) {
|
||||
vsCfg.embeddingModelDims = vsCfg.dimension;
|
||||
} else if (vsCfg.embeddingModelDims && !vsCfg.dimension) {
|
||||
vsCfg.dimension = vsCfg.embeddingModelDims;
|
||||
}
|
||||
vs.config = vsCfg;
|
||||
config.vectorStore = vs;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.historyDbPath) {
|
||||
const dbPath = this.resolvePath
|
||||
? this.resolvePath(this.ossConfig.historyDbPath)
|
||||
: this.ossConfig.historyDbPath;
|
||||
const raw = this.ossConfig.historyDbPath;
|
||||
const isAbsolute = raw.startsWith("/") || /^[A-Za-z]:[/\\]/.test(raw);
|
||||
const dbPath =
|
||||
isAbsolute || !this.resolvePath ? raw : this.resolvePath(raw);
|
||||
config.historyDbPath = dbPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,49 @@ metadata:
|
||||
|
||||
You are performing a memory consolidation pass. Your goal is to review all stored memories for this user and improve their overall quality. Think of this as compressing raw observations into clean, durable knowledge.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### memory_search
|
||||
Semantic search across stored memories.
|
||||
- `query` (required): search query
|
||||
- `limit`: max results
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
- `categories`: filter by category array
|
||||
|
||||
### memory_add
|
||||
Store new facts in long-term memory.
|
||||
- `facts` (required): array of facts — ALL must share the same category
|
||||
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
|
||||
- `importance`: 0.0–1.0
|
||||
|
||||
### memory_get
|
||||
Retrieve a single memory by ID.
|
||||
- `memoryId` (required): the memory ID
|
||||
|
||||
### memory_list
|
||||
List all stored memories for a user or agent.
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
|
||||
### memory_update
|
||||
Update an existing memory's text in place. Atomic and preserves edit history.
|
||||
- `memoryId` (required): the memory ID to update
|
||||
- `text` (required): the new text (replaces old)
|
||||
|
||||
### memory_delete
|
||||
Delete memories by ID, query, or bulk.
|
||||
- `memoryId`: specific memory ID to delete
|
||||
- `all`: delete ALL memories (requires `confirm: true`)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
|
||||
### memory_event_list
|
||||
List recent background processing events (platform mode only).
|
||||
|
||||
### memory_event_status
|
||||
Get status of a specific background event.
|
||||
- `event_id` (required): the event ID to check
|
||||
|
||||
Follow these four phases in order. Do not skip phases.
|
||||
|
||||
## Phase 1: Orient
|
||||
|
||||
@@ -18,6 +18,56 @@ Your primary role is to extract relevant pieces of information from the conversa
|
||||
|
||||
**The core question**: "Would a new agent — with no prior context — benefit from knowing this?" If no → do nothing. Most turns produce zero memory operations. That is correct and expected.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### memory_search
|
||||
Semantic search across stored memories.
|
||||
- `query` (required): search query
|
||||
- `limit`: max results (default: configured topK)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
- `categories`: filter by category array
|
||||
- `filters`: advanced filter object
|
||||
|
||||
### memory_add
|
||||
Store new facts in long-term memory.
|
||||
- `facts` (required): array of facts to store — ALL must share the same category
|
||||
- `text`: alternative single-fact string
|
||||
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
|
||||
- `importance`: 0.0–1.0 (omit for category default)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `metadata`: additional key-value metadata
|
||||
- `longTerm`: true (default) for persistent, false for session-scoped
|
||||
|
||||
### memory_get
|
||||
Retrieve a single memory by ID.
|
||||
- `memoryId` (required): the memory ID
|
||||
|
||||
### memory_list
|
||||
List all stored memories for a user or agent.
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
|
||||
### memory_update
|
||||
Update an existing memory's text in place. Atomic and preserves edit history.
|
||||
- `memoryId` (required): the memory ID to update
|
||||
- `text` (required): the new text (replaces old)
|
||||
|
||||
### memory_delete
|
||||
Delete memories by ID, query, or bulk.
|
||||
- `memoryId`: specific memory ID to delete
|
||||
- `query`: search query to find and delete matching memories
|
||||
- `all`: delete ALL memories (requires `confirm: true`)
|
||||
- `confirm`: safety gate for bulk operations
|
||||
- `userId`, `agentId`: scope overrides
|
||||
|
||||
### memory_event_list
|
||||
List recent background processing events (platform mode only).
|
||||
|
||||
### memory_event_status
|
||||
Get status of a specific background event.
|
||||
- `event_id` (required): the event ID to check
|
||||
|
||||
## Decision Gate
|
||||
|
||||
Every candidate fact must pass ALL four gates:
|
||||
@@ -28,7 +78,7 @@ Every candidate fact must pass ALL four gates:
|
||||
|
||||
**Gate 2 — NOVELTY**: Check your recalled memories below — is this already known?
|
||||
- Already known and unchanged → SKIP
|
||||
- Known but materially changed → UPDATE (find old → forget → store new)
|
||||
- Known but materially changed → UPDATE (find old → update in place)
|
||||
- Genuinely new → proceed
|
||||
- **Material difference test**: Only UPDATE if new information adds real context, details, or changes meaning. Cosmetic differences (synonyms, rephrasing, punctuation) are NOT updates. "Loves daily walks" vs "enjoys daily walks" = no material change = SKIP.
|
||||
|
||||
@@ -196,8 +246,9 @@ Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `tech
|
||||
|
||||
When a recalled memory needs updating (fact changed, status changed, new detail added):
|
||||
1. `memory_search` to find the existing memory
|
||||
2. `memory_delete` on the old memory's ID
|
||||
3. `memory_add` with the corrected/expanded fact
|
||||
2. `memory_update` on the memory's ID with the corrected/expanded text
|
||||
|
||||
`memory_update` is preferred over delete+add because it is **atomic and preserves edit history**.
|
||||
|
||||
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
|
||||
|
||||
@@ -206,10 +257,10 @@ When a recalled memory needs updating (fact changed, status changed, new detail
|
||||
- "User likes Python" → "User enjoys Python" = NOT material = SKIP
|
||||
- When both have unique context, combine: Old "Trip to Paris in September with Jack" + New "User can't wait to visit Eiffel Tower" → "Trip to Paris in September 2025 with friend Jack, user says they can't wait to visit the Eiffel Tower and try authentic French pastries"
|
||||
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, update one to the comprehensive version and forget the others.
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, `memory_update` the best one to the comprehensive version and `memory_delete` the rest.
|
||||
- Old: "User has a dog" + "Dog's name is Poppy" + "User walks dog daily"
|
||||
- New: "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- Action: forget all three old memories, store one consolidated memory
|
||||
- Action: `memory_update` the best version with consolidated text, `memory_delete` the redundant ones
|
||||
|
||||
**Temporary vs permanent changes**: A temporary constraint (e.g., injury pausing a hobby) does NOT contradict the underlying preference. Store the constraint as a new memory; don't delete the preference.
|
||||
- Old: "User enjoys hiking on weekends"
|
||||
@@ -267,8 +318,7 @@ User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac
|
||||
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
|
||||
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
|
||||
→ memory_search("Paris trip planning")
|
||||
→ memory_delete(memoryId: "mem-id-of-old")
|
||||
→ memory_add(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
|
||||
→ memory_update(memoryId: "mem-id-of-old", text: "As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries")
|
||||
```
|
||||
|
||||
### Example 6: Outcome over intent
|
||||
@@ -327,8 +377,8 @@ Agent: "Hello! How can I help?"
|
||||
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
|
||||
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
|
||||
→ memory_search("dog Poppy walks") → find all three old memory IDs
|
||||
→ memory_delete(memoryId: "id-1"), memory_delete(memoryId: "id-2"), memory_delete(memoryId: "id-3")
|
||||
→ memory_add(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
|
||||
→ memory_update(memoryId: "id-1", text: "User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable.")
|
||||
→ memory_delete(memoryId: "id-2"), memory_delete(memoryId: "id-3")
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting, nothing to store
|
||||
|
||||
@@ -443,7 +443,7 @@ describe("OSSProvider — _buildConfig branch coverage", () => {
|
||||
config: expect.objectContaining({ model: "gpt-4", apiKey: "sk-l" }),
|
||||
});
|
||||
expect(capturedConfig!.vectorStore).toEqual({ provider: "qdrant", config: { host: "localhost", port: 6333 } });
|
||||
expect(capturedConfig!.historyDbPath).toBe("/resolved/tmp/history.db");
|
||||
expect(capturedConfig!.historyDbPath).toBe("/tmp/history.db");
|
||||
expect(capturedConfig!.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
@@ -732,4 +732,58 @@ describe("OSSProvider — customInstructions passthrough", () => {
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.customInstructions).toBe("Extract only user preferences.");
|
||||
});
|
||||
|
||||
it("preserves absolute Unix historyDbPath without resolvePath mangling", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "/home/user/.myapp/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/stateDir/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("/home/user/.myapp/history.db");
|
||||
});
|
||||
|
||||
it("preserves absolute Windows historyDbPath without resolvePath mangling", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "C:\\Users\\me\\history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/stateDir/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("C:\\Users\\me\\history.db");
|
||||
});
|
||||
|
||||
it("still resolves relative historyDbPath via resolvePath", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "data/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/resolved/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("/resolved/data/history.db");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("../cli/config-file.ts", () => ({
|
||||
readPluginAuth: vi.fn().mockReturnValue({}),
|
||||
writePluginAuth: vi.fn(),
|
||||
writePluginConfigField: vi.fn(),
|
||||
enableSkillsConfig: vi.fn(),
|
||||
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
|
||||
OPENCLAW_CONFIG_FILE: "/mock/.openclaw/openclaw.json",
|
||||
}));
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
writePluginConfigField,
|
||||
enableSkillsConfig,
|
||||
getBaseUrl,
|
||||
} from "../cli/config-file.ts";
|
||||
import { loadDreamPrompt } from "../skill-loader.ts";
|
||||
@@ -178,7 +180,7 @@ function createMockCfg() {
|
||||
topK: 5,
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
searchThreshold: 0.5,
|
||||
searchThreshold: 0.1,
|
||||
customInstructions: "",
|
||||
customCategories: {},
|
||||
skills: {},
|
||||
|
||||
@@ -44,14 +44,14 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
|
||||
expect(cfg.userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("autoCapture defaults to false", () => {
|
||||
it("autoCapture defaults to true", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.autoCapture).toBe(false);
|
||||
expect(cfg.autoCapture).toBe(true);
|
||||
});
|
||||
|
||||
it("autoRecall defaults to false", () => {
|
||||
it("autoRecall defaults to true", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.autoRecall).toBe(false);
|
||||
expect(cfg.autoRecall).toBe(true);
|
||||
});
|
||||
|
||||
it("topK defaults to 5", () => {
|
||||
@@ -59,9 +59,9 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
|
||||
expect(cfg.topK).toBe(5);
|
||||
});
|
||||
|
||||
it("searchThreshold defaults to 0.5", () => {
|
||||
it("searchThreshold defaults to 0.1", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.searchThreshold).toBe(0.5);
|
||||
expect(cfg.searchThreshold).toBe(0.1);
|
||||
});
|
||||
|
||||
it("customInstructions defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildOssLlmConfig,
|
||||
buildOssEmbedderConfig,
|
||||
buildOssVectorConfig,
|
||||
collectionNameForDims,
|
||||
validateOssFlags,
|
||||
checkQdrantConnectivity,
|
||||
checkOllamaConnectivity,
|
||||
@@ -90,9 +91,9 @@ describe("buildOssEmbedderConfig", () => {
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
|
||||
it("returns unknown dims for custom model", () => {
|
||||
it("falls back to provider default dims for custom model", () => {
|
||||
const result = buildOssEmbedderConfig("ollama", { model: "custom-embed" });
|
||||
expect(result.dims).toBeUndefined();
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +148,49 @@ describe("buildOssVectorConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectionNameForDims", () => {
|
||||
it("generates dimension-based collection name", () => {
|
||||
expect(collectionNameForDims(1536)).toBe("mem0_1536d");
|
||||
expect(collectionNameForDims(768)).toBe("mem0_768d");
|
||||
expect(collectionNameForDims(384)).toBe("mem0_384d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssVectorConfig dimension safety", () => {
|
||||
it("sets both dimension and embeddingModelDims when dims provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
});
|
||||
|
||||
it("sets collectionName based on dims", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
|
||||
it("uses different collection names for different dims", () => {
|
||||
const r1 = buildOssVectorConfig("qdrant", { dims: 1536 });
|
||||
const r2 = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(r1.config.collectionName).not.toBe(r2.config.collectionName);
|
||||
});
|
||||
|
||||
it("omits dimension fields when dims not provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", {});
|
||||
expect(result.config.dimension).toBeUndefined();
|
||||
expect(result.config.embeddingModelDims).toBeUndefined();
|
||||
expect(result.config.collectionName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("works with pgvector too", () => {
|
||||
const result = buildOssVectorConfig("pgvector", {
|
||||
host: "localhost", port: "5432", user: "me", password: "pw", dbname: "test", dims: 768,
|
||||
});
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkQdrantConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkQdrantConnectivity("http://localhost:19999");
|
||||
|
||||
@@ -34,7 +34,7 @@ function createMockToolDeps(overrides = {}): ToolDeps {
|
||||
topK: 5,
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
searchThreshold: 0.5,
|
||||
searchThreshold: 0.1,
|
||||
customInstructions: "test",
|
||||
customCategories: {},
|
||||
} as any,
|
||||
|
||||
@@ -8,7 +8,8 @@ export default defineConfig({
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: [/^node:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module"],
|
||||
external: [/^node:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module",
|
||||
"mem0ai", /^mem0ai\//, "better-sqlite3", "@sinclair/typebox"],
|
||||
define: {
|
||||
__OPENCLAW_PLUGIN_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
|
||||
@@ -79,8 +79,8 @@ class TestRerank:
|
||||
reranker = LLMReranker({"provider": "openai"})
|
||||
reranker.rerank("query", [{"text": "some text"}])
|
||||
|
||||
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
|
||||
assert "some text" in prompt_sent
|
||||
user_msg = mock_llm_instance.generate_response.call_args[1]["messages"][1]["content"]
|
||||
assert "some text" in user_msg
|
||||
|
||||
def test_content_field_extraction(self, mock_llm):
|
||||
_, mock_llm_instance = mock_llm
|
||||
@@ -89,8 +89,8 @@ class TestRerank:
|
||||
reranker = LLMReranker({"provider": "openai"})
|
||||
reranker.rerank("query", [{"content": "some content"}])
|
||||
|
||||
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
|
||||
assert "some content" in prompt_sent
|
||||
user_msg = mock_llm_instance.generate_response.call_args[1]["messages"][1]["content"]
|
||||
assert "some content" in user_msg
|
||||
|
||||
def test_fallback_score_on_llm_error(self, mock_llm):
|
||||
_, mock_llm_instance = mock_llm
|
||||
@@ -106,12 +106,14 @@ class TestRerank:
|
||||
_, mock_llm_instance = mock_llm
|
||||
mock_llm_instance.generate_response.return_value = "0.7"
|
||||
|
||||
custom_prompt = "Rate this: query={query} doc={document}"
|
||||
custom_prompt = "Rate relevance on a scale of 0.0 to 1.0."
|
||||
reranker = LLMReranker({"provider": "openai", "scoring_prompt": custom_prompt})
|
||||
reranker.rerank("my query", [{"memory": "my doc"}])
|
||||
|
||||
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
|
||||
assert prompt_sent == "Rate this: query=my query doc=my doc"
|
||||
messages = mock_llm_instance.generate_response.call_args[1]["messages"]
|
||||
assert messages[0]["content"] == custom_prompt
|
||||
assert "my query" in messages[1]["content"]
|
||||
assert "my doc" in messages[1]["content"]
|
||||
|
||||
def test_original_doc_not_mutated(self, mock_llm):
|
||||
_, mock_llm_instance = mock_llm
|
||||
|
||||
@@ -127,10 +127,10 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
|
||||
# Verify pgvector instance properties
|
||||
self.assertEqual(pgvector.collection_name, "test_collection")
|
||||
self.assertEqual(pgvector.embedding_model_dims, 3)
|
||||
@@ -179,8 +179,8 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
# Verify pgvector instance properties
|
||||
@@ -233,7 +233,7 @@ class TestPGVector(unittest.TestCase):
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
# Verify pgvector instance properties
|
||||
@@ -277,7 +277,7 @@ class TestPGVector(unittest.TestCase):
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
# Verify pgvector instance properties
|
||||
@@ -319,7 +319,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify insert query was executed (psycopg3 uses executemany)
|
||||
insert_calls = [call for call in self.mock_cursor.executemany.call_args_list
|
||||
if "INSERT INTO test_collection" in str(call)]
|
||||
if "INSERT INTO" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(insert_calls) > 0)
|
||||
|
||||
# Verify data format
|
||||
@@ -392,7 +392,9 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_execute_values.assert_called_once()
|
||||
call_args = mock_execute_values.call_args
|
||||
|
||||
self.assertIn("INSERT INTO test_collection", call_args[0][1])
|
||||
mock_psycopg2.sql.SQL.assert_any_call(
|
||||
"INSERT INTO {} (id, vector, payload) VALUES %s"
|
||||
)
|
||||
|
||||
# The data argument should be a list of tuples, one per vector
|
||||
data_arg = call_args[0][2]
|
||||
@@ -400,6 +402,9 @@ class TestPGVector(unittest.TestCase):
|
||||
self.assertEqual(data_arg[0][0], self.test_ids[0])
|
||||
self.assertEqual(data_arg[1][0], self.test_ids[1])
|
||||
|
||||
# Restore the module after the sys.modules patch reverts
|
||||
importlib.reload(sys.modules['mem0.vector_stores.pgvector'])
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@patch('mem0.vector_stores.pgvector.ConnectionPool')
|
||||
@patch.object(PGVector, '_get_cursor')
|
||||
@@ -534,7 +539,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DELETE FROM test_collection" in str(call)]
|
||||
if "DELETE FROM" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -573,7 +578,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DELETE FROM test_collection" in str(call)]
|
||||
if "DELETE FROM" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@@ -615,7 +620,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify update queries were executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -657,7 +662,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify update queries were executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@@ -867,7 +872,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete_col query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DROP TABLE IF EXISTS test_collection" in str(call)]
|
||||
if "DROP TABLE IF EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -906,7 +911,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete_col query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DROP TABLE IF EXISTS test_collection" in str(call)]
|
||||
if "DROP TABLE IF EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@@ -1802,7 +1807,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify the update query was executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -1843,7 +1848,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify the update query was executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -1861,7 +1866,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Only raise exception on the delete operation, not during setup
|
||||
def execute_side_effect(*args, **kwargs):
|
||||
if args and "DELETE FROM" in str(args[0]):
|
||||
if args and ("DELETE FROM" in str(args[0]) or "DELETE" in repr(args[0])):
|
||||
raise Exception("Database error")
|
||||
return MagicMock()
|
||||
mock_cursor.execute.side_effect = execute_side_effect
|
||||
@@ -2003,9 +2008,9 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify only vector update query was executed (not payload)
|
||||
vector_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET vector" in str(call) and "payload" not in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET vector" in str(call) and "payload" not in str(call)]
|
||||
payload_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
|
||||
self.assertTrue(len(vector_update_calls) > 0)
|
||||
self.assertEqual(len(payload_update_calls), 0)
|
||||
@@ -2045,9 +2050,9 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify both vector and payload update queries were executed
|
||||
vector_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET vector" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET vector" in str(call)]
|
||||
payload_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
|
||||
self.assertTrue(len(vector_update_calls) > 0)
|
||||
self.assertTrue(len(payload_update_calls) > 0)
|
||||
|
||||
Reference in New Issue
Block a user