refactor: update memory skill loader, plugin config, and add privacy docs (#4905)

This commit is contained in:
Kartik
2026-04-22 17:15:19 +05:30
committed by GitHub
parent 32b74e18b7
commit f5dc825d47
21 changed files with 1734 additions and 189 deletions
+21
View File
@@ -4,6 +4,27 @@ description: "Release notes for the OpenClaw plugin and agent harness."
mode: "wide"
---
<Update label="2026-04-21" description="v1.0.8">
**New Features:**
- **OSS Onboarding Wizard:** New guided 4-step interactive setup for open-source mode — walks through LLM provider, embedding provider, vector store, and user ID selection with prefilled defaults
- **Agent-Friendly CLI:** Added `--json` flag to all 16 CLI commands for machine-readable output. Agents can call `openclaw mem0 help --json` to discover every command and flag
- **Non-Interactive OSS Setup:** Added `--mode open-source` with `--oss-llm`, `--oss-embedder`, `--oss-vector` flags for fully automated OSS configuration without prompts
- **JSON Helpers Module:** New `cli/json-helpers.ts` with `jsonOut`, `jsonErr`, and `redactSecrets` utilities for consistent structured output
**Improvements:**
- **Init Flow Redesigned:** Replaced 3-option flat menu with 2-level structure: Platform (email login or API key) and Open Source (guided wizard)
- **Provider Selection:** LLM providers: OpenAI, Ollama, Anthropic. Embedding providers: OpenAI, Ollama. Vector stores: Qdrant, PGVector
- **Input Prefill:** All prompts with defaults (base URL, user ID) now prefill the input field instead of showing defaults in brackets
- **Smart Reuse:** When LLM and embedder use the same provider, API key and base URL are automatically reused from the LLM step
- **Default Model:** Updated default LLM model to `gpt-5-mini`
- **Manifest Compliance:** Removed undocumented fields, aligned env var declarations between SKILL.md and manifest, fixed `configSchema.required` for clean installs
**Tests:**
- 404 tests across 15 test files (+3 new: `json-helpers.test.ts`, `oss-wizard.test.ts`, `cli-commands.test.ts`)
</Update>
<Update label="2026-04-20" description="v1.0.7">
**New Features:**
+146 -8
View File
@@ -16,7 +16,7 @@ The plugin provides:
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
Both auto-recall and auto-capture run silently with no manual configuration required.
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.
## Requirements
@@ -147,7 +147,81 @@ OpenClaw treats memory plugins as an exclusive slot. Installing the plugin alone
### Open-Source Mode (Self-hosted)
No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
No Mem0 key needed. Defaults use OpenAI (`gpt-5-mini` for LLM, `text-embedding-3-small` for embeddings) — requires `OPENAI_API_KEY`. For a fully local setup, use Ollama for both.
#### Option 1: Interactive Wizard (Recommended)
Run the guided 4-step wizard:
```bash
openclaw mem0 init --mode open-source
```
The wizard walks you through:
<Steps>
<Step title="LLM provider">
Choose OpenAI (`gpt-5-mini`), Ollama (`llama3.1:8b`, fully local), or Anthropic (`claude-sonnet-4-5-20250514`). Provide an API key or base URL as needed.
</Step>
<Step title="Embedding provider">
Choose OpenAI (`text-embedding-3-small`) or Ollama (`nomic-embed-text`, local). If the same provider was chosen for LLM, the API key and URL are reused automatically.
</Step>
<Step title="Vector store">
Choose Qdrant (`http://localhost:6333`) or PGVector (PostgreSQL). Connectivity is verified before proceeding.
</Step>
<Step title="User ID">
Set your memory namespace identifier.
</Step>
</Steps>
#### Option 2: Non-Interactive Setup
For CI/CD, scripts, or agent-driven setup — pass all options as flags:
```bash
# Fully local with Ollama + Qdrant
openclaw mem0 init --mode open-source \
--oss-llm ollama --oss-embedder ollama --oss-vector qdrant
# OpenAI + Qdrant
openclaw mem0 init --mode open-source \
--oss-llm openai --oss-llm-key <key> \
--oss-embedder openai --oss-embedder-key <key> \
--oss-vector qdrant
# Anthropic LLM + OpenAI embeddings + PGVector
openclaw mem0 init --mode open-source \
--oss-llm anthropic --oss-llm-key <key> \
--oss-embedder openai --oss-embedder-key <key> \
--oss-vector pgvector --oss-vector-user postgres --oss-vector-password secret
```
Add `--json` for machine-readable output (useful when an LLM agent is driving the setup).
<Accordion title="All --oss-* flags">
| Flag | Description |
|------|-------------|
| `--oss-llm <provider>` | `openai`, `ollama`, or `anthropic` |
| `--oss-llm-key <key>` | API key for LLM provider |
| `--oss-llm-model <model>` | Override default LLM model |
| `--oss-llm-url <url>` | Base URL (Ollama only) |
| `--oss-embedder <provider>` | `openai` or `ollama` |
| `--oss-embedder-key <key>` | API key for embedder |
| `--oss-embedder-model <model>` | Override default embedder model |
| `--oss-embedder-url <url>` | Base URL (Ollama only) |
| `--oss-vector <provider>` | `qdrant` or `pgvector` |
| `--oss-vector-url <url>` | Qdrant server URL (default: `http://localhost:6333`) |
| `--oss-vector-host <host>` | PGVector host |
| `--oss-vector-port <port>` | PGVector port |
| `--oss-vector-user <user>` | PGVector user |
| `--oss-vector-password <pw>` | PGVector password |
| `--oss-vector-dbname <db>` | PGVector database name |
| `--oss-vector-dims <n>` | Override embedding dimensions |
</Accordion>
#### Option 3: Manual Config
Minimal config — uses OpenAI defaults:
```json5
{
@@ -168,7 +242,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
}
```
Sensible defaults work out of the box. To customize the embedder, vector store, or LLM:
To customize providers:
```json5
{
@@ -184,8 +258,8 @@ Sensible defaults work out of the box. To customize the embedder, vector store,
"userId": "your-user-id",
"oss": {
"embedder": { "provider": "openai", "config": { "model": "text-embedding-3-small" } },
"vectorStore": { "provider": "qdrant", "config": { "host": "localhost", "port": 6333 } },
"llm": { "provider": "openai", "config": { "model": "gpt-4o" } }
"vectorStore": { "provider": "qdrant", "config": { "url": "http://localhost:6333" } },
"llm": { "provider": "openai", "config": { "model": "gpt-5-mini" } }
}
}
}
@@ -225,6 +299,8 @@ The `memory_search` and `memory_list` tools accept a `scope` parameter (`"sessio
## CLI Commands
All commands support `--json` for machine-readable output — useful when an LLM agent drives the CLI programmatically. Run `openclaw mem0 help --json` to discover every command and flag.
```bash
# Search all memories (long-term + session)
openclaw mem0 search "what languages does the user know"
@@ -238,6 +314,10 @@ openclaw mem0 search "what languages does the user know" --scope session
# List all memories
openclaw mem0 list
openclaw mem0 list --user-id alice --top-k 20
# JSON output (any command)
openclaw mem0 search "preferences" --json
openclaw mem0 status --json
```
## Configuration Options
@@ -248,8 +328,8 @@ openclaw mem0 list --user-id alice --top-k 20
|-----|------|---------|-------------|
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
| `userId` | `string` | OS username | Scope memories per user |
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
| `autoRecall` | `boolean` | `false` | Inject memories before each turn (opt-in) |
| `autoCapture` | `boolean` | `false` | Store facts after each turn (opt-in) |
| `topK` | `number` | `5` | Max memories per recall |
| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
@@ -275,7 +355,7 @@ openclaw mem0 list --user-id alice --top-k 20
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
| `oss.disableHistory` | `boolean` | `false` | Disable memory edit history tracking |
Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM.
Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM (`gpt-5-mini`).
## Plugin Management
@@ -338,6 +418,64 @@ If `openclaw plugins update` fails:
openclaw plugins install @mem0/openclaw-mem0
```
## Privacy & Security
### Data Flow
| Mode | Where data goes | Storage |
|------|----------------|---------|
| **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 are disabled by default (opt-in). To enable either or both:
```json5
{
"plugins": {
"entries": {
"openclaw-mem0": {
"config": {
"autoCapture": true, // send conversations to Mem0 for extraction
"autoRecall": true // inject relevant memories into context
}
}
}
}
}
```
Without these enabled, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly — only the automatic background behavior is off.
### Credential Protection
The plugin never stores API keys, tokens, or secrets as memories. Five independent layers enforce this:
1. **Triage gate** — The extraction prompt rejects values matching known credential patterns (`sk-`, `m0-`, `ghp_`, `AKIA`, `Bearer`, `password=`, `token=`, `secret=`)
2. **Dream cleanup** — Periodic memory consolidation deletes any memories that slipped through containing credential patterns
3. **Extraction instructions** — Default extraction rules explicitly instruct the model to store only that a credential was configured, never the value
4. **Configurable patterns** — Add custom credential patterns via `skills.triage.credentialPatterns`
5. **CLI redaction** — `openclaw mem0 config show` redacts sensitive fields (`apiKey`, `oss.*.config.apiKey`)
### API Key Storage
Plugin config is stored in `~/.openclaw/openclaw.json` with file permissions `0o600` (owner-read-only). For production deployments, use environment variable references (`${MEM0_API_KEY}`) or SecretRef objects instead of plaintext keys.
### Telemetry
Anonymous usage telemetry (PostHog) is enabled by default to help improve the plugin. No conversation content or memory values are included — only event counts (recall, capture, tool usage, CLI commands).
To opt out, set the environment variable:
```bash
export MEM0_TELEMETRY=false
```
### System Prompt Context
The plugin injects memory-related instructions into the agent's system context via OpenClaw's `prependSystemContext` mechanism. This includes the memory triage protocol and recalled memories. This is the standard OpenClaw plugin SDK pattern for memory backends — no user-facing prompts are modified.
## Key Features
1. **Zero Configuration** — Auto-recall and auto-capture work out of the box with no prompting required
+90 -14
View File
@@ -2,7 +2,7 @@
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 watches conversations, extracts what matters, and brings it back when relevant. Automatically.
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.
## Requirements
@@ -99,9 +99,78 @@ That's it. No API key, no config file editing, no environment variables. The plu
### Open-Source (Self-hosted)
No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings and LLM. Vectors are stored locally in SQLite at `~/.mem0/vector_store.db` — no external database required.
No Mem0 key needed. Vectors are stored locally in SQLite at `~/.mem0/vector_store.db` — no external database required.
Defaults: `text-embedding-3-small` for embeddings, `gpt-5.4` for fact extraction.
Defaults: `text-embedding-3-small` (OpenAI) for embeddings, `gpt-5-mini` (OpenAI) for fact extraction — requires `OPENAI_API_KEY`. For a fully local setup, use Ollama for both LLM and embeddings.
#### Interactive Setup (Recommended)
Run the guided 4-step wizard:
```bash
openclaw mem0 init --mode open-source
```
The wizard walks you through:
1. **LLM provider** — OpenAI (`gpt-5-mini`), Ollama (`llama3.1:8b`, local), or Anthropic (`claude-sonnet-4-5-20250514`)
2. **Embedding provider** — OpenAI (`text-embedding-3-small`) or Ollama (`nomic-embed-text`, local)
3. **Vector store** — Qdrant (`http://localhost:6333`) or PGVector (PostgreSQL)
4. **User ID** — your memory namespace identifier
Each step tests connectivity (Ollama, Qdrant, PGVector) before proceeding.
#### Non-Interactive Setup
For CI/CD, scripts, or agent-driven setup — pass all options as flags:
```bash
# Fully local with Ollama + Qdrant
openclaw mem0 init --mode open-source \
--oss-llm ollama --oss-embedder ollama --oss-vector qdrant
# OpenAI + Qdrant
openclaw mem0 init --mode open-source \
--oss-llm openai --oss-llm-key <key> \
--oss-embedder openai --oss-embedder-key <key> \
--oss-vector qdrant
# Anthropic LLM + OpenAI embeddings + PGVector
openclaw mem0 init --mode open-source \
--oss-llm anthropic --oss-llm-key <key> \
--oss-embedder openai --oss-embedder-key <key> \
--oss-vector pgvector --oss-vector-user postgres --oss-vector-password secret
# JSON output (for LLM agents)
openclaw mem0 init --mode open-source --oss-llm ollama --oss-embedder ollama --oss-vector qdrant --json
```
<details>
<summary>All <code>--oss-*</code> flags</summary>
| Flag | Description |
| ---- | ----------- |
| `--oss-llm <provider>` | `openai`, `ollama`, or `anthropic` |
| `--oss-llm-key <key>` | API key for LLM provider |
| `--oss-llm-model <model>` | Override default LLM model |
| `--oss-llm-url <url>` | Base URL (Ollama only) |
| `--oss-embedder <provider>` | `openai` or `ollama` |
| `--oss-embedder-key <key>` | API key for embedder |
| `--oss-embedder-model <model>` | Override default embedder model |
| `--oss-embedder-url <url>` | Base URL (Ollama only) |
| `--oss-vector <provider>` | `qdrant` or `pgvector` |
| `--oss-vector-url <url>` | Qdrant server URL (default: `http://localhost:6333`) |
| `--oss-vector-host <host>` | PGVector host |
| `--oss-vector-port <port>` | PGVector port |
| `--oss-vector-user <user>` | PGVector user |
| `--oss-vector-password <pw>` | PGVector password |
| `--oss-vector-dbname <db>` | PGVector database name |
| `--oss-vector-dims <n>` | Override embedding dimensions |
</details>
#### Manual Config
Minimal config — uses OpenAI defaults:
```json5
{
@@ -130,8 +199,8 @@ Customize the embedder, vector store, or LLM via the `oss` block:
"userId": "alice",
"oss": {
"embedder": { "provider": "openai", "config": { "model": "text-embedding-3-small" } },
"vectorStore": { "provider": "qdrant", "config": { "host": "localhost", "port": 6333 } },
"llm": { "provider": "openai", "config": { "model": "gpt-5.4" } }
"vectorStore": { "provider": "qdrant", "config": { "url": "http://localhost:6333" } },
"llm": { "provider": "openai", "config": { "model": "gpt-5-mini" } }
}
}
```
@@ -144,11 +213,11 @@ 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** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
**Auto-Recall** (`autoRecall: true`) — 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.
**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.
Both run silently. No prompting, no manual calls required.
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.
### Memory Scopes
@@ -176,7 +245,7 @@ Eight tools are registered for agent use:
## CLI
All commands: `openclaw mem0 <command>`.
All commands: `openclaw mem0 <command>`. All commands support `--json` for machine-readable output (for LLM agents).
```bash
# Memory operations
@@ -191,8 +260,9 @@ openclaw mem0 delete --all --user-id alice --confirm
openclaw mem0 import memories.json
# Management
openclaw mem0 init
openclaw mem0 init --api-key <key> --user-id alice
openclaw mem0 init # interactive setup
openclaw mem0 init --mode open-source --oss-llm ollama # non-interactive OSS
openclaw mem0 init --api-key <key> --user-id alice # non-interactive platform
openclaw mem0 status
openclaw mem0 config show
openclaw mem0 config get api_key
@@ -205,6 +275,12 @@ openclaw mem0 event status <event_id>
# Memory consolidation
openclaw mem0 dream
openclaw mem0 dream --dry-run
# JSON output (any command)
openclaw mem0 search "preferences" --json
openclaw mem0 list --json
openclaw mem0 status --json
openclaw mem0 help --json # discover all commands + flags
```
## Configuration Reference
@@ -215,8 +291,8 @@ openclaw mem0 dream --dry-run
| --- | ---- | ------- | ----------- |
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
| `userId` | `string` | OS username | User identifier. All memories scoped to this value. |
| `autoRecall` | `boolean` | `true` | Inject relevant memories before each turn |
| `autoCapture` | `boolean` | `true` | Extract and store facts after each turn |
| `autoRecall` | `boolean` | `false` | Inject relevant memories before each turn |
| `autoCapture` | `boolean` | `false` | Extract and store facts after each turn |
| `topK` | `number` | `5` | Max memories returned per recall |
| `searchThreshold` | `number` | `0.5` | Minimum similarity score (0-1) |
@@ -230,7 +306,7 @@ openclaw mem0 dream --dry-run
### Open-Source Mode
All fields optional. Defaults: `text-embedding-3-small` embeddings, local SQLite vector store (`~/.mem0/vector_store.db`), `gpt-5.4` LLM.
All fields optional. Defaults: `text-embedding-3-small` embeddings, local SQLite vector store (`~/.mem0/vector_store.db`), `gpt-5-mini` LLM.
| Key | Type | Default | Description |
| --- | ---- | ------- | ----------- |
+501 -120
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
/**
* JSON output helpers for agent-friendly CLI commands.
*/
function writeStdout(data: Record<string, unknown>): void {
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
}
export function jsonOut(
opts: { json?: boolean },
data: Record<string, unknown>,
): boolean {
if (!opts.json) return false;
writeStdout(data);
return true;
}
export function jsonErr(
opts: { json?: boolean },
error: string,
): boolean {
if (!opts.json) return false;
writeStdout({ ok: false, error });
return true;
}
export function redactSecrets(
obj: Record<string, unknown>,
secretKeys: Set<string>,
): Record<string, unknown> {
const result = { ...obj };
for (const key of secretKeys) {
const val = result[key];
if (typeof val !== "string") continue;
result[key] = val.length <= 8
? val.slice(0, 2) + "***"
: val.slice(0, 4) + "..." + val.slice(-4);
}
return result;
}
+227
View File
@@ -0,0 +1,227 @@
/**
* OSS provider wizard — provider definitions, config builders, and validation.
*
* Used by the init command for both interactive wizard and non-interactive
* --oss-* flag paths.
*/
import { join } from "node:path";
import { homedir } from "node:os";
// ============================================================================
// Provider definitions
// ============================================================================
export interface ProviderDef {
id: string;
label: string;
needsApiKey: boolean;
needsUrl: boolean;
envVar?: string;
defaultModel: string;
defaultUrl?: string;
}
export const LLM_PROVIDERS: ProviderDef[] = [
{ id: "openai", label: "OpenAI (requires API key)", needsApiKey: true, needsUrl: false, envVar: "OPENAI_API_KEY", defaultModel: "gpt-5-mini" },
{ id: "ollama", label: "Ollama (local, no API key)", needsApiKey: false, needsUrl: true, defaultModel: "llama3.1:8b", defaultUrl: "http://localhost:11434" },
{ id: "anthropic", label: "Anthropic (requires API key)", needsApiKey: true, needsUrl: false, envVar: "ANTHROPIC_API_KEY", defaultModel: "claude-sonnet-4-5-20250514" },
];
export interface EmbedderDef extends ProviderDef {
defaultDims: number;
}
export const EMBEDDER_PROVIDERS: EmbedderDef[] = [
{ id: "openai", label: "OpenAI (requires API key)", needsApiKey: true, needsUrl: false, envVar: "OPENAI_API_KEY", defaultModel: "text-embedding-3-small", defaultDims: 1536 },
{ id: "ollama", label: "Ollama (local, no API key)", needsApiKey: false, needsUrl: true, defaultModel: "nomic-embed-text", defaultUrl: "http://localhost:11434", defaultDims: 768 },
];
export interface VectorDef {
id: string;
label: string;
needsConnection: boolean;
defaultUrl?: string;
defaultPort?: number;
setupHint?: string;
}
export const VECTOR_PROVIDERS: VectorDef[] = [
{ id: "qdrant", label: "Qdrant (requires server — Docker or cloud)", needsConnection: true, defaultUrl: "http://localhost:6333", defaultPort: 6333, setupHint: "docker run -d -p 6333:6333 qdrant/qdrant" },
{ id: "pgvector", label: "PGVector (requires PostgreSQL + pgvector extension)", needsConnection: true, defaultPort: 5432, setupHint: "docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg17" },
];
export const KNOWN_EMBEDDER_DIMS: Record<string, number> = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
"nomic-embed-text": 768,
};
// ============================================================================
// Config builders
// ============================================================================
export interface LlmConfigInput {
apiKey?: string;
model?: string;
url?: string;
}
export function buildOssLlmConfig(
providerId: string,
input: LlmConfigInput,
): { provider: string; config: Record<string, unknown> } {
const def = LLM_PROVIDERS.find((p) => p.id === providerId);
if (!def) throw new Error(`Unknown LLM provider: ${providerId}`);
const config: Record<string, unknown> = {
model: input.model || def.defaultModel,
};
if (input.apiKey) config.apiKey = input.apiKey;
if (providerId === "ollama") {
config.url = input.url || def.defaultUrl;
}
return { provider: providerId, config };
}
export interface EmbedderConfigInput {
apiKey?: string;
model?: string;
url?: string;
}
export function buildOssEmbedderConfig(
providerId: string,
input: EmbedderConfigInput,
): { provider: string; config: Record<string, unknown>; dims: number | undefined } {
const def = EMBEDDER_PROVIDERS.find((p) => p.id === providerId);
if (!def) throw new Error(`Unknown embedder provider: ${providerId}`);
const model = input.model || def.defaultModel;
const config: Record<string, unknown> = { model };
if (input.apiKey) config.apiKey = input.apiKey;
if (providerId === "ollama") {
config.url = input.url || def.defaultUrl;
}
const dims = KNOWN_EMBEDDER_DIMS[model] ?? undefined;
return { provider: providerId, config, dims };
}
export interface VectorConfigInput {
url?: string;
host?: string;
port?: string;
user?: string;
password?: string;
dbname?: string;
apiKey?: string;
dims?: number;
}
export function buildOssVectorConfig(
providerId: string,
input: VectorConfigInput,
): { provider: string; config: Record<string, unknown> } {
const config: Record<string, unknown> = {};
if (providerId === "qdrant") {
config.url = input.url || "http://localhost:6333";
config.onDisk = true;
if (input.apiKey) config.apiKey = input.apiKey;
} else if (providerId === "pgvector") {
config.host = input.host || "localhost";
config.port = parseInt(input.port || "5432", 10);
if (input.user) config.user = input.user;
if (input.password) config.password = input.password;
config.dbname = input.dbname || "postgres";
}
if (input.dims) config.dimension = input.dims;
return { provider: providerId, config };
}
export async function checkOllamaConnectivity(url: string): Promise<{ ok: boolean; error?: string }> {
try {
const resp = await fetch(`${url.replace(/\/+$/, "")}/api/tags`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) return { ok: true };
return { ok: false, error: `Ollama returned HTTP ${resp.status}` };
} catch {
return { ok: false, error: `Cannot reach Ollama at ${url}. Install: https://ollama.com/download` };
}
}
export async function checkPgConnectivity(host: string, port: number): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
import("node:net").then(({ createConnection }) => {
const sock = createConnection({ host, port, timeout: 3000 });
sock.once("connect", () => { sock.destroy(); resolve({ ok: true }); });
sock.once("timeout", () => { sock.destroy(); resolve({ ok: false, error: `PostgreSQL not reachable at ${host}:${port}` }); });
sock.once("error", () => { sock.destroy(); resolve({ ok: false, error: `PostgreSQL not reachable at ${host}:${port}. Ensure PostgreSQL with pgvector extension is running.` }); });
});
});
}
export async function checkQdrantConnectivity(url: string): Promise<{ ok: boolean; error?: string }> {
try {
const resp = await fetch(`${url.replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) return { ok: true };
return { ok: false, error: `Qdrant returned HTTP ${resp.status}` };
} catch (err) {
return { ok: false, error: `Cannot reach Qdrant at ${url}. Start it with: docker run -d -p 6333:6333 qdrant/qdrant` };
}
}
// ============================================================================
// Non-interactive flag validation
// ============================================================================
export interface OssFlags {
ossLlm?: string;
ossLlmKey?: string;
ossLlmModel?: string;
ossLlmUrl?: string;
ossEmbedder?: string;
ossEmbedderKey?: string;
ossEmbedderModel?: string;
ossEmbedderUrl?: string;
ossVector?: string;
ossVectorUrl?: string;
ossVectorHost?: string;
ossVectorPort?: string;
ossVectorUser?: string;
ossVectorPassword?: string;
ossVectorDbname?: string;
ossVectorDims?: string;
}
export function validateOssFlags(
flags: OssFlags,
): { error?: string } {
const llmId = flags.ossLlm || "openai";
const llmDef = LLM_PROVIDERS.find((p) => p.id === llmId);
if (!llmDef) return { error: `Unknown LLM provider: ${llmId}. Valid: ${LLM_PROVIDERS.map((p) => p.id).join(", ")}` };
if (llmDef.needsApiKey && !flags.ossLlmKey) {
return { error: `--oss-llm-key required when --oss-llm is ${llmId}` };
}
const embId = flags.ossEmbedder || "openai";
const embDef = EMBEDDER_PROVIDERS.find((p) => p.id === embId);
if (!embDef) return { error: `Unknown embedder provider: ${embId}. Valid: ${EMBEDDER_PROVIDERS.map((p) => p.id).join(", ")}` };
if (embDef.needsApiKey && !flags.ossEmbedderKey && !flags.ossLlmKey) {
return { error: `--oss-embedder-key required when --oss-embedder is ${embId}` };
}
const vecId = flags.ossVector || "qdrant";
const vecDef = VECTOR_PROVIDERS.find((p) => p.id === vecId);
if (!vecDef) return { error: `Unknown vector store provider: ${vecId}. Valid: ${VECTOR_PROVIDERS.map((p) => p.id).join(", ")}` };
if (vecId === "pgvector" && !flags.ossVectorUser) {
return { error: "--oss-vector-user required when --oss-vector is pgvector" };
}
return {};
}
+2 -2
View File
@@ -231,8 +231,8 @@ export const mem0ConfigSchema = {
return "default";
}
})(),
autoCapture: cfg.autoCapture !== false,
autoRecall: cfg.autoRecall !== false,
autoCapture: cfg.autoCapture === true,
autoRecall: cfg.autoRecall === true,
// v3.0.0: customPrompt renamed to customInstructions (backwards-compat: accept either)
customInstructions:
typeof cfg.customInstructions === "string"
+70 -5
View File
@@ -1,15 +1,14 @@
{
"id": "openclaw-mem0",
"name": "Memory (Mem0)",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source. PLATFORM MODE: Sends conversation data to mem0.ai cloud (requires MEM0_API_KEY). OPEN-SOURCE MODE: Stores vectors locally (~/.mem0/history.db) but uses external APIs for embeddings/LLM (default: OpenAI, requires OPENAI_API_KEY). Auto-recall injects memories before agent turns; auto-capture extracts facts after turns. Both configurable via autoRecall/autoCapture settings. Config stored in ~/.openclaw/openclaw.json.",
"version": "1.0.7",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source. PLATFORM MODE: Sends conversation data to mem0.ai cloud (requires MEM0_API_KEY). OPEN-SOURCE MODE: Stores vectors locally (~/.mem0/history.db) but uses external APIs for embeddings/LLM (default: OpenAI, requires OPENAI_API_KEY). Auto-recall injects relevant memories into agent context before each turn; auto-capture extracts durable facts after turns. Both are opt-in via autoRecall/autoCapture config settings (default: false). The plugin injects a memory triage protocol into system context when skills mode is enabled. Config stored in ~/.openclaw/openclaw.json.",
"version": "1.0.8",
"kind": "memory",
"skills": ["skills"],
"commandAliases": [
{
"name": "mem0",
"cliCommand": "mem0",
"description": "Mem0 memory plugin commands"
"cliCommand": "mem0"
}
],
"contracts": {
@@ -20,7 +19,7 @@
},
"providerAuthEnvVars": {
"mem0": ["MEM0_API_KEY"],
"openclaw-mem0-oss": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "AZURE_OPENAI_API_KEY", "COHERE_API_KEY"]
"openclaw-mem0-oss": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
},
"providerAuthChoices": [
{
@@ -35,6 +34,32 @@
"cliFlag": "--mem0-api-key",
"cliOption": "--mem0-api-key <key>",
"cliDescription": "Mem0 platform API key"
},
{
"provider": "openclaw-mem0-oss",
"method": "config",
"choiceId": "oss-openai",
"choiceLabel": "Open Source with OpenAI",
"choiceHint": "Self-hosted mode using OpenAI for LLM and embeddings",
"groupId": "oss",
"groupLabel": "Open Source (self-hosted)",
"optionKey": "oss.llm.config.apiKey",
"cliFlag": "--oss-llm-key",
"cliOption": "--oss-llm-key <key>",
"cliDescription": "OpenAI API key for OSS LLM"
},
{
"provider": "openclaw-mem0-oss",
"method": "config",
"choiceId": "oss-ollama",
"choiceLabel": "Open Source with Ollama (local)",
"choiceHint": "Fully local mode, no API keys needed",
"groupId": "oss",
"groupLabel": "Open Source (self-hosted)",
"optionKey": "oss.llm.config.ollama_base_url",
"cliFlag": "--oss-llm-url",
"cliOption": "--oss-llm-url <url>",
"cliDescription": "Ollama base URL for local LLM"
}
],
"uiHints": {
@@ -86,11 +111,29 @@
"placeholder": "5",
"help": "Maximum number of memories to retrieve"
},
"userEmail": {
"label": "User Email",
"sensitive": true,
"advanced": true,
"help": "Email address associated with the Mem0 account. Set automatically during platform login."
},
"oss": {
"label": "Open-Source Configuration",
"advanced": true,
"help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. For API keys in sub-provider configs, use SecretRef objects or ${VAR} syntax instead of plaintext values."
},
"oss.llm.config.apiKey": {
"label": "OSS LLM API Key",
"sensitive": true,
"advanced": true,
"help": "API key for open-source LLM provider. Use SecretRef or ${VAR} syntax."
},
"oss.embedder.config.apiKey": {
"label": "OSS Embedder API Key",
"sensitive": true,
"advanced": true,
"help": "API key for open-source embedder provider. Use SecretRef or ${VAR} syntax."
},
"skills": {
"label": "Agentic Memory Skills",
"advanced": true,
@@ -114,6 +157,10 @@
"userId": {
"type": "string"
},
"baseUrl": {
"type": "string",
"description": "API base URL override (default: https://api.mem0.ai)"
},
"userEmail": {
"type": "string"
},
@@ -238,5 +285,23 @@
}
},
"required": []
},
"setup": {
"providers": [
{
"id": "mem0",
"authMethods": ["api-key"],
"envVars": ["MEM0_API_KEY"],
"description": "Platform mode: hosted memory at mem0.ai"
},
{
"id": "openclaw-mem0-oss",
"authMethods": ["api-key", "config"],
"envVars": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
"description": "Open-source mode: self-hosted with chosen LLM/embedder providers. No env vars needed when using Ollama (local)."
}
],
"requiresRuntime": false,
"postInstallHint": "Run 'openclaw mem0 init' to configure mode and credentials"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "1.0.7",
"version": "1.0.8",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
+28 -5
View File
@@ -214,6 +214,7 @@ class PlatformProvider implements Mem0Provider {
// ============================================================================
class OSSProvider implements Mem0Provider {
private static _warnPatched = false;
private memory: any; // Memory from mem0ai/oss
private initPromise: Promise<void> | null = null;
@@ -241,7 +242,7 @@ class OSSProvider implements Mem0Provider {
provider: "openai",
config: { model: "text-embedding-3-small" },
};
const defaultLlm = { provider: "openai", config: { model: "gpt-5.4" } };
const defaultLlm = { provider: "openai", config: { model: "gpt-5-mini" } };
const stripEmpty = (obj: Record<string, unknown>) => {
const out = { ...obj };
@@ -325,13 +326,35 @@ class OSSProvider implements Mem0Provider {
VectorCls.prototype.__patched = true;
}
// Proactively detect broken better-sqlite3 native binding (e.g. Node
// version mismatch) and skip history to avoid noisy constructor failures.
let sqliteOk = true;
if (!this.ossConfig?.disableHistory) {
try {
// @ts-ignore — better-sqlite3 is a transitive dep; no types in this package
const bs3Mod = await import("better-sqlite3");
const BS3 = bs3Mod.default ?? bs3Mod;
const testDb = new (BS3 as any)(":memory:");
(testDb as any).close();
} catch {
sqliteOk = false;
}
}
if (!OSSProvider._warnPatched) {
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === "string" && args[0].includes("checkCompatibility")) return;
origWarn.apply(console, args);
};
OSSProvider._warnPatched = true;
}
let mem: any;
try {
mem = new Memory(this._buildConfig());
mem = new Memory(this._buildConfig(!sqliteOk));
} catch (err) {
// If constructor fails (e.g. native SQLite binding under jiti/Docker),
// retry with a FRESH config that has history disabled.
if (!this.ossConfig?.disableHistory) {
if (!this.ossConfig?.disableHistory && sqliteOk) {
console.warn(
"[mem0] Memory initialization failed, retrying with history disabled:",
err instanceof Error ? err.message : err,
+5 -10
View File
@@ -192,21 +192,16 @@ function renderCategoriesBlock(
}
function renderTriageKnobs(config: SkillsConfig): string {
const triage = config.triage;
if (!triage) return "";
const lines: string[] = [];
if (triage.importanceThreshold !== undefined) {
if (config.triage?.importanceThreshold !== undefined) {
lines.push(
`- Only store facts with importance >= ${triage.importanceThreshold}`,
`- Only store facts with importance >= ${config.triage.importanceThreshold}`,
);
}
const patterns = resolveCredentialPatterns(config);
if (config.triage?.credentialPatterns) {
lines.push(`- Credential patterns to scan: ${patterns.join(", ")}`);
}
lines.push(`- Credential patterns to scan: ${patterns.map((p) => `\`${p}\``).join(", ")}`);
if (lines.length === 0) return "";
return "\n## Active Configuration Overrides\n\n" + lines.join("\n");
@@ -265,8 +260,8 @@ export function loadSkill(
parts.push(renderCategoriesBlock(mergedCats));
}
// Inject triage knobs (maxFactsPerTurn, importanceThreshold, credentialPatterns)
if (skillName === "memory-triage") {
// Inject triage knobs (importanceThreshold, credentialPatterns)
if (skillName === "memory-triage" || skillName === "memory-dream") {
const knobs = renderTriageKnobs(config);
if (knobs) parts.push(knobs);
}
+1 -1
View File
@@ -46,7 +46,7 @@ Execute the actions identified in Phase 2. Work in this priority order:
### 3a. Delete dangerous and expired entries
Delete immediately using `memory_delete`:
- Credentials, API keys, tokens, passwords, secrets (patterns: sk-, m0-, ghp_, AKIA, Bearer, password=, token=, secret=)
- Credentials, API keys, tokens, passwords, secrets (matching known credential prefixes and auth patterns injected by the plugin at runtime)
- Pure timestamps with no context
- Raw tool output stored as memory
- Heartbeat or cron execution records
+4 -4
View File
@@ -37,9 +37,9 @@ Every candidate fact must pass ALL four gates:
- Fail: vague impressions, questions, small talk, acknowledgments, generic assistant responses ("Sure, I can help") → SKIP
**Gate 4 — SAFE**: Does this contain ANY credential, secret, or token?
- Scan for: `sk-`, `m0-`, `ghp_`, `AKIA`, `ak_`, `Bearer `, bot tokens (digits:alphanumeric), webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env context, `password=`, `token=`, `secret=`, `.env` values
- Scan for known credential prefixes, auth tokens, webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env context, and key-value assignment patterns. The plugin injects the full pattern list at runtime.
- ANY match → NEVER STORE the value. Instead, store that the credential was configured:
- WRONG: "User's API key is sk-abc123..."
- WRONG: "User's API key is [redacted]"
- RIGHT: "API key was configured for the service (as of 2026-03-30)"
- When in doubt → SKIP. No exceptions.
@@ -218,7 +218,7 @@ When a recalled memory needs updating (fact changed, status changed, new detail
## What NEVER to Store
- **Credentials and secrets** — even embedded in config blocks, setup logs, or tool output. Includes sk-, m0-, ak_, ghp_, bot tokens, bearer tokens, webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env contexts. Record that the credential was configured, never the value itself.
- **Credentials and secrets** — even embedded in config blocks, setup logs, or tool output. Includes any known credential prefixes, auth tokens, bearer tokens, webhook URLs with tokens, pairing codes, and long alphanumeric strings in config/env contexts. Record that the credential was configured, never the value itself.
- **Raw tool output** — bash results, file contents, API responses, logs, diffs, test output. Extract only the durable OUTCOME or ROOT CAUSE.
- **One-time commands** — "stop the script", "continue where you left off", "run this"
- **Acknowledgments and emotional reactions** — "ok", "sure", "sounds good", "sir", "got it", "thanks", "you're right"
@@ -280,7 +280,7 @@ Agent: [updates the sheet successfully]
### Example 7: Credential — store the fact, not the value
```
User: "Use this API key for the new service: sk-proj-abc123def456"
User: "Use this API key for the new service: [credential value]"
Agent: [configures the service]
→ memory_add(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
```
+35 -1
View File
@@ -119,7 +119,12 @@ describe("OSSProvider — disableHistory passthrough to Memory", () => {
expect(capturedConfig!.disableHistory).toBe(true);
});
it("does not set disableHistory when not configured", async () => {
it("does not set disableHistory when not configured and sqlite works", async () => {
// Mock better-sqlite3 so the proactive probe succeeds
vi.doMock("better-sqlite3", () => {
return { default: class { close() {} } };
});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
@@ -248,6 +253,12 @@ describe("OSSProvider — graceful SQLite fallback", () => {
});
it("retries with disableHistory: true when initial construction fails", async () => {
// Mock better-sqlite3 so the proactive probe succeeds — tests the
// catch-retry fallback path for other constructor errors.
vi.doMock("better-sqlite3", () => {
return { default: class { close() {} } };
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
@@ -274,6 +285,29 @@ describe("OSSProvider — graceful SQLite fallback", () => {
warnSpy.mockRestore();
});
it("proactively disables history when better-sqlite3 binary is broken", async () => {
// Do NOT mock better-sqlite3 — let probe detect the real version mismatch
// (or force it to fail if native binary happens to work on this Node).
vi.doMock("better-sqlite3", () => {
return { default: class { constructor() { throw new Error("NODE_MODULE_VERSION mismatch"); } } };
});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
// Only ONE constructor call — probe detected broken sqlite, skipped retry
expect(capturedConfigs).toHaveLength(1);
expect(capturedConfigs[0].disableHistory).toBe(true);
});
it("does not retry when disableHistory is already true", async () => {
// Force the constructor to always throw, regardless of disableHistory
forceConstructorError = "vector store connection refused";
+296
View File
@@ -32,6 +32,7 @@ vi.mock("../skill-loader.ts", () => ({
loadDreamPrompt: vi.fn().mockReturnValue("dream prompt"),
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
@@ -251,6 +252,7 @@ describe("registerCliCommands", () => {
warn: ReturnType<typeof vi.spyOn>;
};
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetAllMocks();
@@ -267,6 +269,7 @@ describe("registerCliCommands", () => {
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
};
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
afterEach(() => {
@@ -274,6 +277,7 @@ describe("registerCliCommands", () => {
consoleSpy.error.mockRestore();
consoleSpy.warn.mockRestore();
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
vi.restoreAllMocks();
});
@@ -527,6 +531,158 @@ describe("registerCliCommands", () => {
vi.unstubAllGlobals();
});
it("outputs JSON for --api-key flow when --json is set", async () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({}),
}));
await initCmd._action!({ apiKey: "m0-key", json: true });
const jsonCall = stdoutSpy.mock.calls.find((c) => {
try {
const p = JSON.parse(c[0] as string);
return typeof p.ok === "boolean";
} catch { return false; }
});
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(jsonCall![0] as string);
expect(parsed.ok).toBe(true);
expect(parsed.mode).toBe("platform");
expect(parsed.validated).toBe(true);
vi.unstubAllGlobals();
});
it("outputs JSON for --api-key flow with failed validation when --json is set", async () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: vi.fn().mockResolvedValue({}),
}));
await initCmd._action!({ apiKey: "bad-key", json: true });
const jsonCall = stdoutSpy.mock.calls.find((c) => {
try {
const p = JSON.parse(c[0] as string);
return typeof p.ok === "boolean";
} catch { return false; }
});
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(jsonCall![0] as string);
expect(parsed.ok).toBe(false);
expect(parsed.mode).toBe("platform");
expect(parsed.validated).toBe(false);
expect(parsed.httpStatus).toBe(401);
vi.unstubAllGlobals();
});
it("outputs JSON for --api-key + --email conflict when --json is set", async () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
await initCmd._action!({ apiKey: "key", email: "a@b.com", json: true });
const jsonCall = stdoutSpy.mock.calls.find((c) => {
try {
const p = JSON.parse(c[0] as string);
return p.ok === false;
} catch { return false; }
});
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(jsonCall![0] as string);
expect(parsed.error).toContain("Cannot use both");
expect(writePluginAuth).not.toHaveBeenCalled();
});
it("outputs JSON for email send-code flow when --json is set", async () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({}),
}));
await initCmd._action!({ email: "user@example.com", json: true });
const jsonCall = stdoutSpy.mock.calls.find((c) => {
try {
const p = JSON.parse(c[0] as string);
return p.codeSent === true;
} catch { return false; }
});
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(jsonCall![0] as string);
expect(parsed.ok).toBe(true);
expect(parsed.email).toBe("user@example.com");
expect(parsed.nextCommand).toContain("--code");
vi.unstubAllGlobals();
});
it("outputs JSON for email verify flow when --json is set", async () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ api_key: "m0-verified" }),
}));
await initCmd._action!({ email: "u@b.com", code: "123456", json: true });
const jsonCall = stdoutSpy.mock.calls.find((c) => {
try {
const p = JSON.parse(c[0] as string);
return p.ok === true && p.mode === "platform";
} catch { return false; }
});
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(jsonCall![0] as string);
expect(parsed.email).toBe("u@b.com");
expect(parsed.message).toContain("Authenticated");
vi.unstubAllGlobals();
});
it("clears stale apiKey when switching to OSS mode", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
apiKey: "m0-old-platform-key",
mode: "platform",
userId: "testuser",
});
await initCmd._action!({
mode: "open-source",
ossLlm: "ollama",
ossEmbedder: "ollama",
ossVector: "qdrant",
});
expect(writePluginAuth).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: "",
mode: "open-source",
}),
);
vi.unstubAllGlobals();
});
});
// ========================================================================
@@ -1456,4 +1612,144 @@ describe("registerCliCommands", () => {
);
});
});
// ========================================================================
// Restructured init menu flags
// ========================================================================
describe("init — restructured menu", () => {
it("registers --mode flag", () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
const modeOpt = initCmd._options.find((o) => o.flags.includes("--mode"));
expect(modeOpt).toBeDefined();
});
it("registers --oss-llm flag", () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
const opt = initCmd._options.find((o) => o.flags.includes("--oss-llm "));
expect(opt).toBeDefined();
});
it("registers --json flag on init", () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
const opt = initCmd._options.find((o) => o.flags.includes("--json"));
expect(opt).toBeDefined();
});
});
// ========================================================================
// --json flag registration on all commands
// ========================================================================
describe("--json flag registration", () => {
for (const name of ["search", "add", "get", "list", "update", "delete", "status", "import", "dream"]) {
it(`registers --json on ${name}`, () => {
const { mem0 } = setup();
const cmd = findCommand(mem0, name)!;
const opt = cmd._options.find((o) => o.flags.includes("--json"));
expect(opt).toBeDefined();
});
}
it("registers --json on config show", () => {
const { mem0 } = setup();
const configCmd = findCommand(mem0, "config")!;
const showCmd = findCommand(configCmd, "show")!;
expect(showCmd).toBeDefined();
const opt = showCmd._options.find((o) => o.flags.includes("--json"));
expect(opt).toBeDefined();
});
});
// ========================================================================
// Non-interactive OSS init
// ========================================================================
describe("init --mode open-source (non-interactive)", () => {
it("writes LLM, embedder, and vector config for ollama + qdrant", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
await initCmd._action!({
mode: "open-source",
ossLlm: "ollama",
ossEmbedder: "ollama",
ossVector: "qdrant",
userId: "test-user",
});
expect(writePluginConfigField).toHaveBeenCalledWith(
["oss", "llm"],
expect.objectContaining({ provider: "ollama" }),
);
expect(writePluginConfigField).toHaveBeenCalledWith(
["oss", "embedder"],
expect.objectContaining({ provider: "ollama" }),
);
expect(writePluginConfigField).toHaveBeenCalledWith(
["oss", "vectorStore"],
expect.objectContaining({ provider: "qdrant" }),
);
expect(writePluginAuth).toHaveBeenCalledWith(
expect.objectContaining({ mode: "open-source", userId: "test-user" }),
);
vi.unstubAllGlobals();
});
it("outputs JSON when --json is passed", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
await initCmd._action!({
mode: "open-source",
ossLlm: "ollama",
ossEmbedder: "ollama",
ossVector: "qdrant",
json: true,
});
const jsonCall = stdoutSpy.mock.calls.find((c) => {
try {
const p = JSON.parse(c[0] as string);
return p.ok === true;
} catch {
return false;
}
});
expect(jsonCall).toBeDefined();
if (jsonCall) {
const parsed = JSON.parse(jsonCall[0] as string);
expect(parsed.mode).toBe("open-source");
expect(parsed.config.llm.provider).toBe("ollama");
}
vi.unstubAllGlobals();
});
it("errors when openai LLM has no key", async () => {
const { mem0 } = setup();
const initCmd = findCommand(mem0, "init")!;
// Ensure env var is not set so validation fails
const savedEnv = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
await initCmd._action!({ mode: "open-source", ossLlm: "openai" });
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("--oss-llm-key"),
);
// Restore env var
if (savedEnv !== undefined) process.env.OPENAI_API_KEY = savedEnv;
});
});
});
+4 -4
View File
@@ -44,14 +44,14 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
expect(cfg.userId.length).toBeGreaterThan(0);
});
it("autoCapture defaults to true", () => {
it("autoCapture defaults to false", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.autoCapture).toBe(true);
expect(cfg.autoCapture).toBe(false);
});
it("autoRecall defaults to true", () => {
it("autoRecall defaults to false", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.autoRecall).toBe(true);
expect(cfg.autoRecall).toBe(false);
});
it("topK defaults to 5", () => {
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { jsonOut, jsonErr, redactSecrets } from "../cli/json-helpers.ts";
describe("jsonOut", () => {
let writeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
afterEach(() => { writeSpy.mockRestore(); });
it("returns false and prints nothing when json is falsy", () => {
expect(jsonOut({}, { ok: true })).toBe(false);
expect(writeSpy).not.toHaveBeenCalled();
});
it("returns true and prints JSON to stdout when json is true", () => {
expect(jsonOut({ json: true }, { ok: true, count: 3 })).toBe(true);
expect(writeSpy).toHaveBeenCalledOnce();
const parsed = JSON.parse(writeSpy.mock.calls[0][0] as string);
expect(parsed).toEqual({ ok: true, count: 3 });
});
});
describe("jsonErr", () => {
let writeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
afterEach(() => { writeSpy.mockRestore(); });
it("returns false when json is falsy", () => {
expect(jsonErr({}, "bad")).toBe(false);
});
it("returns true and prints error JSON to stdout", () => {
expect(jsonErr({ json: true }, "Something broke")).toBe(true);
const parsed = JSON.parse(writeSpy.mock.calls[0][0] as string);
expect(parsed).toEqual({ ok: false, error: "Something broke" });
});
});
describe("redactSecrets", () => {
it("redacts string values for known secret keys", () => {
const input = { apiKey: "m0-abcdefghijklmnop", name: "test" };
const result = redactSecrets(input, new Set(["apiKey"]));
expect(result.apiKey).toBe("m0-a...mnop");
expect(result.name).toBe("test");
});
it("handles short keys", () => {
const result = redactSecrets({ apiKey: "ab" }, new Set(["apiKey"]));
expect(result.apiKey).toBe("ab***");
});
it("skips non-string values", () => {
const result = redactSecrets({ count: 5 }, new Set(["count"]));
expect(result.count).toBe(5);
});
});
+196
View File
@@ -0,0 +1,196 @@
import { describe, it, expect } from "vitest";
import {
LLM_PROVIDERS,
EMBEDDER_PROVIDERS,
VECTOR_PROVIDERS,
KNOWN_EMBEDDER_DIMS,
buildOssLlmConfig,
buildOssEmbedderConfig,
buildOssVectorConfig,
validateOssFlags,
checkQdrantConnectivity,
checkOllamaConnectivity,
checkPgConnectivity,
} from "../cli/oss-wizard.ts";
describe("LLM_PROVIDERS", () => {
it("has 3 providers", () => {
expect(LLM_PROVIDERS).toHaveLength(3);
expect(LLM_PROVIDERS.map((p) => p.id)).toEqual(["openai", "ollama", "anthropic"]);
});
it("openai requires API key", () => {
const openai = LLM_PROVIDERS.find((p) => p.id === "openai")!;
expect(openai.needsApiKey).toBe(true);
expect(openai.defaultModel).toBe("gpt-5-mini");
});
it("ollama needs no API key but needs URL", () => {
const ollama = LLM_PROVIDERS.find((p) => p.id === "ollama")!;
expect(ollama.needsApiKey).toBe(false);
expect(ollama.needsUrl).toBe(true);
expect(ollama.defaultUrl).toBe("http://localhost:11434");
});
});
describe("EMBEDDER_PROVIDERS", () => {
it("has 2 providers", () => {
expect(EMBEDDER_PROVIDERS).toHaveLength(2);
});
});
describe("KNOWN_EMBEDDER_DIMS", () => {
it("maps default models to dims", () => {
expect(KNOWN_EMBEDDER_DIMS["text-embedding-3-small"]).toBe(1536);
expect(KNOWN_EMBEDDER_DIMS["nomic-embed-text"]).toBe(768);
});
});
describe("buildOssLlmConfig", () => {
it("builds openai config with API key", () => {
const result = buildOssLlmConfig("openai", { apiKey: "sk-test" });
expect(result).toEqual({
provider: "openai",
config: { model: "gpt-5-mini", apiKey: "sk-test" },
});
});
it("builds ollama config with custom URL and model", () => {
const result = buildOssLlmConfig("ollama", { url: "http://myhost:11434", model: "mistral" });
expect(result).toEqual({
provider: "ollama",
config: { model: "mistral", url: "http://myhost:11434" },
});
});
it("builds ollama config with default URL", () => {
const result = buildOssLlmConfig("ollama", {});
expect(result.config.url).toBe("http://localhost:11434");
});
it("ignores url for non-ollama providers", () => {
const result = buildOssLlmConfig("anthropic", { apiKey: "sk-ant", url: "http://ignored" });
expect(result.config).not.toHaveProperty("url");
expect(result.config).toHaveProperty("apiKey", "sk-ant");
});
});
describe("buildOssEmbedderConfig", () => {
it("builds openai embedder", () => {
const result = buildOssEmbedderConfig("openai", { apiKey: "sk-test" });
expect(result.config.model).toBe("text-embedding-3-small");
expect(result.dims).toBe(1536);
});
it("builds ollama embedder with url field", () => {
const result = buildOssEmbedderConfig("ollama", { url: "http://myhost:11434" });
expect(result.config.url).toBe("http://myhost:11434");
expect(result.config).not.toHaveProperty("ollama_base_url");
expect(result.config.model).toBe("nomic-embed-text");
expect(result.dims).toBe(768);
});
it("returns unknown dims for custom model", () => {
const result = buildOssEmbedderConfig("ollama", { model: "custom-embed" });
expect(result.dims).toBeUndefined();
});
});
describe("VECTOR_PROVIDERS", () => {
it("has 2 providers", () => {
expect(VECTOR_PROVIDERS).toHaveLength(2);
expect(VECTOR_PROVIDERS.map((p) => p.id)).toEqual(["qdrant", "pgvector"]);
});
it("qdrant requires server connection", () => {
const qdrant = VECTOR_PROVIDERS.find((p) => p.id === "qdrant")!;
expect(qdrant.needsConnection).toBe(true);
expect(qdrant.defaultUrl).toBe("http://localhost:6333");
expect(qdrant.setupHint).toContain("docker");
});
it("pgvector requires connection and has setup hint", () => {
const pg = VECTOR_PROVIDERS.find((p) => p.id === "pgvector")!;
expect(pg.needsConnection).toBe(true);
expect(pg.defaultPort).toBe(5432);
expect(pg.setupHint).toContain("pgvector");
});
});
describe("buildOssVectorConfig", () => {
it("builds qdrant with default url and dims", () => {
const result = buildOssVectorConfig("qdrant", { dims: 1536 });
expect(result.config.url).toBe("http://localhost:6333");
expect(result.config.onDisk).toBe(true);
expect(result.config.dimension).toBe(1536);
});
it("builds qdrant with custom url", () => {
const result = buildOssVectorConfig("qdrant", { url: "http://qdrant.local:6333", dims: 768 });
expect(result.config.url).toBe("http://qdrant.local:6333");
expect(result.config.onDisk).toBe(true);
expect(result.config.dimension).toBe(768);
});
it("builds qdrant with api key for cloud", () => {
const result = buildOssVectorConfig("qdrant", { url: "https://cloud.qdrant.io", apiKey: "qd-key", dims: 1536 });
expect(result.config.apiKey).toBe("qd-key");
expect(result.config.url).toBe("https://cloud.qdrant.io");
});
it("builds pgvector with connection details", () => {
const result = buildOssVectorConfig("pgvector", {
host: "db.local", port: "5432", user: "me", password: "pw", dbname: "mydb", dims: 512,
});
expect(result.config.host).toBe("db.local");
expect(result.config.dimension).toBe(512);
});
});
describe("checkQdrantConnectivity", () => {
it("returns error for unreachable host", async () => {
const result = await checkQdrantConnectivity("http://localhost:19999");
expect(result.ok).toBe(false);
expect(result.error).toContain("Cannot reach Qdrant");
});
});
describe("checkOllamaConnectivity", () => {
it("returns error for unreachable host", async () => {
const result = await checkOllamaConnectivity("http://localhost:19998");
expect(result.ok).toBe(false);
expect(result.error).toContain("Cannot reach Ollama");
});
});
describe("checkPgConnectivity", () => {
it("returns error for unreachable host", async () => {
const result = await checkPgConnectivity("localhost", 19997);
expect(result.ok).toBe(false);
expect(result.error).toContain("PostgreSQL not reachable");
});
});
describe("validateOssFlags", () => {
it("returns error when openai LLM has no key", () => {
const result = validateOssFlags({ ossLlm: "openai" });
expect(result.error).toContain("--oss-llm-key");
});
it("passes for ollama with no key", () => {
const result = validateOssFlags({ ossLlm: "ollama", ossEmbedder: "ollama", ossVector: "qdrant" });
expect(result.error).toBeUndefined();
});
it("returns error for unknown provider", () => {
const result = validateOssFlags({ ossLlm: "bogus" });
expect(result.error).toContain("Unknown LLM provider");
});
it("returns error when pgvector missing user", () => {
const result = validateOssFlags({
ossLlm: "ollama", ossEmbedder: "ollama", ossVector: "pgvector",
});
expect(result.error).toContain("--oss-vector-user");
});
});
+4 -3
View File
@@ -263,10 +263,11 @@ describe("memory_search execute", () => {
scope: "session",
});
// Should call buildSearchOptions with session ID
// Should call buildSearchOptions with session ID as 4th arg (sessionKey)
expect(ctx.buildSearchOptions).toHaveBeenCalledWith(
"testuser",
undefined,
undefined,
"session-abc",
);
expect(result.details.count).toBe(1);
@@ -446,8 +447,8 @@ describe("memory_add execute", () => {
await tool.execute("call-7", { text: "new fact" });
// Search should be called for dedup before add
expect(searchMock).toHaveBeenCalledOnce();
// Mem0 backend handles dedup internally — no separate search call
expect(searchMock).not.toHaveBeenCalled();
expect(addMock).toHaveBeenCalledOnce();
});
});
-3
View File
@@ -82,9 +82,6 @@ export function createMemoryAddTool(deps: ToolDeps) {
}
const combinedText = allFacts.join("\n");
const dedupOpts = buildSearchOptions(uid, 3);
dedupOpts.threshold = 0.85;
await provider.search(combinedText.slice(0, 200), dedupOpts);
const result = await provider.add([{ role: "user", content: combinedText }], buildAddOptions(uid, runId, currentSessionId));
const added = result.results?.filter((r) => r.event === "ADD") ?? [];
+2 -2
View File
@@ -47,7 +47,7 @@ export function createMemorySearchTool(deps: ToolDeps) {
if (scope === "session") {
if (currentSessionId) {
results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, currentSessionId)));
results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, undefined, currentSessionId)));
}
} else if (scope === "long-term") {
results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit)));
@@ -55,7 +55,7 @@ export function createMemorySearchTool(deps: ToolDeps) {
const longTerm = await provider.search(query, applyFilters(buildSearchOptions(uid, limit)));
let session: MemoryItem[] = [];
if (currentSessionId) {
session = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, currentSessionId)));
session = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, undefined, currentSessionId)));
}
const seen = new Set(longTerm.map((r) => r.id));
results = [...longTerm, ...session.filter((r) => !seen.has(r.id))];