diff --git a/docs/changelog/openclaw.mdx b/docs/changelog/openclaw.mdx index 9de66efa7..53aaba797 100644 --- a/docs/changelog/openclaw.mdx +++ b/docs/changelog/openclaw.mdx @@ -4,6 +4,27 @@ description: "Release notes for the OpenClaw plugin and agent harness." mode: "wide" --- + + +**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`) + + + **New Features:** diff --git a/docs/integrations/openclaw.mdx b/docs/integrations/openclaw.mdx index a48088a56..8c2cfc918 100644 --- a/docs/integrations/openclaw.mdx +++ b/docs/integrations/openclaw.mdx @@ -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: + + + + 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. + + + 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. + + + Choose Qdrant (`http://localhost:6333`) or PGVector (PostgreSQL). Connectivity is verified before proceeding. + + + Set your memory namespace identifier. + + + +#### 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 \ + --oss-embedder openai --oss-embedder-key \ + --oss-vector qdrant + +# Anthropic LLM + OpenAI embeddings + PGVector +openclaw mem0 init --mode open-source \ + --oss-llm anthropic --oss-llm-key \ + --oss-embedder openai --oss-embedder-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). + + +| Flag | Description | +|------|-------------| +| `--oss-llm ` | `openai`, `ollama`, or `anthropic` | +| `--oss-llm-key ` | API key for LLM provider | +| `--oss-llm-model ` | Override default LLM model | +| `--oss-llm-url ` | Base URL (Ollama only) | +| `--oss-embedder ` | `openai` or `ollama` | +| `--oss-embedder-key ` | API key for embedder | +| `--oss-embedder-model ` | Override default embedder model | +| `--oss-embedder-url ` | Base URL (Ollama only) | +| `--oss-vector ` | `qdrant` or `pgvector` | +| `--oss-vector-url ` | Qdrant server URL (default: `http://localhost:6333`) | +| `--oss-vector-host ` | PGVector host | +| `--oss-vector-port ` | PGVector port | +| `--oss-vector-user ` | PGVector user | +| `--oss-vector-password ` | PGVector password | +| `--oss-vector-dbname ` | PGVector database name | +| `--oss-vector-dims ` | Override embedding dimensions | + + +#### 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 diff --git a/openclaw/README.md b/openclaw/README.md index d554459c0..680361232 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -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 \ + --oss-embedder openai --oss-embedder-key \ + --oss-vector qdrant + +# Anthropic LLM + OpenAI embeddings + PGVector +openclaw mem0 init --mode open-source \ + --oss-llm anthropic --oss-llm-key \ + --oss-embedder openai --oss-embedder-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 +``` + +
+All --oss-* flags + +| Flag | Description | +| ---- | ----------- | +| `--oss-llm ` | `openai`, `ollama`, or `anthropic` | +| `--oss-llm-key ` | API key for LLM provider | +| `--oss-llm-model ` | Override default LLM model | +| `--oss-llm-url ` | Base URL (Ollama only) | +| `--oss-embedder ` | `openai` or `ollama` | +| `--oss-embedder-key ` | API key for embedder | +| `--oss-embedder-model ` | Override default embedder model | +| `--oss-embedder-url ` | Base URL (Ollama only) | +| `--oss-vector ` | `qdrant` or `pgvector` | +| `--oss-vector-url ` | Qdrant server URL (default: `http://localhost:6333`) | +| `--oss-vector-host ` | PGVector host | +| `--oss-vector-port ` | PGVector port | +| `--oss-vector-user ` | PGVector user | +| `--oss-vector-password ` | PGVector password | +| `--oss-vector-dbname ` | PGVector database name | +| `--oss-vector-dims ` | Override embedding dimensions | + +
+ +#### 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 Architecture

-**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 `. +All commands: `openclaw mem0 `. 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 --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 --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 # 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 | | --- | ---- | ------- | ----------- | diff --git a/openclaw/cli/commands.ts b/openclaw/cli/commands.ts index 41297bd8b..18987ec29 100644 --- a/openclaw/cli/commands.ts +++ b/openclaw/cli/commands.ts @@ -45,6 +45,12 @@ import { writePluginConfigField, OPENCLAW_CONFIG_FILE, } from "./config-file.ts"; +import { jsonOut, jsonErr, redactSecrets } from "./json-helpers.ts"; +import { + LLM_PROVIDERS, EMBEDDER_PROVIDERS, VECTOR_PROVIDERS, + buildOssLlmConfig, buildOssEmbedderConfig, buildOssVectorConfig, + validateOssFlags, checkQdrantConnectivity, checkOllamaConnectivity, checkPgConnectivity, +} from "./oss-wizard.ts"; // ============================================================================ // Reusable helpers (DRY) @@ -201,26 +207,173 @@ function saveLoginConfig( apiKey: string, userIdFlag?: string, userEmail?: string, + silent?: boolean, ): void { const existingAuth = readPluginAuth(); const userId = resolveUserId(userIdFlag, existingAuth.userId); writePluginAuth({ apiKey, userId, mode: "platform", ...(userEmail && { userEmail }) }); - console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`); - console.log(` Mode: platform`); - console.log(` User ID: ${userId}`); + if (!silent) { + console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`); + console.log(` Mode: platform`); + console.log(` User ID: ${userId}`); + } } -function saveOssConfig(userIdFlag?: string): void { +function saveOssConfig(userIdFlag?: string, silent?: boolean): void { const existingAuth = readPluginAuth(); const userId = resolveUserId(userIdFlag, existingAuth.userId); - writePluginAuth({ userId, mode: "open-source" }); + writePluginAuth({ apiKey: "", userId, mode: "open-source" }); - console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`); - console.log(` Mode: open-source`); - console.log(` User ID: ${userId}`); + if (!silent) { + console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`); + console.log(` Mode: open-source`); + console.log(` User ID: ${userId}`); + } +} + +async function runOssWizardInteractive( + opts: { userId?: string; json?: boolean }, + existingAuth: PluginAuthConfig, +): Promise { + // === Step 1: LLM Provider === + console.log("\n Step 1/4 — LLM Provider\n"); + LLM_PROVIDERS.forEach((p, i) => console.log(` ${i + 1}. ${p.label}`)); + console.log(""); + const llmIdx = parseInt(await promptInput(` Choice (1-${LLM_PROVIDERS.length}): `) || "1", 10) - 1; + const llmDef = LLM_PROVIDERS[llmIdx] || LLM_PROVIDERS[0]; + + let llmApiKey: string | undefined; + let llmUrl: string | undefined; + if (llmDef.needsApiKey) { + llmApiKey = await promptInput(` ${llmDef.envVar} API Key (Enter to use env var): `); + if (!llmApiKey) llmApiKey = undefined; + } + if (llmDef.needsUrl) { + llmUrl = await promptInput(` Base URL: `, llmDef.defaultUrl) || llmDef.defaultUrl; + } + + const llmCfg = buildOssLlmConfig(llmDef.id, { apiKey: llmApiKey, url: llmUrl }); + + if (llmDef.id === "ollama") { + const ollamaUrl = (llmCfg.config.url as string) || "http://localhost:11434"; + const check = await checkOllamaConnectivity(ollamaUrl); + if (!check.ok) { + console.error(`\n ⚠ Ollama not reachable at ${ollamaUrl}. Install: https://ollama.com/download\n`); + return; + } + console.log(" ✓ Ollama connected"); + } + + writePluginConfigField(["oss", "llm"], llmCfg); + + // === Step 2: Embedding Provider === + console.log("\n Step 2/4 — Embedding Provider\n"); + EMBEDDER_PROVIDERS.forEach((p, i) => console.log(` ${i + 1}. ${p.label}`)); + console.log(""); + const embIdx = parseInt(await promptInput(` Choice (1-${EMBEDDER_PROVIDERS.length}): `) || "1", 10) - 1; + const embDef = EMBEDDER_PROVIDERS[embIdx] || EMBEDDER_PROVIDERS[0]; + + let embApiKey: string | undefined; + let embUrl: string | undefined; + if (embDef.needsApiKey) { + if (embDef.id === llmDef.id && llmApiKey) { + console.log(` Reusing ${llmDef.id} API key from Step 1`); + embApiKey = llmApiKey; + } else { + embApiKey = await promptInput(` ${embDef.envVar} API Key (Enter to use env var): `); + if (!embApiKey) embApiKey = undefined; + } + } + if (embDef.needsUrl) { + if (embDef.id === llmDef.id && llmUrl) { + console.log(` Reusing ${llmDef.id} base URL from Step 1`); + embUrl = llmUrl; + } else { + embUrl = await promptInput(` Base URL: `, embDef.defaultUrl) || embDef.defaultUrl; + } + } + + const embCfg = buildOssEmbedderConfig(embDef.id, { apiKey: embApiKey, url: embUrl }); + + if (embDef.id === "ollama" && embDef.id !== llmDef.id) { + const ollamaUrl = (embCfg.config.url as string) || "http://localhost:11434"; + const check = await checkOllamaConnectivity(ollamaUrl); + if (!check.ok) { + console.error(`\n ⚠ Ollama not reachable at ${ollamaUrl}. Install: https://ollama.com/download\n`); + return; + } + console.log(" ✓ Ollama connected"); + } + + writePluginConfigField(["oss", "embedder"], { provider: embCfg.provider, config: embCfg.config }); + const dims = embCfg.dims ?? embDef.defaultDims; + + // === Step 3: Vector Store === + console.log("\n Step 3/4 — Vector Store\n"); + VECTOR_PROVIDERS.forEach((p, i) => console.log(` ${i + 1}. ${p.label}`)); + console.log(""); + const vecIdx = parseInt(await promptInput(` Choice (1-${VECTOR_PROVIDERS.length}): `) || "1", 10) - 1; + const vecDef = VECTOR_PROVIDERS[vecIdx] || VECTOR_PROVIDERS[0]; + + let vecInput: Record = { dims }; + if (vecDef.id === "qdrant") { + if (vecDef.setupHint) console.log(`\n Hint: ${vecDef.setupHint}`); + console.log(""); + const url = await promptInput(` Qdrant URL: `, vecDef.defaultUrl) || vecDef.defaultUrl; + vecInput = { url, dims }; + + const check = await checkQdrantConnectivity(url!); + if (!check.ok) { + console.error(`\n ⚠ Qdrant not reachable at ${url}. Start with: docker run -d -p 6333:6333 qdrant/qdrant\n`); + return; + } + console.log(" ✓ Qdrant connected"); + } else if (vecDef.id === "pgvector") { + if (vecDef.setupHint) console.log(`\n Hint: ${vecDef.setupHint}`); + console.log(""); + const host = await promptInput(" Host [localhost]: ") || "localhost"; + const port = await promptInput(" Port [5432]: ") || "5432"; + const user = await promptInput(" User: "); + const password = await promptInput(" Password: "); + const dbname = await promptInput(" Database [postgres]: ") || "postgres"; + vecInput = { host, port, user, password, dbname, dims }; + + const check = await checkPgConnectivity(host, parseInt(port, 10)); + if (!check.ok) { + console.error(`\n ⚠ PostgreSQL not reachable at ${host}:${port}. ${vecDef.setupHint ? `Start with: ${vecDef.setupHint}` : ""}\n`); + return; + } + console.log(" ✓ PostgreSQL connected"); + } + + const vecCfg = buildOssVectorConfig(vecDef.id, vecInput as any); + writePluginConfigField(["oss", "vectorStore"], vecCfg); + + // === Step 4: User ID === + console.log("\n Step 4/4 — User ID\n"); + let userIdValue = opts.userId; + if (!userIdValue) { + const defaultUid = resolveUserId(undefined, existingAuth.userId); + const uidInput = await promptInput(` User ID: `, defaultUid); + userIdValue = uidInput || defaultUid; + } + + console.log(""); + saveOssConfig(userIdValue); + + console.log(""); + console.log(" Open-source mode configured!"); + console.log(""); + 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(` User ID: ${userIdValue}`); + console.log(""); + console.log(" Run: openclaw gateway restart"); + console.log(""); } // ============================================================================ @@ -247,7 +400,7 @@ export function registerCliCommands( ({ program }) => { const mem0 = program .command("mem0") - .description("Mem0 memory plugin commands") + .description("Mem0 memory plugin commands\n\nTip: All commands support --json for machine-readable output (for LLM agents)") .configureHelp({ sortSubcommands: false, subcommandTerm: (cmd) => cmd.name() }); // Telemetry: fire event for each CLI subcommand @@ -273,45 +426,198 @@ export function registerCliCommands( .option("--code ", "Verification code (use with --email)") .option("--api-key ", "Direct API key entry") .option("--user-id ", "Set user ID for memory namespace") + .option("--mode ", "platform or open-source (skips menu)") + .option("--oss-llm ", "LLM: openai, ollama, anthropic") + .option("--oss-llm-key ", "API key for LLM provider") + .option("--oss-llm-model ", "Override default LLM model") + .option("--oss-llm-url ", "Base URL (ollama only)") + .option("--oss-embedder ", "Embedder: openai, ollama, huggingface") + .option("--oss-embedder-key ", "API key for embedder") + .option("--oss-embedder-model ", "Override default embedder model") + .option("--oss-embedder-url ", "Base URL (ollama only)") + .option("--oss-vector ", "Vector store: qdrant, pgvector") + .option("--oss-vector-url ", "Qdrant server URL (default: http://localhost:6333)") + .option("--oss-vector-host ", "PGVector host") + .option("--oss-vector-port ", "PGVector port") + .option("--oss-vector-user ", "PGVector user") + .option("--oss-vector-password ", "PGVector password") + .option("--oss-vector-dbname ", "PGVector database name") + .option("--oss-vector-dims ", "Override embedding dimensions") + .option("--json", "Machine-readable JSON output") .action( async (opts: { email?: string; code?: string; apiKey?: string; userId?: string; + mode?: string; + 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; + json?: boolean; }) => { try { const baseUrl = "https://api.mem0.ai"; const existingAuth = readPluginAuth(); const hasExistingConfig = !!(existingAuth.apiKey || existingAuth.mode); + // -- Non-interactive OSS via --mode open-source + flags -- + if (opts.mode === "open-source") { + const validation = validateOssFlags(opts); + if (validation.error) { + if (jsonErr(opts, validation.error)) return; + console.error(validation.error); + return; + } + + const llmId = opts.ossLlm || "openai"; + const embId = opts.ossEmbedder || "openai"; + const vecId = opts.ossVector || "qdrant"; + + const embKey = opts.ossEmbedderKey || (embId === llmId ? opts.ossLlmKey : undefined); + const embUrl = opts.ossEmbedderUrl || (embId === llmId ? opts.ossLlmUrl : undefined); + + const llmCfg = buildOssLlmConfig(llmId, { + apiKey: opts.ossLlmKey, model: opts.ossLlmModel, url: opts.ossLlmUrl, + }); + const embCfg = buildOssEmbedderConfig(embId, { + apiKey: embKey, model: opts.ossEmbedderModel, url: embUrl, + }); + const dims = opts.ossVectorDims ? parseInt(opts.ossVectorDims, 10) : embCfg.dims; + const vecCfg = buildOssVectorConfig(vecId, { + url: opts.ossVectorUrl, host: opts.ossVectorHost, + port: opts.ossVectorPort, user: opts.ossVectorUser, + password: opts.ossVectorPassword, dbname: opts.ossVectorDbname, + dims, + }); + + // Connectivity checks — Ollama, Qdrant, PGVector + const ollamaUrls = new Set(); + if (llmId === "ollama") ollamaUrls.add((llmCfg.config.url as string) || "http://localhost:11434"); + if (embId === "ollama") ollamaUrls.add((embCfg.config.url as string) || "http://localhost:11434"); + for (const oUrl of ollamaUrls) { + const check = await checkOllamaConnectivity(oUrl); + if (!check.ok) { + const msg = `Ollama not reachable at ${oUrl}. Install: https://ollama.com/download`; + if (jsonErr(opts, msg)) return; + console.error(`\n ${msg}\n`); + return; + } + } + + if (vecId === "qdrant") { + const qdrantUrl = (vecCfg.config.url as string) || "http://localhost:6333"; + const check = await checkQdrantConnectivity(qdrantUrl); + if (!check.ok) { + const msg = `Qdrant not reachable at ${qdrantUrl}. Start with: docker run -d -p 6333:6333 qdrant/qdrant`; + if (jsonErr(opts, msg)) return; + console.error(`\n ${msg}\n`); + return; + } + } else if (vecId === "pgvector") { + const pgHost = (vecCfg.config.host as string) || "localhost"; + const pgPort = (vecCfg.config.port as number) || 5432; + const check = await checkPgConnectivity(pgHost, pgPort); + if (!check.ok) { + const msg = `PostgreSQL not reachable at ${pgHost}:${pgPort}. Start with: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg17`; + if (jsonErr(opts, msg)) return; + console.error(`\n ${msg}\n`); + return; + } + } + + writePluginConfigField(["oss", "llm"], llmCfg); + writePluginConfigField(["oss", "embedder"], { provider: embCfg.provider, config: embCfg.config }); + writePluginConfigField(["oss", "vectorStore"], vecCfg); + saveOssConfig(opts.userId, !!opts.json); + + const vecDisplay = vecId === "qdrant" ? vecCfg.config.url : vecCfg.config.host; + const summary = { + ok: true as const, + 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 }) }, + }, + userId: resolveUserId(opts.userId, existingAuth.userId), + message: "Open-source mode configured. Restart the gateway: openclaw gateway restart", + }; + if (jsonOut(opts, summary)) return; + + console.log("\n Open-source mode configured!\n"); + console.log(` LLM: ${llmCfg.provider} (${llmCfg.config.model})`); + console.log(` Embedder: ${embCfg.provider} (${embCfg.config.model})`); + console.log(` Vector: ${vecCfg.provider} (${vecDisplay})`); + console.log(` User ID: ${resolveUserId(opts.userId, existingAuth.userId)}`); + console.log("\n Restart the gateway: openclaw gateway restart\n"); + return; + } + + // -- Non-interactive --mode platform routing ----------------- + if (opts.mode === "platform") { + if (!opts.apiKey && !opts.email) { + const msg = "--api-key or --email required for platform mode"; + if (jsonErr(opts, msg)) return; + console.error(msg); + return; + } + // fall through to existing --api-key / --email handlers below + } + // -- API key flow ------------------------------------------------ if (opts.apiKey) { if (opts.email) { - console.error("Cannot use both --api-key and --email."); + const msg = "Cannot use both --api-key and --email."; + if (jsonErr(opts, msg)) return; + console.error(msg); return; } const check = await validateApiKey(baseUrl, opts.apiKey); - saveLoginConfig(opts.apiKey, opts.userId, check.userEmail); + saveLoginConfig(opts.apiKey, opts.userId, check.userEmail, !!opts.json); + + let message: string; + if (check.ok) { + message = "API key validated. Connected to Mem0 Platform."; + } else if (check.status) { + message = `API key saved but validation returned HTTP ${check.status}. Check that the key is correct.`; + } else { + message = `API key saved but could not reach ${baseUrl}: ${check.error}. Check your network connection.`; + } + + const summary = { + ok: check.ok, + mode: "platform" as const, + userId: resolveUserId(opts.userId, existingAuth.userId), + validated: check.ok, + ...(check.status && !check.ok && { httpStatus: check.status }), + message, + }; + if (jsonOut(opts, summary)) return; + if (hasExistingConfig) { console.log( " Existing configuration detected — updated API key (other settings preserved).", ); } - if (check.ok) { - console.log( - " API key validated. Connected to Mem0 Platform.", - ); - } else if (check.status) { - console.warn( - ` API key saved but validation returned HTTP ${check.status}. Check that the key is correct.`, - ); + console.log(` ${message}`); } else { - console.warn( - ` API key saved but could not reach ${baseUrl}: ${check.error}. Check your network connection.`, - ); + console.warn(` ${message}`); } console.log( " Restart the gateway: openclaw gateway restart\n", @@ -323,9 +629,21 @@ export function registerCliCommands( if (opts.email && opts.code) { const email = opts.email.trim().toLowerCase(); const apiKey = await verifyEmailCode(baseUrl, email, opts.code); - if (!apiKey) return; + if (!apiKey) { + if (jsonErr(opts, "Email verification failed — no API key returned.")) return; + return; + } + + saveLoginConfig(apiKey, opts.userId, email, !!opts.json); + const summary = { + ok: true as const, + mode: "platform" as const, + userId: resolveUserId(opts.userId, existingAuth.userId), + email, + message: "Authenticated. Restart the gateway: openclaw gateway restart", + }; + if (jsonOut(opts, summary)) return; - saveLoginConfig(apiKey, opts.userId, email); if (hasExistingConfig) { console.log( " Existing configuration detected — updated API key (other settings preserved).", @@ -343,9 +661,13 @@ export function registerCliCommands( const email = opts.email.trim().toLowerCase(); const sent = await sendVerificationCode(baseUrl, email); if (sent) { + const nextCmd = `openclaw mem0 init --email ${email} --code `; + if (jsonOut(opts, { ok: true, email, codeSent: true, nextCommand: nextCmd })) return; console.log( - `Verification code sent! Run:\n openclaw mem0 init --email ${email} --code `, + `Verification code sent! Run:\n ${nextCmd}`, ); + } else { + if (jsonErr(opts, `Failed to send verification code to ${email}.`)) return; } return; } @@ -353,21 +675,15 @@ export function registerCliCommands( // -- No flags: interactive flow ----------------------------------- if (!process.stdin.isTTY) { console.log("Usage (non-interactive):"); - console.log( - " openclaw mem0 init --api-key ", - ); - console.log( - " openclaw mem0 init --api-key --user-id ", - ); - console.log( - " openclaw mem0 init --email ", - ); - console.log( - " openclaw mem0 init --email --code ", - ); - console.log( - " openclaw mem0 init --email --code --user-id ", - ); + console.log(" Platform:"); + console.log(" openclaw mem0 init --api-key "); + console.log(" openclaw mem0 init --api-key --user-id "); + console.log(" openclaw mem0 init --email "); + console.log(" openclaw mem0 init --email --code "); + console.log(" Open Source:"); + console.log(" openclaw mem0 init --mode open-source --oss-llm ollama --oss-embedder ollama --oss-vector qdrant"); + console.log(" openclaw mem0 init --mode open-source --oss-llm openai --oss-llm-key "); + console.log(" Add --json for machine-readable output."); return; } @@ -423,14 +739,19 @@ export function registerCliCommands( } console.log("\n Mem0 Setup\n"); - console.log(" How would you like to set up Mem0?"); - console.log(" 1. Login with email (recommended)"); - console.log(" 2. Enter API key manually"); - console.log(" 3. Open-source mode (self-hosted)\n"); + console.log(" How would you like to use Mem0?"); + console.log(" 1. Platform (recommended) — hosted memory, managed by Mem0"); + console.log(" 2. Open Source — self-hosted, choose your own providers\n"); - const choice = (await promptInput(" Choice (1/2/3): ")) || "1"; + const modeChoice = (await promptInput(" Choice (1/2): ")) || "1"; - if (choice === "1") { + if (modeChoice === "1") { + console.log("\n How would you like to authenticate?"); + console.log(" 1. Login with email (recommended)"); + console.log(" 2. Enter API key manually\n"); + const authChoice = (await promptInput(" Choice (1/2): ")) || "1"; + + if (authChoice === "1") { // --- Email interactive flow --- const email = ( await promptInput(" Email: ") @@ -471,7 +792,7 @@ export function registerCliCommands( console.log( " Restart the gateway: openclaw gateway restart\n", ); - } else if (choice === "2") { + } else if (authChoice === "2") { // --- API key interactive flow --- const key = await promptInput(" API Key: "); if (!key) { @@ -509,68 +830,13 @@ export function registerCliCommands( console.log( " Restart the gateway: openclaw gateway restart\n", ); - } else if (choice === "3") { - // --- Open-source interactive flow --- - console.log( - "\n Open-source mode uses the Mem0 OSS SDK locally.", - ); - console.log( - " By default it requires an OpenAI API key for embeddings and LLM.\n", - ); - - console.log( - " You need an OpenAI API key for embeddings and LLM.", - ); - console.log( - " Get one from https://platform.openai.com/api-keys\n", - ); - const openaiKey = await promptInput( - " OpenAI API Key (or press Enter to skip): ", - ); - if (openaiKey) { - writePluginConfigField( - ["oss", "embedder"], - { provider: "openai", config: { apiKey: openaiKey } }, - ); - writePluginConfigField( - ["oss", "llm"], - { provider: "openai", config: { apiKey: openaiKey } }, - ); - console.log( - "\n OpenAI API key saved to config.\n", - ); } else { - console.log( - "\n Skipped. You can add it later via:", - ); - console.log( - " openclaw mem0 config set embedder_key ", - ); - console.log( - " Or set OPENAI_API_KEY in your environment.\n", - ); + console.log("Invalid choice. Run `openclaw mem0 init` again."); } - - // Prompt for userId - let userIdValue3 = opts.userId; - if (!userIdValue3) { - const defaultUid = resolveUserId(undefined, existingAuth.userId); - const uidInput = await promptInput( - ` User ID: `, defaultUid, - ); - userIdValue3 = uidInput || defaultUid; - } - - console.log(""); - saveOssConfig(userIdValue3); - console.log(" Open-source mode configured!"); - console.log( - " Restart the gateway: openclaw gateway restart\n", - ); + } else if (modeChoice === "2") { + await runOssWizardInteractive(opts, existingAuth); } else { - console.log( - "Invalid choice. Run `openclaw mem0 init` again.", - ); + console.log("Invalid choice. Run `openclaw mem0 init` again."); } } catch (err) { console.error(`Init failed: ${String(err)}`); @@ -594,6 +860,7 @@ export function registerCliCommands( ) .option("--agent-id ", "Search agent's memory namespace") .option("--user-id ", "Override user ID") + .option("--json", "Output as JSON") .action( async ( query: string, @@ -602,6 +869,7 @@ export function registerCliCommands( scope: string; agentId?: string; userId?: string; + json?: boolean; }, ) => { try { @@ -675,6 +943,7 @@ export function registerCliCommands( } if (!allResults.length) { + if (jsonOut(opts, { ok: true, results: [] })) return; console.log("No memories found."); return; } @@ -687,8 +956,10 @@ export function registerCliCommands( categories: r.categories, created_at: r.created_at, })); + if (jsonOut(opts, { ok: true, results: output })) return; console.log(JSON.stringify(output, null, 2)); } catch (err) { + if (jsonErr(opts, `Search failed: ${String(err)}`)) return; console.error(`Search failed: ${String(err)}`); } }, @@ -704,10 +975,11 @@ export function registerCliCommands( .argument("", "Text to store as a memory") .option("--user-id ", "Override user ID") .option("--agent-id ", "Store in agent's memory namespace") + .option("--json", "Output as JSON") .action( async ( text: string, - opts: { userId?: string; agentId?: string }, + opts: { userId?: string; agentId?: string; json?: boolean }, ) => { try { const uid = opts.userId @@ -720,6 +992,7 @@ export function registerCliCommands( { user_id: uid, source: "OPENCLAW" }, ); const count = result.results?.length ?? 0; + if (jsonOut(opts, { ok: true, memories: (result.results || []).map((r: any) => ({ id: r.id, memory: r.memory, event: r.event })) })) return; if (count > 0) { console.log(`Added ${count} memory(s):`); for (const r of result.results) { @@ -731,6 +1004,7 @@ export function registerCliCommands( ); } } catch (err) { + if (jsonErr(opts, `Add failed: ${String(err)}`)) return; console.error(`Add failed: ${String(err)}`); } }, @@ -744,9 +1018,11 @@ export function registerCliCommands( .command("get") .description("Get a specific memory by ID") .argument("", "Memory ID to retrieve") - .action(async (memoryId: string) => { + .option("--json", "Output as JSON") + .action(async (memoryId: string, opts: { json?: boolean } = {}) => { try { const memory = await provider.get(memoryId); + if (jsonOut(opts, { ok: true, memory: { id: memory.id, memory: memory.memory, user_id: memory.user_id, categories: memory.categories, metadata: memory.metadata, created_at: memory.created_at, updated_at: memory.updated_at } })) return; console.log( JSON.stringify( { @@ -763,6 +1039,7 @@ export function registerCliCommands( ), ); } catch (err) { + if (jsonErr(opts, `Get failed: ${String(err)}`)) return; console.error(`Get failed: ${String(err)}`); } }); @@ -777,11 +1054,13 @@ export function registerCliCommands( .option("--user-id ", "Override user ID") .option("--agent-id ", "List agent's memories") .option("--top-k ", "Max results", "50") + .option("--json", "Output as JSON") .action( async (opts: { userId?: string; agentId?: string; topK: string; + json?: boolean; }) => { try { const uid = opts.userId @@ -797,6 +1076,7 @@ export function registerCliCommands( }); if (!Array.isArray(memories) || memories.length === 0) { + if (jsonOut(opts, { ok: true, memories: [], count: 0 })) return; console.log("No memories found."); return; } @@ -808,9 +1088,11 @@ export function registerCliCommands( created_at: m.created_at, updated_at: m.updated_at, })); + if (jsonOut(opts, { ok: true, memories: output, count: memories.length })) return; console.log(JSON.stringify(output, null, 2)); console.log(`\nTotal: ${memories.length} memories`); } catch (err) { + if (jsonErr(opts, `List failed: ${String(err)}`)) return; console.error(`List failed: ${String(err)}`); } }, @@ -825,11 +1107,14 @@ export function registerCliCommands( .description("Update a memory's text") .argument("", "Memory ID to update") .argument("", "New text for the memory") - .action(async (memoryId: string, text: string) => { + .option("--json", "Output as JSON") + .action(async (memoryId: string, text: string, opts: { json?: boolean } = {}) => { try { await provider.update(memoryId, text); + if (jsonOut(opts, { ok: true, memory: { id: memoryId, memory: text } })) return; console.log(`Memory ${memoryId} updated.`); } catch (err) { + if (jsonErr(opts, `Update failed: ${String(err)}`)) return; console.error(`Update failed: ${String(err)}`); } }); @@ -846,6 +1131,7 @@ export function registerCliCommands( .option("--user-id ", "Override user ID (with --all)") .option("--agent-id ", "Delete from agent's namespace") .option("--confirm", "Skip confirmation for bulk delete") + .option("--json", "Output as JSON") .action( async ( memoryId: string | undefined, @@ -854,6 +1140,7 @@ export function registerCliCommands( userId?: string; agentId?: string; confirm?: boolean; + json?: boolean; }, ) => { try { @@ -880,11 +1167,13 @@ export function registerCliCommands( } await provider.deleteAll(uid); + if (jsonOut(opts, { ok: true, deleted: true, id: "all", userId: uid })) return; console.log(`All memories deleted for user "${uid}".`); return; } if (!memoryId) { + if (jsonErr(opts, "Provide a memory_id or use --all to delete all memories.")) return; console.error( "Provide a memory_id or use --all to delete all memories.", ); @@ -892,8 +1181,10 @@ export function registerCliCommands( } await provider.delete(memoryId); + if (jsonOut(opts, { ok: true, deleted: true, id: memoryId })) return; console.log(`Memory ${memoryId} deleted.`); } catch (err) { + if (jsonErr(opts, `Delete failed: ${String(err)}`)) return; console.error(`Delete failed: ${String(err)}`); } }, @@ -906,15 +1197,24 @@ export function registerCliCommands( mem0 .command("status") .description("Check API connectivity and current config") - .action(async () => { + .option("--json", "Output as JSON") + .action(async (opts: { json?: boolean } = {}) => { try { const auth = readPluginAuth(); + const result = await backend.status(); + if (jsonOut(opts, { + ok: true, + mode: cfg.mode, + connected: result.connected, + userId: cfg.userId, + ...(result.url && { url: result.url }), + ...(result.error && { error: String(result.error) }), + })) return; console.log(`Mode: ${cfg.mode}`); console.log(`User ID: ${cfg.userId}`); console.log(`Config: ${OPENCLAW_CONFIG_FILE}`); console.log(""); - const result = await backend.status(); if (result.connected) { console.log("Connected to Mem0"); } else { @@ -927,6 +1227,7 @@ export function registerCliCommands( console.log(`Error: ${String(result.error)}`); } } catch (err) { + if (jsonErr(opts, `Status check failed: ${String(err)}`)) return; console.error(`Status check failed: ${String(err)}`); } }); @@ -1011,19 +1312,14 @@ export function registerCliCommands( return values[field]; } - /** Redact a secret value for display: first 4 + ... + last 4 */ - function redact(value: string): string { - if (value.length <= 8) return value.slice(0, 2) + "***"; - return value.slice(0, 4) + "..." + value.slice(-4); - } - /** Format a config value for display (redacts secrets). */ function displayValue(field: string, value: unknown): string { if (value === undefined || value === null || value === "") { return "(not set)"; } if (SECRET_KEYS.has(field) && typeof value === "string") { - return redact(value); + const redacted = redactSecrets({ v: value }, new Set(["v"])); + return redacted.v as string; } return String(value); } @@ -1031,7 +1327,41 @@ export function registerCliCommands( configCmd .command("show") .description("Show current configuration") - .action(() => { + .option("--json", "Output as JSON") + .action((opts: { json?: boolean } = {}) => { + if (opts.json) { + const showEntries: Array<[string, string]> = [ + ["mode", "mode"], + ["user_id", "userId"], + ["auto_recall", "autoRecall"], + ["auto_capture", "autoCapture"], + ["top_k", "topK"], + ]; + if (cfg.mode === "platform") { + showEntries.push(["api_key", "apiKey"], ["email", "userEmail"]); + } else { + showEntries.push( + ["embedder_provider", "oss.embedder.provider"], + ["embedder_model", "oss.embedder.config.model"], + ["embedder_key", "oss.embedder.config.apiKey"], + ["llm_provider", "oss.llm.provider"], + ["llm_model", "oss.llm.config.model"], + ["llm_key", "oss.llm.config.apiKey"], + ["vector_provider", "oss.vectorStore.provider"], + ["history_db_path", "oss.historyDbPath"], + ["disable_history", "oss.disableHistory"], + ); + } + const configObj: Record = {}; + for (const [displayKey, field] of showEntries) { + const val = getConfigValue(field); + configObj[displayKey] = SECRET_KEYS.has(field) && typeof val === "string" + ? (redactSecrets({ v: val }, new Set(["v"])).v) + : val; + } + jsonOut(opts, { ok: true, config: configObj }); + return; + } // Display order: general first, then mode-specific const entries: Array<[string, string]> = [ ["mode", "mode"], @@ -1102,15 +1432,18 @@ export function registerCliCommands( .command("get") .description("Get a config value") .argument("", "Config key (e.g. user_id, api_key, llm_model)") - .action((key: string) => { + .option("--json", "Output as JSON") + .action((key: string, opts: { json?: boolean } = {}) => { const field = resolveConfigKey(key); if (!field) { + if (jsonErr(opts, `Unknown config key: ${key}`)) return; console.error( `Unknown config key: ${key}`, ); return; } const value = getConfigValue(field); + if (jsonOut(opts, { ok: true, key, value })) return; console.log(displayValue(field, value)); }); @@ -1119,9 +1452,11 @@ export function registerCliCommands( .description("Set a config value") .argument("", "Config key (e.g. user_id, api_key, llm_model)") .argument("", "New value") - .action((key: string, rawValue: string) => { + .option("--json", "Output as JSON") + .action((key: string, rawValue: string, opts: { json?: boolean } = {}) => { const field = resolveConfigKey(key); if (!field) { + if (jsonErr(opts, `Unknown config key: ${key}`)) return; console.error( `Unknown config key: ${key}`, ); @@ -1138,6 +1473,7 @@ export function registerCliCommands( } else if (INTEGER_KEYS.has(field)) { const parsed = parseInt(rawValue, 10); if (isNaN(parsed)) { + if (jsonErr(opts, `Invalid integer value: ${rawValue}`)) return; console.error(`Invalid integer value: ${rawValue}`); return; } @@ -1150,6 +1486,7 @@ export function registerCliCommands( } else { writePluginAuth({ [field]: value } as PluginAuthConfig); } + if (jsonOut(opts, { ok: true, key, value })) return; console.log( `${key} = ${displayValue(field, value)}`, ); @@ -1165,10 +1502,11 @@ export function registerCliCommands( .argument("", "Path to JSON file containing memories") .option("--user-id ", "Override user ID for all imported memories") .option("--agent-id ", "Override agent ID for all imported memories") + .option("--json", "Output as JSON") .action( async ( file: string, - opts: { userId?: string; agentId?: string }, + opts: { userId?: string; agentId?: string; json?: boolean }, ) => { try { let data: unknown; @@ -1202,11 +1540,13 @@ export function registerCliCommands( } } + if (jsonOut(opts, { ok: true, imported: added, failed, total: items.length })) return; console.log(`Imported ${added} memories.`); if (failed) { console.error(`${failed} memories failed to import.`); } } catch (err) { + if (jsonErr(opts, `Import failed: ${String(err)}`)) return; console.error(`Import failed: ${String(err)}`); } }, @@ -1223,7 +1563,8 @@ export function registerCliCommands( eventCmd .command("list") .description("List recent background events") - .action(async () => { + .option("--json", "Output as JSON") + .action(async (opts: { json?: boolean } = {}) => { try { if (!backend || cfg.mode === "open-source") { console.log("Event tracking is only available in platform mode."); @@ -1234,6 +1575,7 @@ export function registerCliCommands( console.log("No events found."); return; } + if (jsonOut(opts, { ok: true, events: results })) return; // Table header const header = [ @@ -1263,6 +1605,7 @@ export function registerCliCommands( } console.log(`\n${results.length} event${results.length !== 1 ? "s" : ""}`); } catch (err) { + if (jsonErr(opts, `Failed to list events: ${String(err)}`)) return; console.error(`Failed to list events: ${String(err)}`); } }); @@ -1271,13 +1614,15 @@ export function registerCliCommands( .command("status") .description("Get status of a specific background event") .argument("", "Event ID to check") - .action(async (eventId: string) => { + .option("--json", "Output as JSON") + .action(async (eventId: string, opts: { json?: boolean } = {}) => { try { if (!backend || cfg.mode === "open-source") { console.log("Event tracking is only available in platform mode."); return; } const ev = await backend.getEvent(eventId); + if (jsonOut(opts, { ok: true, event: ev })) return; const status = String(ev.status ?? "—"); const evType = String(ev.event_type ?? "—"); @@ -1313,6 +1658,7 @@ export function registerCliCommands( } } } catch (err) { + if (jsonErr(opts, `Failed to get event: ${String(err)}`)) return; console.error(`Failed to get event: ${String(err)}`); } }); @@ -1347,7 +1693,28 @@ export function registerCliCommands( }; if (opts.json) { - console.log(JSON.stringify({ commands }, null, 2)); + const detailed = { + commands: { + memory: { + search: { description: "Query your memory store", flags: { "--top-k ": "Max results", "--scope ": "Memory scope", "--user-id ": "Override user ID", "--agent-id ": "Agent namespace", "--json": "JSON output" } }, + add: { description: "Add a memory from text", flags: { "--user-id ": "Override user ID", "--agent-id ": "Agent namespace", "--json": "JSON output" } }, + get: { description: "Get a specific memory by ID", flags: { "--json": "JSON output" } }, + list: { description: "List memories", flags: { "--user-id ": "Override user ID", "--top-k ": "Max results", "--json": "JSON output" } }, + update: { description: "Update a memory's text", flags: { "--json": "JSON output" } }, + delete: { description: "Delete a memory or all memories", flags: { "--all": "Delete all", "--confirm": "Skip confirmation", "--json": "JSON output" } }, + import: { description: "Import memories from JSON file", flags: { "--user-id ": "Override user ID", "--json": "JSON output" } }, + }, + management: { + init: { description: "Set up Mem0", flags: { "--mode ": "platform or open-source", "--api-key ": "API key", "--email ": "Email login", "--oss-llm

": "LLM provider", "--oss-embedder

": "Embedder", "--oss-vector

": "Vector store", "--json": "JSON output" } }, + status: { description: "Check connectivity", flags: { "--json": "JSON output" } }, + config: { description: "Manage configuration (show, get, set)", flags: { "--json": "JSON output" } }, + event: { description: "Manage background events (list, status)", flags: { "--json": "JSON output" } }, + dream: { description: "Run memory consolidation", flags: { "--dry-run": "Show inventory only", "--json": "JSON output" } }, + help: { description: "Show help", flags: { "--json": "JSON output" } }, + }, + }, + }; + process.stdout.write(JSON.stringify(detailed, null, 2) + "\n"); return; } @@ -1379,7 +1746,8 @@ export function registerCliCommands( "--dry-run", "Show memory inventory without running consolidation", ) - .action(async (opts: { dryRun?: boolean }) => { + .option("--json", "Output as JSON") + .action(async (opts: { dryRun?: boolean; json?: boolean }) => { try { const uid = cfg.userId; const memories = await provider.getAll({ @@ -1389,6 +1757,7 @@ export function registerCliCommands( const count = Array.isArray(memories) ? memories.length : 0; if (count === 0) { + if (jsonOut(opts, { ok: true, count: 0, message: "No memories to consolidate." })) return; console.log("No memories to consolidate."); return; } @@ -1401,6 +1770,17 @@ export function registerCliCommands( "uncategorized"; catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1); } + + if (opts.dryRun && opts.json) { + jsonOut(opts, { ok: true, count, categories: Object.fromEntries(catCounts) }); + return; + } + + if (opts.json && !opts.dryRun) { + jsonOut(opts, { ok: true, count, message: `${count} memories available for consolidation` }); + return; + } + process.stderr.write(`\nMemory inventory for "${uid}":\n`); for (const [cat, num] of [...catCounts.entries()].sort( (a, b) => b[1] - a[1], @@ -1451,6 +1831,7 @@ export function registerCliCommands( `Dream prompt written to stdout (${fullPrompt.length} chars). Paste it into an OpenClaw session to run consolidation.\n`, ); } catch (err) { + if (jsonErr(opts, `Dream failed: ${String(err)}`)) return; console.error(`Dream failed: ${String(err)}`); } }); diff --git a/openclaw/cli/json-helpers.ts b/openclaw/cli/json-helpers.ts new file mode 100644 index 000000000..8637702ac --- /dev/null +++ b/openclaw/cli/json-helpers.ts @@ -0,0 +1,40 @@ +/** + * JSON output helpers for agent-friendly CLI commands. + */ + +function writeStdout(data: Record): void { + process.stdout.write(JSON.stringify(data, null, 2) + "\n"); +} + +export function jsonOut( + opts: { json?: boolean }, + data: Record, +): 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, + secretKeys: Set, +): Record { + 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; +} diff --git a/openclaw/cli/oss-wizard.ts b/openclaw/cli/oss-wizard.ts new file mode 100644 index 000000000..f8d693fab --- /dev/null +++ b/openclaw/cli/oss-wizard.ts @@ -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 = { + "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 } { + const def = LLM_PROVIDERS.find((p) => p.id === providerId); + if (!def) throw new Error(`Unknown LLM provider: ${providerId}`); + + const config: Record = { + 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; 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 = { 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 } { + const config: Record = {}; + + 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 {}; +} diff --git a/openclaw/config.ts b/openclaw/config.ts index f413dade5..e16ef3c7a 100644 --- a/openclaw/config.ts +++ b/openclaw/config.ts @@ -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" diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index 09a2d1105..f8c709000 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -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 ", "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 ", + "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 ", + "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" } } \ No newline at end of file diff --git a/openclaw/package.json b/openclaw/package.json index a7aa6a325..8754633b4 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -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", diff --git a/openclaw/providers.ts b/openclaw/providers.ts index 6acf6cfcd..a29795cde 100644 --- a/openclaw/providers.ts +++ b/openclaw/providers.ts @@ -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 | 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) => { 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, diff --git a/openclaw/skill-loader.ts b/openclaw/skill-loader.ts index b1224b629..616194266 100644 --- a/openclaw/skill-loader.ts +++ b/openclaw/skill-loader.ts @@ -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); } diff --git a/openclaw/skills/memory-dream/SKILL.md b/openclaw/skills/memory-dream/SKILL.md index 9224eafea..20411c161 100644 --- a/openclaw/skills/memory-dream/SKILL.md +++ b/openclaw/skills/memory-dream/SKILL.md @@ -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 diff --git a/openclaw/skills/memory-triage/SKILL.md b/openclaw/skills/memory-triage/SKILL.md index d78acffb1..dd2dcb8ca 100644 --- a/openclaw/skills/memory-triage/SKILL.md +++ b/openclaw/skills/memory-triage/SKILL.md @@ -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") ``` diff --git a/openclaw/sqlite-resilience.test.ts b/openclaw/sqlite-resilience.test.ts index 484f75390..80fb47872 100644 --- a/openclaw/sqlite-resilience.test.ts +++ b/openclaw/sqlite-resilience.test.ts @@ -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"; diff --git a/openclaw/tests/cli-commands.test.ts b/openclaw/tests/cli-commands.test.ts index 04d832b55..9dec74467 100644 --- a/openclaw/tests/cli-commands.test.ts +++ b/openclaw/tests/cli-commands.test.ts @@ -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; }; let stderrSpy: ReturnType; + let stdoutSpy: ReturnType; 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).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; + }); + }); }); diff --git a/openclaw/tests/config.test.ts b/openclaw/tests/config.test.ts index 4b630f704..712133be6 100644 --- a/openclaw/tests/config.test.ts +++ b/openclaw/tests/config.test.ts @@ -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", () => { diff --git a/openclaw/tests/json-helpers.test.ts b/openclaw/tests/json-helpers.test.ts new file mode 100644 index 000000000..1a8a26c6b --- /dev/null +++ b/openclaw/tests/json-helpers.test.ts @@ -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; + 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; + 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); + }); +}); diff --git a/openclaw/tests/oss-wizard.test.ts b/openclaw/tests/oss-wizard.test.ts new file mode 100644 index 000000000..185f9e197 --- /dev/null +++ b/openclaw/tests/oss-wizard.test.ts @@ -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"); + }); +}); diff --git a/openclaw/tests/tools.test.ts b/openclaw/tests/tools.test.ts index 1c6f843ca..d28aa94b5 100644 --- a/openclaw/tests/tools.test.ts +++ b/openclaw/tests/tools.test.ts @@ -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(); }); }); diff --git a/openclaw/tools/memory-add.ts b/openclaw/tools/memory-add.ts index c021b30ec..59d5e1709 100644 --- a/openclaw/tools/memory-add.ts +++ b/openclaw/tools/memory-add.ts @@ -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") ?? []; diff --git a/openclaw/tools/memory-search.ts b/openclaw/tools/memory-search.ts index 5c8f7f46a..1eddb88e7 100644 --- a/openclaw/tools/memory-search.ts +++ b/openclaw/tools/memory-search.ts @@ -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))];