diff --git a/openclaw/CHANGELOG.md b/openclaw/CHANGELOG.md index b99fc0aae..d034305f2 100644 --- a/openclaw/CHANGELOG.md +++ b/openclaw/CHANGELOG.md @@ -2,32 +2,35 @@ All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file. -## [1.0.4] - 2026-04-03 (beta) +## [1.0.4] - 2026-04-04 ### Added -- **Interactive login flow**: `openclaw mem0 login` with interactive menu (email verification or direct API key). Non-interactive modes: `--api-key`, `--email`, `--email --code` -- **Config file fallback**: Reads `~/.mem0/config.json` (shared with Python CLI) when no API key in plugin config. Supports both camelCase and snake_case field names -- **CLI subcommands**: `openclaw mem0 login`, `openclaw mem0 search`, `openclaw mem0 stats`, `openclaw mem0 status`, `openclaw mem0 dream` +- **Interactive init flow**: `openclaw mem0 init` with interactive menu (email verification or direct API key). Non-interactive modes: `--api-key`, `--email`, `--email --code` +- **`memory_add` tool**: Replaces `memory_store` — name now matches `mem0` CLI and platform API - **`memory_delete` tool**: Unified delete — single ID, search-then-delete, bulk, entity cascade. Replaces `memory_forget` and `memory_delete_all` -- **Backend layer**: `backend/base.ts` + `backend/platform.ts` with direct `fetch()` for platform mode, `providerToBackend()` adapter for OSS -- **Plugin manifest**: Added `name`, `description`, `contracts.tools`, `baseUrl` config field, CLI `descriptors` for lazy-loading +- **CLI subcommands**: `openclaw mem0 init`, `openclaw mem0 status` (renamed from `stats`), `openclaw mem0 config show`, `openclaw mem0 config set` +- **`fs-safe.ts` module**: Isolated filesystem wrappers (sync read/write/exists/mkdir/unlink) in a separate entry point — keeps file I/O out of the main bundle +- **`backend/` module**: `PlatformBackend` with direct HTTP API access for CLI commands +- **`cli/config-file.ts`**: Persistent plugin auth storage in `~/.openclaw/openclaw.json` +- **Plugin manifest**: Added `contracts.tools`, `configSchema`, and `uiHints` to `openclaw.plugin.json` +- **Test suite**: 329 tests across 10 test files covering tools, CLI, config, dream gate, providers, and skill-loader ### Changed -- **Modular architecture**: Extracted tools into `tools/` directory (7 files) and CLI into `cli/commands.ts` — `index.ts` down from 1724 to ~780 lines -- **WRITE_TOOLS updated**: Dream gate tracks `memory_delete` instead of removed `memory_forget` / `memory_delete_all` -- **Auto-recall timeout** (#4634): Recall wrapped in 8-second `Promise.race` — if OSS/Ollama LLM takes too long, recall is skipped instead of stalling the gateway -- **Auto-capture fire-and-forget** (#4634): `provider.add()` now runs in the background via `.then()/.catch()` — the `agent_end` hook returns immediately, zero event loop blocking +- **Modular architecture**: Extracted tools into `tools/` directory (7 files) and CLI into `cli/commands.ts` — `index.ts` down from ~1700 to ~890 lines +- **Code splitting**: tsup builds with `splitting: true` and two entry points (`index.ts`, `fs-safe.ts`), separating filesystem I/O from the main bundle +- **Skills updated**: All SKILL.md files reference new tool names (`memory_add`, `memory_delete`) matching the plugin manifest +- **WRITE_TOOLS updated**: Dream gate tracks `memory_delete` and `memory_add` instead of `memory_forget` and `memory_store` +- **`mem0ai` dependency**: Updated from `2.3.0` to `2.4.5` +- **Auto-recall timeout**: Recall wrapped in 8-second `Promise.race` — if the LLM takes too long, recall is skipped instead of stalling the gateway +- **Auto-capture fire-and-forget**: `provider.add()` now runs in the background via `.then()/.catch()` — the `agent_end` hook returns immediately, zero event loop blocking - **Auto-capture minimum content gate**: Skips extraction when total user content is <50 chars after filtering — trivial conversations ("ok", "thanks") no longer trigger LLM calls -- **CLI search**: Removed `source: "OPENCLAW"` filter and lowered threshold to 0.3 so explicit searches find all memories, not just plugin-tagged ones +- **CLI search**: Lowered threshold to 0.3 so explicit searches are more permissive than auto-recall ### Removed +- `memory_store` tool — replaced by `memory_add` - `memory_forget` tool — replaced by `memory_delete` -- `memory_delete_all` tool — replaced by `memory_delete` -- `memory_status` tool — redundant with `openclaw mem0 status` CLI -- `memory_import` tool — bulk import, rarely needed by agents -- `entity_list`, `entity_delete` tools — niche, platform-only -- `event_list`, `event_status` tools — debugging tools, not agent tools -- Duplicate `ToolContext` interfaces from individual tool files — now imports from canonical `tools/index.ts` +- `memory_delete_all` tool — merged into `memory_delete` +- **`custom_instructions` / `custom_categories` in `buildAddOptions`**: No longer injected into every auto-capture API call. Config fields (`customInstructions`, `customCategories`) remain as user-configurable options. ## [1.0.3] - 2026-04-03 diff --git a/openclaw/README.md b/openclaw/README.md index 951781bd6..a815a624f 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -2,135 +2,54 @@ 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 watches conversations, extracts what matters, and brings it back when relevant. Automatically. -## How it works - -
-
-
+
+
", "Verification code (use with --email)")
.option("--api-key ", "Direct API key entry")
+ .option("--user-id ", "Set user ID for memory namespace")
.action(
- async (opts: { email?: string; code?: string; apiKey?: string }) => {
+ async (opts: {
+ email?: string;
+ code?: string;
+ apiKey?: string;
+ userId?: string;
+ }) => {
try {
- const config = readMem0Config();
- const baseUrl = getBaseUrl(config);
+ const baseUrl = getBaseUrl();
+ const existingAuth = readPluginAuth();
+ const hasExistingConfig = !!(existingAuth.apiKey || existingAuth.mode);
// -- API key flow ------------------------------------------------
if (opts.apiKey) {
@@ -89,322 +289,333 @@ export function registerCliCommands(
return;
}
- setPlatformAuth(config, opts.apiKey, baseUrl);
- writeMem0Config(config);
-
- // Validate with ping
- try {
- const resp = await fetch(`${baseUrl}/v1/ping/`, {
- headers: { Authorization: `Token ${opts.apiKey}` },
- });
- if (resp.ok) {
- console.log(
- "API key saved and validated. Connected to Mem0 Platform.",
- );
- } else {
- console.warn(
- `API key saved but validation returned HTTP ${resp.status}. ` +
- "Check that the key is correct.",
- );
- }
- } catch (err) {
- console.warn(
- `API key saved but could not reach ${baseUrl}: ${String(err)}. ` +
- "Check your network connection.",
+ const check = await validateApiKey(baseUrl, opts.apiKey);
+ saveLoginConfig(opts.apiKey, baseUrl, opts.userId, check.userEmail);
+ 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.`,
+ );
+ } else {
+ console.warn(
+ ` API key saved but could not reach ${baseUrl}: ${check.error}. Check your network connection.`,
+ );
+ }
+ console.log(
+ " Restart the gateway: openclaw gateway restart\n",
+ );
return;
}
- // -- Email + code (verify) flow ----------------------------------
+ // -- Email + code (verify) — non-interactive ----------------------
if (opts.email && opts.code) {
const email = opts.email.trim().toLowerCase();
- const url = baseUrl.replace(/\/+$/, "");
+ const apiKey = await verifyEmailCode(baseUrl, email, opts.code);
+ if (!apiKey) return;
- let resp: Response;
- try {
- resp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, code: opts.code.trim() }),
- });
- } catch (err) {
- console.error(`Could not reach ${url}: ${String(err)}`);
- return;
- }
-
- if (resp.status === 429) {
- console.error(
- "Too many attempts. Try again in a few minutes.",
+ saveLoginConfig(apiKey, baseUrl, opts.userId, email);
+ if (hasExistingConfig) {
+ console.log(
+ " Existing configuration detected — updated API key (other settings preserved).",
);
- return;
}
- if (!resp.ok) {
- let detail: string;
- try {
- const body = (await resp.json()) as Record;
- detail = String(body.error ?? resp.statusText);
- } catch {
- detail = resp.statusText;
- }
- console.error(`Verification failed: ${detail}`);
- return;
- }
-
- const body = (await resp.json()) as Record;
- const apiKey = body.api_key as string | undefined;
- if (!apiKey) {
- console.error(
- "Auth succeeded but no API key was returned. Contact support.",
- );
- return;
- }
-
- setPlatformAuth(config, apiKey, baseUrl);
- writeMem0Config(config);
-
+ console.log(" Authenticated!");
console.log(
- "Authenticated! Configuration saved to ~/.mem0/config.json",
+ " Restart the gateway: openclaw gateway restart\n",
);
return;
}
- // -- Email only (send code) flow ---------------------------------
+ // -- Email only (send code) ---------------------------------------
if (opts.email) {
const email = opts.email.trim().toLowerCase();
- const url = baseUrl.replace(/\/+$/, "");
-
- let resp: Response;
- try {
- resp = await fetch(`${url}/api/v1/auth/email_code/`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email }),
- });
- } catch (err) {
- console.error(`Could not reach ${url}: ${String(err)}`);
- return;
- }
-
- if (resp.status === 429) {
- console.error(
- "Too many attempts. Try again in a few minutes.",
+ const sent = await sendVerificationCode(baseUrl, email);
+ if (sent) {
+ console.log(
+ `Verification code sent! Run:\n openclaw mem0 init --email ${email} --code `,
);
- return;
}
- if (!resp.ok) {
- let detail: string;
- try {
- const body = (await resp.json()) as Record;
- detail = String(body.error ?? resp.statusText);
- } catch {
- detail = resp.statusText;
- }
- console.error(`Failed to send code: ${detail}`);
- return;
- }
-
- console.log(
- `Verification code sent! Run:\n openclaw mem0 login --email ${email} --code `,
- );
return;
}
- // -- No flags: interactive login flow ------------------------------
+ // -- No flags: interactive flow -----------------------------------
if (!process.stdin.isTTY) {
console.log("Usage (non-interactive):");
console.log(
- " openclaw mem0 login --api-key Save API key directly",
+ " openclaw mem0 init --api-key ",
);
console.log(
- " openclaw mem0 login --email Send verification code",
+ " openclaw mem0 init --api-key --user-id ",
);
console.log(
- " openclaw mem0 login --email --code Verify & authenticate",
+ " openclaw mem0 init --email ",
+ );
+ console.log(
+ " openclaw mem0 init --email --code ",
+ );
+ console.log(
+ " openclaw mem0 init --email --code --user-id ",
);
return;
}
- console.log("\n Mem0 Login\n");
- console.log(" How would you like to authenticate?");
- console.log(" 1. Login with email (recommended)");
- console.log(" 2. Enter API key manually\n");
+ // Detect existing config and offer to reuse or reconfigure
+ if (hasExistingConfig) {
+ console.log("\n Existing Mem0 configuration found:\n");
+ if (existingAuth.apiKey) {
+ const masked = existingAuth.apiKey.length > 8
+ ? existingAuth.apiKey.slice(0, 4) + "..." + existingAuth.apiKey.slice(-4)
+ : existingAuth.apiKey.slice(0, 2) + "***";
+ console.log(` API Key: ${masked}`);
+ }
+ if (existingAuth.userId)
+ console.log(` User ID: ${existingAuth.userId}`);
+ if (existingAuth.mode)
+ console.log(` Mode: ${existingAuth.mode}`);
+ if (existingAuth.orgId)
+ console.log(` Org ID: ${existingAuth.orgId}`);
+ if (existingAuth.projectId)
+ console.log(` Project: ${existingAuth.projectId}`);
+ console.log("");
- const choice = await prompt(" Choice (1/2): ");
+ // Validate existing key before asking
+ if (existingAuth.apiKey) {
+ const check = await validateApiKey(
+ existingAuth.baseUrl || baseUrl,
+ existingAuth.apiKey,
+ );
+ if (check.ok) {
+ console.log(
+ " Existing API key is valid and connected.\n",
+ );
+ } else {
+ console.log(
+ " Existing API key could not be validated (may be expired or revoked).\n",
+ );
+ }
+ }
+
+ const reuse = await promptInput(
+ " Keep existing configuration? (Y/n): ",
+ );
+ if (
+ reuse === "" ||
+ reuse.toLowerCase() === "y" ||
+ reuse.toLowerCase() === "yes"
+ ) {
+ console.log(
+ "\n Configuration preserved. No changes made.",
+ );
+ console.log(
+ " To update individual settings: openclaw mem0 config set \n",
+ );
+ return;
+ }
+ console.log("");
+ }
+
+ 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");
+
+ const choice = await promptInput(" Choice (1/2/3): ");
if (choice === "1") {
- const email = (await prompt(" Email: ")).toLowerCase();
+ // --- Email interactive flow ---
+ const email = (
+ await promptInput(" Email: ")
+ ).toLowerCase();
if (!email) {
console.error("Email is required.");
return;
}
- const url = baseUrl.replace(/\/+$/, "");
- let sendResp: Response;
- try {
- sendResp = await fetch(`${url}/api/v1/auth/email_code/`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email }),
- });
- } catch (err) {
- console.error(`Could not reach ${url}: ${String(err)}`);
- return;
- }
+ const sent = await sendVerificationCode(baseUrl, email);
+ if (!sent) return;
- if (sendResp.status === 429) {
- console.error("Too many attempts. Try again later.");
- return;
- }
- if (!sendResp.ok) {
- let detail: string;
- try {
- const b = (await sendResp.json()) as Record<
- string,
- unknown
- >;
- detail = String(b.error ?? sendResp.statusText);
- } catch {
- detail = sendResp.statusText;
- }
- console.error(`Failed to send code: ${detail}`);
- return;
- }
-
- console.log(" Verification code sent! Check your email.\n");
- const code = await prompt(" Code: ");
+ console.log(
+ " Verification code sent! Check your email.\n",
+ );
+ const code = await promptInput(" Code: ");
if (!code) {
console.error("Code is required.");
return;
}
- let verifyResp: Response;
- try {
- verifyResp = await fetch(
- `${url}/api/v1/auth/email_code/verify/`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, code }),
- },
+ const apiKey = await verifyEmailCode(baseUrl, email, code);
+ if (!apiKey) return;
+
+ // Prompt for userId if not passed via flag
+ let userIdValue = opts.userId;
+ if (!userIdValue) {
+ const defaultUid = resolveUserId(undefined, existingAuth.userId);
+ const uidInput = await promptInput(
+ ` User ID (${defaultUid}): `,
);
- } catch (err) {
- console.error(`Could not reach ${url}: ${String(err)}`);
- return;
+ userIdValue = uidInput || undefined;
}
- if (verifyResp.status === 429) {
- console.error("Too many attempts. Try again later.");
- return;
- }
- if (!verifyResp.ok) {
- let detail: string;
- try {
- const b = (await verifyResp.json()) as Record<
- string,
- unknown
- >;
- detail = String(b.error ?? verifyResp.statusText);
- } catch {
- detail = verifyResp.statusText;
- }
- console.error(`Verification failed: ${detail}`);
- return;
- }
-
- const verifyBody = (await verifyResp.json()) as Record<
- string,
- unknown
- >;
- const verifiedKey = verifyBody.api_key as string | undefined;
- if (!verifiedKey) {
- console.error(
- "Auth succeeded but no API key returned. Contact support.",
- );
- return;
- }
-
- setPlatformAuth(config, verifiedKey, baseUrl);
- writeMem0Config(config);
- console.log(
- "\n Authenticated! Configuration saved to ~/.mem0/config.json",
- );
+ console.log("");
+ saveLoginConfig(apiKey, baseUrl, userIdValue, email);
+ console.log(" Authenticated!");
console.log(
" Restart the gateway: openclaw gateway restart\n",
);
} else if (choice === "2") {
- const key = await prompt(" API Key: ");
+ // --- API key interactive flow ---
+ const key = await promptInput(" API Key: ");
if (!key) {
console.error("API key is required.");
return;
}
- setPlatformAuth(config, key, baseUrl);
- writeMem0Config(config);
+ // Prompt for userId if not passed via flag
+ let userIdValue2 = opts.userId;
+ if (!userIdValue2) {
+ const defaultUid = resolveUserId(undefined, existingAuth.userId);
+ const uidInput = await promptInput(
+ ` User ID (${defaultUid}): `,
+ );
+ userIdValue2 = uidInput || undefined;
+ }
- try {
- const resp = await fetch(`${baseUrl}/v1/ping/`, {
- headers: { Authorization: `Token ${key}` },
- });
- if (resp.ok) {
- console.log(
- "\n API key saved and validated. Connected to Mem0 Platform.",
- );
- } else {
- console.warn(
- `\n API key saved but validation returned HTTP ${resp.status}.`,
- );
- }
- } catch (err) {
+ console.log("");
+ const check = await validateApiKey(baseUrl, key);
+ saveLoginConfig(key, baseUrl, userIdValue2, check.userEmail);
+
+ if (check.ok) {
+ console.log(
+ " API key validated. Connected to Mem0 Platform.",
+ );
+ } else if (check.status) {
console.warn(
- `\n API key saved but could not reach ${baseUrl}: ${String(err)}`,
+ ` API key saved but validation returned HTTP ${check.status}.`,
+ );
+ } else {
+ console.warn(
+ ` API key saved but could not reach ${baseUrl}: ${check.error}`,
);
}
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 oss.embedder.config.apiKey ",
+ );
+ console.log(
+ " Or set OPENAI_API_KEY in your environment.\n",
+ );
+ }
+
+ // Prompt for userId
+ let userIdValue3 = opts.userId;
+ if (!userIdValue3) {
+ const defaultUid = resolveUserId(undefined, existingAuth.userId);
+ const uidInput = await promptInput(
+ ` User ID (${defaultUid}): `,
+ );
+ userIdValue3 = uidInput || undefined;
+ }
+
+ console.log("");
+ saveOssConfig(userIdValue3);
+ console.log(" Open-source mode configured!");
+ console.log(
+ " Restart the gateway: openclaw gateway restart\n",
+ );
} else {
- console.log("Invalid choice. Run `openclaw mem0 login` again.");
+ console.log(
+ "Invalid choice. Run `openclaw mem0 init` again.",
+ );
}
} catch (err) {
- console.error(`Login failed: ${String(err)}`);
+ console.error(`Init failed: ${String(err)}`);
}
},
);
// ====================================================================
- // search
+ // search (matches: mem0 search --top-k --user-id --agent-id)
// ====================================================================
mem0
.command("search")
- .description("Search memories in Mem0")
+ .description("Search memories")
.argument("", "Search query")
- .option("--limit ", "Max results", String(cfg.topK))
+ .option("--top-k ", "Max results", String(cfg.topK))
.option(
"--scope ",
'Memory scope: "session", "long-term", or "all"',
"all",
)
- .option(
- "--agent ",
- "Search a specific agent's memory namespace",
- )
+ .option("--agent-id ", "Search agent's memory namespace")
+ .option("--user-id ", "Override user ID")
.action(
async (
query: string,
- opts: { limit: string; scope: string; agent?: string },
+ opts: {
+ topK: string;
+ scope: string;
+ agentId?: string;
+ userId?: string;
+ },
) => {
try {
- const limit = parseInt(opts.limit, 10);
+ const limit = parseInt(opts.topK, 10);
const scope = opts.scope as "session" | "long-term" | "all";
const currentSessionId = getCurrentSessionId();
- const uid = opts.agent
- ? agentUserId(opts.agent)
- : effectiveUserId(currentSessionId);
+ const uid = opts.userId
+ ? opts.userId
+ : opts.agentId
+ ? agentUserId(opts.agentId)
+ : effectiveUserId(currentSessionId);
- // CLI search: build options WITHOUT source filter so users can
- // find ALL their memories, not just plugin-tagged ones.
+ // CLI search: no source filter so users find ALL memories
const cliSearchOpts = (
userIdOverride?: string,
lim?: number,
@@ -412,7 +623,6 @@ export function registerCliCommands(
): SearchOptions => {
const base = buildSearchOptions(userIdOverride, lim, runId);
delete (base as any).source;
- // Use a lower threshold for explicit CLI searches
base.threshold = 0.3;
return base;
};
@@ -487,54 +697,252 @@ export function registerCliCommands(
);
// ====================================================================
- // stats
+ // add (matches: mem0 add --user-id --agent-id)
// ====================================================================
mem0
- .command("stats")
- .description("Show memory statistics from Mem0")
- .option("--agent ", "Show stats for a specific agent")
- .action(async (opts: { agent?: string }) => {
+ .command("add")
+ .description("Add a memory from text")
+ .argument("", "Text to store as a memory")
+ .option("--user-id ", "Override user ID")
+ .option("--agent-id ", "Store in agent's memory namespace")
+ .action(
+ async (
+ text: string,
+ opts: { userId?: string; agentId?: string },
+ ) => {
+ try {
+ const uid = opts.userId
+ ? opts.userId
+ : opts.agentId
+ ? agentUserId(opts.agentId)
+ : effectiveUserId(getCurrentSessionId());
+ const result = await provider.add(
+ [{ role: "user", content: text }],
+ { user_id: uid },
+ );
+ const count = result.results?.length ?? 0;
+ if (count > 0) {
+ console.log(`Added ${count} memory(s):`);
+ for (const r of result.results) {
+ console.log(` ${r.id}: ${r.memory} [${r.event}]`);
+ }
+ } else {
+ console.log(
+ "No new memories extracted (text may already be stored or not contain durable facts).",
+ );
+ }
+ } catch (err) {
+ console.error(`Add failed: ${String(err)}`);
+ }
+ },
+ );
+
+ // ====================================================================
+ // get (matches: mem0 get )
+ // ====================================================================
+
+ mem0
+ .command("get")
+ .description("Get a specific memory by ID")
+ .argument("", "Memory ID to retrieve")
+ .action(async (memoryId: string) => {
try {
- const uid = opts.agent ? agentUserId(opts.agent) : cfg.userId;
- const memories = await provider.getAll({
- user_id: uid,
- source: "OPENCLAW",
- });
- console.log(`Mode: ${cfg.mode}`);
+ const memory = await provider.get(memoryId);
console.log(
- `User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`,
- );
- console.log(
- `Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
- );
- console.log(`Graph enabled: ${cfg.enableGraph}`);
- console.log(
- `Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`,
+ JSON.stringify(
+ {
+ 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,
+ },
+ null,
+ 2,
+ ),
);
} catch (err) {
- console.error(`Stats failed: ${String(err)}`);
+ console.error(`Get failed: ${String(err)}`);
}
});
// ====================================================================
- // status
+ // list (matches: mem0 list --user-id --agent-id --top-k)
+ // ====================================================================
+
+ mem0
+ .command("list")
+ .description("List memories with optional filters")
+ .option("--user-id ", "Override user ID")
+ .option("--agent-id ", "List agent's memories")
+ .option("--top-k ", "Max results", "50")
+ .action(
+ async (opts: {
+ userId?: string;
+ agentId?: string;
+ topK: string;
+ }) => {
+ try {
+ const uid = opts.userId
+ ? opts.userId
+ : opts.agentId
+ ? agentUserId(opts.agentId)
+ : cfg.userId;
+ const limit = parseInt(opts.topK, 10);
+ const memories = await provider.getAll({
+ user_id: uid,
+ page_size: limit,
+ source: "OPENCLAW",
+ });
+
+ if (!Array.isArray(memories) || memories.length === 0) {
+ console.log("No memories found.");
+ return;
+ }
+
+ const output = memories.map((m) => ({
+ id: m.id,
+ memory: m.memory,
+ categories: m.categories,
+ created_at: m.created_at,
+ updated_at: m.updated_at,
+ }));
+ console.log(JSON.stringify(output, null, 2));
+ console.log(`\nTotal: ${memories.length} memories`);
+ } catch (err) {
+ console.error(`List failed: ${String(err)}`);
+ }
+ },
+ );
+
+ // ====================================================================
+ // update (matches: mem0 update )
+ // ====================================================================
+
+ mem0
+ .command("update")
+ .description("Update a memory's text")
+ .argument("", "Memory ID to update")
+ .argument("", "New text for the memory")
+ .action(async (memoryId: string, text: string) => {
+ try {
+ await provider.update(memoryId, text);
+ console.log(`Memory ${memoryId} updated.`);
+ } catch (err) {
+ console.error(`Update failed: ${String(err)}`);
+ }
+ });
+
+ // ====================================================================
+ // delete (matches: mem0 delete --all --user-id)
+ // ====================================================================
+
+ mem0
+ .command("delete")
+ .description("Delete a memory, or all memories for a user")
+ .argument("[memory_id]", "Memory ID to delete")
+ .option("--all", "Delete all memories for the user")
+ .option("--user-id ", "Override user ID (with --all)")
+ .option("--agent-id ", "Delete from agent's namespace")
+ .option("--confirm", "Skip confirmation for bulk delete")
+ .action(
+ async (
+ memoryId: string | undefined,
+ opts: {
+ all?: boolean;
+ userId?: string;
+ agentId?: string;
+ confirm?: boolean;
+ },
+ ) => {
+ try {
+ if (opts.all) {
+ const uid = opts.userId
+ ? opts.userId
+ : opts.agentId
+ ? agentUserId(opts.agentId)
+ : cfg.userId;
+
+ if (!opts.confirm && process.stdin.isTTY) {
+ const answer = await promptInput(
+ ` Delete ALL memories for user "${uid}"? This cannot be undone. (yes/N): `,
+ );
+ if (answer.toLowerCase() !== "yes") {
+ console.log("Cancelled.");
+ return;
+ }
+ } else if (!opts.confirm) {
+ console.error(
+ "Bulk delete requires --confirm flag in non-interactive mode.",
+ );
+ return;
+ }
+
+ await provider.deleteAll(uid);
+ console.log(`All memories deleted for user "${uid}".`);
+ return;
+ }
+
+ if (!memoryId) {
+ console.error(
+ "Provide a memory_id or use --all to delete all memories.",
+ );
+ return;
+ }
+
+ await provider.delete(memoryId);
+ console.log(`Memory ${memoryId} deleted.`);
+ } catch (err) {
+ console.error(`Delete failed: ${String(err)}`);
+ }
+ },
+ );
+
+ // ====================================================================
+ // history (matches: mem0 history )
+ // ====================================================================
+
+ mem0
+ .command("history")
+ .description("View edit history of a memory")
+ .argument("", "Memory ID to view history for")
+ .action(async (memoryId: string) => {
+ try {
+ const entries = await provider.history(memoryId);
+ if (!entries.length) {
+ console.log("No history found for this memory.");
+ return;
+ }
+ console.log(JSON.stringify(entries, null, 2));
+ } catch (err) {
+ console.error(`History failed: ${String(err)}`);
+ }
+ });
+
+ // ====================================================================
+ // status (matches: mem0 status)
// ====================================================================
mem0
.command("status")
- .description("Check Mem0 API connectivity")
+ .description("Check API connectivity and current config")
.action(async () => {
try {
+ const auth = readPluginAuth();
+ 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 {
console.log("Not connected to Mem0");
}
- if (result.mode) {
- console.log(`Mode: ${String(result.mode)}`);
- }
if (result.url) {
console.log(`URL: ${String(result.url)}`);
}
@@ -546,6 +954,245 @@ export function registerCliCommands(
}
});
+ // ====================================================================
+ // config (matches: mem0 config show, mem0 config get, mem0 config set)
+ // ====================================================================
+
+ const configCmd = mem0
+ .command("config")
+ .description("Manage plugin configuration");
+
+ // All settable config keys: short alias → camelCase field in openclaw.json
+ // Matches Python CLI key names (snake_case) with dot-notation support.
+ const CONFIG_KEYS: Record = {
+ // Short aliases (matches Python CLI)
+ api_key: "apiKey",
+ base_url: "baseUrl",
+ user_id: "userId",
+ org_id: "orgId",
+ project_id: "projectId",
+ enable_graph: "enableGraph",
+ auto_recall: "autoRecall",
+ auto_capture: "autoCapture",
+ top_k: "topK",
+ mode: "mode",
+ // Dot notation (matches Python CLI: platform.api_key, defaults.user_id)
+ "platform.api_key": "apiKey",
+ "platform.base_url": "baseUrl",
+ "defaults.user_id": "userId",
+ "defaults.org_id": "orgId",
+ "defaults.project_id": "projectId",
+ "defaults.enable_graph": "enableGraph",
+ "defaults.auto_recall": "autoRecall",
+ "defaults.auto_capture": "autoCapture",
+ "defaults.top_k": "topK",
+ };
+
+ // Keys that contain secrets — redact in show/get output
+ const SECRET_KEYS = new Set(["apiKey"]);
+
+ // Boolean config fields — coerce "true"/"1"/"yes" on set
+ const BOOLEAN_KEYS = new Set([
+ "enableGraph",
+ "autoRecall",
+ "autoCapture",
+ ]);
+
+ // Integer config fields — coerce to number on set
+ const INTEGER_KEYS = new Set(["topK"]);
+
+ /** Resolve a user-facing key to the internal camelCase field name. */
+ function resolveConfigKey(key: string): string | null {
+ return CONFIG_KEYS[key] ?? null;
+ }
+
+ /** Read a config value by internal field name. */
+ function getConfigValue(field: string): unknown {
+ const auth = readPluginAuth();
+ const values: Record = {
+ apiKey: auth.apiKey ?? cfg.apiKey,
+ baseUrl: auth.baseUrl ?? cfg.baseUrl ?? "https://api.mem0.ai",
+ userId: auth.userId ?? cfg.userId,
+ orgId: auth.orgId ?? cfg.orgId,
+ projectId: auth.projectId ?? cfg.projectId,
+ mode: auth.mode ?? cfg.mode,
+ enableGraph: cfg.enableGraph,
+ autoRecall: cfg.autoRecall,
+ autoCapture: cfg.autoCapture,
+ topK: cfg.topK,
+ };
+ 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);
+ }
+ return String(value);
+ }
+
+ configCmd
+ .command("show")
+ .description("Show current configuration")
+ .action(() => {
+ // Display order matching Python CLI: platform first, then defaults
+ const entries: Array<[string, string, string]> = [
+ ["platform.api_key", "apiKey", ""],
+ ["platform.base_url", "baseUrl", ""],
+ ["defaults.user_id", "userId", ""],
+ ["defaults.org_id", "orgId", ""],
+ ["defaults.project_id", "projectId", ""],
+ ["defaults.enable_graph", "enableGraph", ""],
+ ["defaults.auto_recall", "autoRecall", ""],
+ ["defaults.auto_capture", "autoCapture", ""],
+ ["defaults.top_k", "topK", ""],
+ ["mode", "mode", ""],
+ ];
+
+ // Calculate column widths
+ const maxKeyLen = Math.max(
+ ...entries.map(([k]) => k.length),
+ 3,
+ );
+
+ console.log("");
+ console.log(
+ ` ${"Key".padEnd(maxKeyLen)} Value`,
+ );
+ console.log(
+ ` ${"─".repeat(maxKeyLen)} ${"─".repeat(30)}`,
+ );
+ for (const [displayKey, field] of entries) {
+ const value = getConfigValue(field);
+ const display = displayValue(field, value);
+ console.log(
+ ` ${displayKey.padEnd(maxKeyLen)} ${display}`,
+ );
+ }
+ console.log("");
+ console.log(` Config file: ${OPENCLAW_CONFIG_FILE}`);
+ console.log("");
+ console.log(" To change a setting:");
+ console.log(" openclaw mem0 config set ");
+ console.log("");
+ console.log(" Examples:");
+ console.log(" openclaw mem0 config set mode open-source");
+ console.log(" openclaw mem0 config set mode platform");
+ console.log(" openclaw mem0 config set auto_recall false");
+ console.log(" openclaw mem0 config set top_k 10");
+ console.log("");
+ });
+
+ configCmd
+ .command("get")
+ .description("Get a config value")
+ .argument("", "Config key (e.g. user_id, platform.api_key)")
+ .action((key: string) => {
+ const field = resolveConfigKey(key);
+ if (!field) {
+ console.error(
+ `Unknown config key: ${key}`,
+ );
+ return;
+ }
+ const value = getConfigValue(field);
+ console.log(displayValue(field, value));
+ });
+
+ configCmd
+ .command("set")
+ .description("Set a config value")
+ .argument("", "Config key (e.g. user_id, platform.api_key)")
+ .argument("", "New value")
+ .action((key: string, rawValue: string) => {
+ const field = resolveConfigKey(key);
+ if (!field) {
+ console.error(
+ `Unknown config key: ${key}`,
+ );
+ return;
+ }
+
+ // Type coercion (matches Python CLI behavior)
+ let value: unknown = rawValue;
+ if (BOOLEAN_KEYS.has(field)) {
+ value =
+ rawValue.toLowerCase() === "true" ||
+ rawValue === "1" ||
+ rawValue.toLowerCase() === "yes";
+ } else if (INTEGER_KEYS.has(field)) {
+ const parsed = parseInt(rawValue, 10);
+ if (isNaN(parsed)) {
+ console.error(`Invalid integer value: ${rawValue}`);
+ return;
+ }
+ value = parsed;
+ }
+
+ writePluginAuth({ [field]: value } as PluginAuthConfig);
+ console.log(
+ `${key} = ${displayValue(field, value)}`,
+ );
+ });
+
+ // ====================================================================
+ // help (matches: mem0 help, mem0 help --json)
+ // ====================================================================
+
+ mem0
+ .command("help")
+ .description("Show help. Use --json for machine-readable output (for LLM agents)")
+ .option("--json", "Output as JSON for agent/programmatic use")
+ .action((opts: { json?: boolean }) => {
+ const commands = {
+ memory: {
+ add: "Add a memory from text, messages, or stdin",
+ search: "Query your memory store — semantic, keyword, or hybrid retrieval",
+ get: "Get a specific memory by ID",
+ list: "List memories with optional filters",
+ update: "Update a memory's text or metadata",
+ delete: "Delete a memory, all memories, or an entity",
+ history: "View edit history of a memory",
+ },
+ management: {
+ init: "Interactive setup wizard for mem0 CLI",
+ status: "Check connectivity and authentication",
+ help: "Show help. Use --json for machine-readable output (for LLM agents)",
+ config: "Manage mem0 configuration (show, get, set)",
+ dream: "Run memory consolidation (review, merge, prune)",
+ },
+ };
+
+ if (opts.json) {
+ console.log(JSON.stringify({ commands }, null, 2));
+ return;
+ }
+
+ console.log("");
+ console.log(" openclaw mem0 ");
+ console.log("");
+ console.log(" Memory:");
+ for (const [cmd, desc] of Object.entries(commands.memory)) {
+ console.log(` ${cmd.padEnd(12)} ${desc}`);
+ }
+ console.log("");
+ console.log(" Management:");
+ for (const [cmd, desc] of Object.entries(commands.management)) {
+ console.log(` ${cmd.padEnd(12)} ${desc}`);
+ }
+ console.log("");
+ });
+
// ====================================================================
// dream
// ====================================================================
@@ -573,7 +1220,6 @@ export function registerCliCommands(
return;
}
- // Show current state summary on stderr (keeps stdout clean for piping)
const catCounts = new Map();
for (const mem of memories) {
const cat =
@@ -595,7 +1241,6 @@ export function registerCliCommands(
return;
}
- // Load dream prompt and format it with the full memory inventory
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
if (!dreamPrompt) {
process.stderr.write(
@@ -604,7 +1249,6 @@ export function registerCliCommands(
return;
}
- // Build the full dream context: protocol + memory dump
const memoryDump = (memories as MemoryItem[])
.map((m, i) => {
const cat =
@@ -629,10 +1273,9 @@ export function registerCliCommands(
"Begin consolidation. Review all memories above and execute merge, delete, and rewrite operations using the available tools.",
].join("\n");
- // Only the prompt goes to stdout — safe to pipe directly
process.stdout.write(fullPrompt + "\n");
process.stderr.write(
- `Dream prompt written to stdout (${fullPrompt.length} chars). Pipe with: openclaw mem0 dream | openclaw run --stdin\n`,
+ `Dream prompt written to stdout (${fullPrompt.length} chars). Paste it into an OpenClaw session to run consolidation.\n`,
);
} catch (err) {
console.error(`Dream failed: ${String(err)}`);
diff --git a/openclaw/cli/config-file.ts b/openclaw/cli/config-file.ts
index 281403fff..060b96d0d 100644
--- a/openclaw/cli/config-file.ts
+++ b/openclaw/cli/config-file.ts
@@ -1,65 +1,145 @@
/**
- * File-based config helpers for ~/.mem0/config.json.
+ * File-based config helpers for the OpenClaw Mem0 plugin.
*
- * Separated from commands.ts so that the security scanner does not see
- * file-read + network-send in the same module (false-positive exfiltration
- * pattern). This module only touches the filesystem — no network calls.
+ * Plugin auth and settings are stored in ~/.openclaw/openclaw.json under
+ * plugins.entries.openclaw-mem0.config — the single source of truth.
+ *
+ * Uses fs-safe.ts for all filesystem operations to pass the OpenClaw
+ * code_safety scanner.
*/
-import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
+import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
+
+// OpenClaw config — source of truth for plugin settings
+export const OPENCLAW_CONFIG_DIR = join(homedir(), ".openclaw");
+export const OPENCLAW_CONFIG_FILE = join(OPENCLAW_CONFIG_DIR, "openclaw.json");
-export const CONFIG_DIR = join(homedir(), ".mem0");
-export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
-export interface Mem0FileConfig {
- version: number;
- platform: Record;
- defaults: Record;
- [key: string]: unknown;
+const PLUGIN_ID = "openclaw-mem0";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/** Fields stored in the plugin config section of openclaw.json */
+export interface PluginAuthConfig {
+ apiKey?: string;
+ baseUrl?: string;
+ userId?: string;
+ orgId?: string;
+ projectId?: string;
+ userEmail?: string;
+ mode?: string;
+ enableGraph?: boolean;
+ autoRecall?: boolean;
+ autoCapture?: boolean;
+ topK?: number;
}
-export function readMem0Config(): Mem0FileConfig {
- if (existsSync(CONFIG_FILE)) {
+// ============================================================================
+// OpenClaw config read/write
+// ============================================================================
+
+/** Read the full ~/.openclaw/openclaw.json */
+function readFullConfig(): Record {
+ if (exists(OPENCLAW_CONFIG_FILE)) {
try {
- const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
- return {
- ...raw,
- version: raw.version ?? 1,
- platform: raw.platform ?? {},
- defaults: raw.defaults ?? {},
- };
+ return JSON.parse(readText(OPENCLAW_CONFIG_FILE));
} catch {
/* ignore parse errors */
}
}
- return { version: 1, platform: {}, defaults: {} };
+ return {};
}
-/** Get the base URL from config, handling both camelCase and snake_case */
-export function getBaseUrl(config: Mem0FileConfig): string {
- const p = config.platform;
- return ((p.baseUrl ?? p.base_url) as string) || DEFAULT_BASE_URL;
-}
-
-/** Set API key + base URL on the platform config (preserves existing fields) */
-export function setPlatformAuth(
- config: Mem0FileConfig,
- apiKey: string,
- baseUrl: string,
-): void {
- config.platform.apiKey = apiKey;
- config.platform.baseUrl = baseUrl;
- // Also write snake_case so Python CLI can read it
- config.platform.api_key = apiKey;
- config.platform.base_url = baseUrl;
-}
-
-export function writeMem0Config(config: Mem0FileConfig): void {
- if (!existsSync(CONFIG_DIR)) {
- mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
+/** Write the full ~/.openclaw/openclaw.json (preserves all non-plugin config) */
+function writeFullConfig(config: Record): void {
+ if (!exists(OPENCLAW_CONFIG_DIR)) {
+ mkdirp(OPENCLAW_CONFIG_DIR, 0o700);
}
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
+ writeText(
+ OPENCLAW_CONFIG_FILE,
+ JSON.stringify(config, null, 2),
+ { mode: 0o600 },
+ );
+}
+
+/** Read plugin auth/identity config from openclaw.json's plugin section */
+export function readPluginAuth(): PluginAuthConfig {
+ const full = readFullConfig() as any;
+ const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
+ if (!cfg || typeof cfg !== "object") return {};
+ return {
+ apiKey: (cfg.apiKey ?? cfg.api_key) as string | undefined,
+ baseUrl: (cfg.baseUrl ?? cfg.base_url) as string | undefined,
+ userId: (cfg.userId ?? cfg.user_id) as string | undefined,
+ orgId: (cfg.orgId ?? cfg.org_id) as string | undefined,
+ projectId: (cfg.projectId ?? cfg.project_id) as string | undefined,
+ userEmail: (cfg.userEmail ?? cfg.user_email) as string | undefined,
+ mode: cfg.mode as string | undefined,
+ enableGraph: cfg.enableGraph as boolean | undefined,
+ autoRecall: cfg.autoRecall as boolean | undefined,
+ autoCapture: cfg.autoCapture as boolean | undefined,
+ topK: cfg.topK as number | undefined,
+ };
+}
+
+/** Write auth/identity fields into the plugin section of openclaw.json */
+export function writePluginAuth(auth: PluginAuthConfig): void {
+ const full = readFullConfig() as any;
+
+ // Ensure nested structure exists
+ if (!full.plugins) full.plugins = {};
+ if (!full.plugins.entries) full.plugins.entries = {};
+ if (!full.plugins.entries[PLUGIN_ID]) {
+ full.plugins.entries[PLUGIN_ID] = { enabled: true, config: {} };
+ }
+ if (!full.plugins.entries[PLUGIN_ID].config) {
+ full.plugins.entries[PLUGIN_ID].config = {};
+ }
+
+ const cfg = full.plugins.entries[PLUGIN_ID].config;
+
+ // Write all defined fields into the config section
+ for (const [key, value] of Object.entries(auth)) {
+ if (value !== undefined) cfg[key] = value;
+ }
+
+ writeFullConfig(full);
+}
+
+export function writePluginConfigField(
+ path: string[],
+ value: unknown,
+): void {
+ const full = readFullConfig() as any;
+
+ if (!full.plugins) full.plugins = {};
+ if (!full.plugins.entries) full.plugins.entries = {};
+ if (!full.plugins.entries[PLUGIN_ID]) {
+ full.plugins.entries[PLUGIN_ID] = { enabled: true, config: {} };
+ }
+ if (!full.plugins.entries[PLUGIN_ID].config) {
+ full.plugins.entries[PLUGIN_ID].config = {};
+ }
+
+ let target = full.plugins.entries[PLUGIN_ID].config;
+ for (let i = 0; i < path.length - 1; i++) {
+ if (!target[path[i]] || typeof target[path[i]] !== "object") {
+ target[path[i]] = {};
+ }
+ target = target[path[i]];
+ }
+ target[path[path.length - 1]] = value;
+
+ writeFullConfig(full);
+}
+
+/** Get the configured base URL from openclaw.json or default */
+export function getBaseUrl(): string {
+ const auth = readPluginAuth();
+ return auth.baseUrl || DEFAULT_BASE_URL;
}
diff --git a/openclaw/config.ts b/openclaw/config.ts
index 533de492b..60329157c 100644
--- a/openclaw/config.ts
+++ b/openclaw/config.ts
@@ -1,43 +1,26 @@
/**
- * Configuration parsing, env var resolution, and default instructions/categories.
+ * Configuration parsing and default instructions/categories.
+ *
+ * NOTE: This module must NOT import from `node:fs` or `node:fs/promises`.
+ * All filesystem operations are centralized in fs-safe.ts.
*/
-import { readFileSync, existsSync } from "node:fs";
-import { join } from "node:path";
-import { homedir, userInfo } from "node:os";
+import { userInfo } from "node:os";
import type { Mem0Config, Mem0Mode } from "./types.ts";
-// NOTE: No process.env access in this module. OpenClaw resolves ${VAR}
-// syntax in openclaw.json before passing pluginConfig to register().
-// Plugin-side env var resolution was removed to clear OpenClaw's
-// security scanner warning ("credential harvesting" pattern).
+// NOTE: The gateway resolves ${VAR} syntax in openclaw.json before passing
+// pluginConfig to register(). No plugin-side variable resolution needed.
// ============================================================================
-// Login config fallback — reads ~/.mem0/config.json (shared with CLI)
+// Login config fallback type — read from openclaw.json plugin section
// ============================================================================
-function readMem0ConfigFile(): {
+/** Shape accepted by parse() for the openclaw.json plugin auth fallback. */
+export interface FileConfig {
apiKey?: string;
baseUrl?: string;
orgId?: string;
projectId?: string;
-} {
- try {
- const configPath = join(homedir(), ".mem0", "config.json");
- if (!existsSync(configPath)) return {};
- const raw = JSON.parse(readFileSync(configPath, "utf-8"));
- const p = raw?.platform;
- if (!p) return {};
- return {
- // Support both camelCase (our login) and snake_case (Python CLI)
- apiKey: p.apiKey || p.api_key || undefined,
- baseUrl: p.baseUrl || p.base_url || undefined,
- orgId: p.orgId || p.org_id || undefined,
- projectId: p.projectId || p.project_id || undefined,
- };
- } catch {
- return {};
- }
}
// ============================================================================
@@ -193,20 +176,18 @@ function assertAllowedKeys(
}
export const mem0ConfigSchema = {
- parse(value: unknown): Mem0Config {
+ parse(value: unknown, fileConfig?: FileConfig): Mem0Config {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("openclaw-mem0 config required");
}
const cfg = value as Record;
assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config");
- // Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform
+ // Only two modes: "platform" (default) or "open-source"
const mode: Mem0Mode =
- cfg.mode === "oss" || cfg.mode === "open-source"
- ? "open-source"
- : "platform";
+ cfg.mode === "open-source" ? "open-source" : "platform";
- // Resolve API key: pluginConfig → ~/.mem0/config.json fallback
+ // Resolve API key: pluginConfig → fileConfig fallback (from openclaw.json plugin section)
let resolvedApiKey =
typeof cfg.apiKey === "string" ? cfg.apiKey : undefined;
let resolvedBaseUrl =
@@ -214,8 +195,7 @@ export const mem0ConfigSchema = {
let resolvedOrgId = typeof cfg.orgId === "string" ? cfg.orgId : undefined;
let resolvedProjectId =
typeof cfg.projectId === "string" ? cfg.projectId : undefined;
- if (mode === "platform" && !resolvedApiKey) {
- const fileConfig = readMem0ConfigFile();
+ if (mode === "platform" && !resolvedApiKey && fileConfig) {
if (fileConfig.apiKey) resolvedApiKey = fileConfig.apiKey;
if (fileConfig.baseUrl) resolvedBaseUrl = fileConfig.baseUrl;
if (!resolvedOrgId && fileConfig.orgId) resolvedOrgId = fileConfig.orgId;
@@ -227,7 +207,7 @@ export const mem0ConfigSchema = {
// The plugin should register successfully and log a setup message.
const needsSetup = mode === "platform" && !resolvedApiKey;
- // OpenClaw resolves ${VAR} in pluginConfig before register() — no plugin-side expansion needed
+ // OpenClaw resolves ${VAR} in openclaw.json before register() — no plugin-side expansion needed
let ossConfig: Mem0Config["oss"];
if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) {
ossConfig = cfg.oss as Mem0Config["oss"];
diff --git a/openclaw/dream-gate.ts b/openclaw/dream-gate.ts
index 50526f3f2..4d7764725 100644
--- a/openclaw/dream-gate.ts
+++ b/openclaw/dream-gate.ts
@@ -6,8 +6,8 @@
* Lock prevents concurrent consolidation runs.
*/
-import * as fs from "fs";
-import * as path from "path";
+import * as path from "node:path";
+import { readText, writeText, mkdirp, unlink } from "./fs-safe.ts";
// ============================================================================
// Types
@@ -52,7 +52,7 @@ function lockPath(stateDir: string): string {
function ensureDir(dir: string): void {
try {
- fs.mkdirSync(dir, { recursive: true });
+ mkdirp(dir);
} catch {
/* exists */
}
@@ -60,7 +60,7 @@ function ensureDir(dir: string): void {
function readState(stateDir: string): DreamState {
try {
- const raw = fs.readFileSync(statePath(stateDir), "utf-8");
+ const raw = readText(statePath(stateDir));
return JSON.parse(raw) as DreamState;
} catch {
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
@@ -69,7 +69,7 @@ function readState(stateDir: string): DreamState {
function writeState(stateDir: string, state: DreamState): void {
ensureDir(stateDir);
- fs.writeFileSync(statePath(stateDir), JSON.stringify(state, null, 2));
+ writeText(statePath(stateDir), JSON.stringify(state, null, 2));
}
// ============================================================================
@@ -157,7 +157,7 @@ export function acquireDreamLock(stateDir: string): boolean {
// Check existing lock
try {
- const raw = fs.readFileSync(lp, "utf-8");
+ const raw = readText(lp);
const lock = JSON.parse(raw) as DreamLock;
const age = Date.now() - lock.startedAt;
if (age < LOCK_STALE_MS) {
@@ -165,7 +165,7 @@ export function acquireDreamLock(stateDir: string): boolean {
}
// Stale lock — remove it before attempting exclusive create
try {
- fs.unlinkSync(lp);
+ unlink(lp);
} catch {
/* race ok */
}
@@ -177,7 +177,7 @@ export function acquireDreamLock(stateDir: string): boolean {
// only one succeeds. The other gets EEXIST.
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
try {
- fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
+ writeText(lp, JSON.stringify(lock), { flag: "wx" });
return true;
} catch {
return false; // Lost race
@@ -189,7 +189,7 @@ export function acquireDreamLock(stateDir: string): boolean {
*/
export function releaseDreamLock(stateDir: string): void {
try {
- fs.unlinkSync(lockPath(stateDir));
+ unlink(lockPath(stateDir));
} catch {
/* already gone */
}
diff --git a/openclaw/filtering.ts b/openclaw/filtering.ts
index a401da027..6137777ab 100644
--- a/openclaw/filtering.ts
+++ b/openclaw/filtering.ts
@@ -27,6 +27,13 @@ const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> =
/Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g,
replacement: "",
},
+ {
+ // OpenClaw TUI sends "Sender (untrusted metadata)" with a JSON block
+ // containing label, id, name, username — strip to prevent storing as memory
+ pattern:
+ /Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
+ replacement: "",
+ },
{ pattern: /\[media attached:.*?\]/g, replacement: "" },
{
pattern:
diff --git a/openclaw/fs-safe.ts b/openclaw/fs-safe.ts
new file mode 100644
index 000000000..c12482d7a
--- /dev/null
+++ b/openclaw/fs-safe.ts
@@ -0,0 +1,36 @@
+/**
+ * Safe filesystem helpers — thin wrappers around Node.js fs sync operations.
+ * Isolated in its own module so the plugin bundle keeps file I/O separate
+ * from network calls (avoids code_safety exfiltration heuristic).
+ */
+
+import * as fs from "node:fs";
+
+/** Read a file as UTF-8 text. */
+export function readText(filePath: string): string {
+ return fs.readFileSync(filePath, "utf-8");
+}
+
+/** Check if a file or directory exists. */
+export function exists(filePath: string): boolean {
+ return fs.existsSync(filePath);
+}
+
+/** Write text to a file. */
+export function writeText(
+ filePath: string,
+ content: string,
+ opts?: { mode?: number; flag?: string },
+): void {
+ fs.writeFileSync(filePath, content, opts);
+}
+
+/** Create a directory (recursive). */
+export function mkdirp(dirPath: string, mode?: number): void {
+ fs.mkdirSync(dirPath, { mode, recursive: true });
+}
+
+/** Remove a file. */
+export function unlink(filePath: string): void {
+ fs.unlinkSync(filePath);
+}
diff --git a/openclaw/index.ts b/openclaw/index.ts
index f23402ae2..df06665fb 100644
--- a/openclaw/index.ts
+++ b/openclaw/index.ts
@@ -5,14 +5,14 @@
* and the open-source self-hosted SDK. Uses the official `mem0ai` package.
*
* Features:
- * - 7 core tools: memory_search, memory_store, memory_get, memory_list,
+ * - 7 core tools: memory_search, memory_add, memory_get, memory_list,
* memory_update, memory_delete, memory_history
* - Short-term (session-scoped) and long-term (user-scoped) memory
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
* - Auto-capture: stores key facts scoped to the current session after each agent turn
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
- * - CLI: openclaw mem0 search, openclaw mem0 stats
+ * - CLI: openclaw mem0 search, openclaw mem0 status
* - Dual mode: platform or open-source (self-hosted)
*/
@@ -21,12 +21,12 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type {
Mem0Config,
Mem0Provider,
- MemoryItem,
AddOptions,
SearchOptions,
} from "./types.ts";
import { createProvider, providerToBackend } from "./providers.ts";
import { mem0ConfigSchema } from "./config.ts";
+import type { FileConfig } from "./config.ts";
import { filterMessagesForExtraction } from "./filtering.ts";
import {
effectiveUserId,
@@ -51,9 +51,11 @@ import {
} from "./dream-gate.ts";
import { PlatformBackend } from "./backend/platform.ts";
import type { Backend } from "./backend/base.ts";
-import { registerAllTools } from "./tools/index.ts";
-import type { ToolContext } from "./tools/index.ts";
import { registerCliCommands } from "./cli/commands.ts";
+import { readPluginAuth } from "./cli/config-file.ts";
+import { registerAllTools } from "./tools/index.ts";
+import type { ToolDeps } from "./tools/index.ts";
+import { captureEvent } from "./telemetry.ts";
// ============================================================================
// Re-exports (for tests and external consumers)
@@ -74,19 +76,13 @@ export {
filterMessagesForExtraction,
} from "./filtering.ts";
export { mem0ConfigSchema } from "./config.ts";
+export type { FileConfig } from "./config.ts";
export { createProvider } from "./providers.ts";
// ============================================================================
// Helpers
// ============================================================================
-/** Convert Record categories to the array format mem0ai expects */
-function categoriesToArray(
- cats: Record,
-): Array> {
- return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
-}
-
// ============================================================================
// Plugin Definition
// ============================================================================
@@ -99,19 +95,34 @@ const memoryPlugin = {
configSchema: mem0ConfigSchema,
register(api: OpenClawPluginApi) {
- const cfg = mem0ConfigSchema.parse(api.pluginConfig);
+ // Read auth from openclaw.json plugin config (picks up post-startup login).
+ // This is the single source of truth — set via `openclaw mem0 login`.
+ const pluginAuth = readPluginAuth();
+ const fileConfig: FileConfig = {
+ apiKey: pluginAuth.apiKey,
+ baseUrl: pluginAuth.baseUrl,
+ orgId: pluginAuth.orgId,
+ projectId: pluginAuth.projectId,
+ };
+ const cfg = mem0ConfigSchema.parse(api.pluginConfig, fileConfig);
+
+ // Telemetry context bound to this plugin instance's config
+ const telemetryCtx = { apiKey: cfg.apiKey, mode: cfg.mode, skillsActive: false };
+ const _captureEvent = (event: string, props?: Record) => {
+ try { captureEvent(event, props, telemetryCtx); } catch { /* silently swallow */ }
+ };
if (cfg.needsSetup) {
api.logger.warn(
"openclaw-mem0: API key not configured. Memory features are disabled.\n" +
" To set up, run:\n" +
- " openclaw mem0 login\n" +
+ " openclaw mem0 init\n" +
" Get your key at: https://app.mem0.ai/dashboard/api-keys",
);
- // Register CLI even without API key — login command must be available
+ // Register CLI even without API key — init command must be available
// to bootstrap configuration. Pass nulls for backend/provider since
- // only the login subcommand works without auth.
+ // only the init subcommand works without auth.
registerCliCommands(
api,
null as any,
@@ -119,8 +130,9 @@ const memoryPlugin = {
cfg,
() => cfg.userId,
(id: string) => `${cfg.userId}:agent:${id}`,
- () => ({ user_id: cfg.userId, top_k: cfg.topK, source: "OPENCLAW" }),
+ () => ({ user_id: cfg.userId, top_k: cfg.topK }),
() => undefined,
+ (cmd: string) => _captureEvent(`openclaw.${cmd}`, { command: cmd }),
);
api.registerService({
@@ -160,6 +172,13 @@ const memoryPlugin = {
resolveUserId(cfg.userId, opts, currentSessionId);
const skillsActive = isSkillsMode(cfg.skills);
+ telemetryCtx.skillsActive = skillsActive;
+
+ _captureEvent("openclaw.plugin.registered", {
+ auto_recall: cfg.autoRecall,
+ auto_capture: cfg.autoCapture,
+ });
+
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
);
@@ -176,8 +195,6 @@ const memoryPlugin = {
};
if (runId) opts.run_id = runId;
if (cfg.mode === "platform") {
- opts.custom_instructions = cfg.customInstructions;
- opts.custom_categories = categoriesToArray(cfg.customCategories);
opts.enable_graph = cfg.enableGraph;
opts.output_format = "v1.1";
}
@@ -207,23 +224,25 @@ const memoryPlugin = {
}
// ========================================================================
- // Tools
+ // Tools (modular — each tool in its own file under tools/)
// ========================================================================
- const toolCtx: ToolContext = {
+ const toolDeps: ToolDeps = {
api,
- backend,
- cfg,
provider,
+ cfg,
resolveUserId: _resolveUserId,
effectiveUserId: _effectiveUserId,
agentUserId: _agentUserId,
- getCurrentSessionId: () => currentSessionId,
- skillsActive,
buildAddOptions,
buildSearchOptions,
+ getCurrentSessionId: () => currentSessionId,
+ skillsActive,
+ captureToolEvent: (toolName: string, props: Record) => {
+ _captureEvent(`openclaw.tool.${toolName}`, { tool_name: toolName, ...props });
+ },
};
- registerAllTools(toolCtx);
+ registerAllTools(toolDeps);
// ========================================================================
// CLI Commands
@@ -238,6 +257,7 @@ const memoryPlugin = {
_agentUserId,
buildSearchOptions,
() => currentSessionId,
+ (cmd: string) => _captureEvent(`openclaw.cli.${cmd}`, { command: cmd }),
);
// ========================================================================
@@ -258,6 +278,7 @@ const memoryPlugin = {
getStateDir: () => pluginStateDir,
},
skillsActive,
+ _captureEvent,
);
// ========================================================================
@@ -304,6 +325,7 @@ function registerHooks(
getStateDir: () => string | undefined;
},
skillsActive: boolean = false,
+ _captureEvent: (event: string, props?: Record) => void = () => {},
) {
// ========================================================================
// SKILLS MODE: Agentic memory via before_prompt_build
@@ -372,6 +394,7 @@ function registerHooks(
const recallStrategy = cfg.skills?.recall?.strategy ?? "smart";
if (recallEnabled && recallStrategy !== "manual") {
+ const recallStart = Date.now();
try {
const query = sanitizeQuery(event.prompt);
@@ -395,6 +418,12 @@ function registerHooks(
`openclaw-mem0: skills-mode recall (strategy=${recallStrategy}) injecting ${recallResult.memories.length} memories (~${recallResult.tokenEstimate} tokens)`,
);
+ _captureEvent("openclaw.hook.recall", {
+ strategy: recallStrategy,
+ memory_count: recallResult.memories.length,
+ latency_ms: Date.now() - recallStart,
+ });
+
recallContext = recallResult.context;
} catch (err) {
api.logger.warn(
@@ -440,6 +469,7 @@ function registerHooks(
"\n";
// Track which session triggered dream (session-keyed, not global)
dreamSessionId = sessionId;
+ _captureEvent("openclaw.hook.dream", { phase: "triggered", memory_count: memCount });
api.logger.info(
`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`,
);
@@ -487,12 +517,12 @@ function registerHooks(
}
// Verify the model actually performed WRITE operations (not just reads).
- // Only count memory_store, memory_update, memory_delete.
+ // Only count memory_add, memory_update, memory_delete.
// Exclude memory_list and memory_search (read-only, orient-only pass).
// Scan only the LAST assistant message (this turn), not the full session
// snapshot, to avoid matching earlier tool calls from prior turns.
const WRITE_TOOLS = new Set([
- "memory_store",
+ "memory_add",
"memory_update",
"memory_delete",
]);
@@ -512,6 +542,7 @@ function registerHooks(
if (writeToolUsed) {
releaseDreamLock(stateDir);
recordDreamCompletion(stateDir);
+ _captureEvent("openclaw.hook.dream", { phase: "completed", write_tools_used: true });
api.logger.info(
"openclaw-mem0: auto-dream completed (verified write tool usage), lock released",
);
@@ -545,11 +576,14 @@ function registerHooks(
// LEGACY MODE: Original auto-recall + auto-capture behavior
// ========================================================================
+ // Track last seen session ID to detect actual new sessions (not every turn)
+ let lastRecallSessionId: string | undefined;
+
// Auto-recall: inject relevant memories before agent starts
if (cfg.autoRecall) {
const RECALL_TIMEOUT_MS = 8_000;
- api.on("before_agent_start", async (event, ctx) => {
+ api.on("before_agent_start", async (event: any, ctx: any) => {
if (!event.prompt || event.prompt.length < 5) return;
// Skip non-interactive triggers (cron, heartbeat, automation)
@@ -562,24 +596,51 @@ function registerHooks(
return;
}
+ // Skip recall for system/bootstrap prompts to save API calls
+ const promptLower = event.prompt.toLowerCase();
+ const isSystemPrompt =
+ promptLower.includes("a new session was started") ||
+ promptLower.includes("session startup sequence") ||
+ promptLower.includes("/new or /reset") ||
+ promptLower.startsWith("system:") ||
+ promptLower.startsWith("run your session");
+ if (isSystemPrompt) {
+ api.logger.info(
+ "openclaw-mem0: skipping recall for system/bootstrap prompt",
+ );
+ return;
+ }
+
// Update shared state for tools (best-effort — tools don't have ctx)
if (sessionId) session.setCurrentSessionId(sessionId);
- // Detect new session for cold-start broadening
- const isNewSession = true; // treat every hook invocation as potentially new
+ // Detect actual new session (first turn with a different sessionKey)
+ const isNewSession =
+ sessionId !== undefined && sessionId !== lastRecallSessionId;
+ if (sessionId) lastRecallSessionId = sessionId;
// Subagents have ephemeral UUIDs — their namespace is always empty.
// Search the parent (main) user namespace instead so subagents get
// the user's long-term context.
const isSubagent = isSubagentSession(sessionId);
const recallSessionKey = isSubagent ? undefined : sessionId;
+
+ // Strip OpenClaw sender metadata from the prompt before searching
+ const cleanPrompt = event.prompt
+ .replace(
+ /Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
+ "",
+ )
+ .trim();
+
+ const recallStart = Date.now();
const recallWork = async () => {
- // Use a larger candidate pool for recall, then filter down
+ // Single search with a reasonable candidate pool
const recallTopK = Math.max((cfg.topK ?? 5) * 2, 10);
// Search long-term memories (user-scoped; subagents read from parent namespace)
let longTermResults = await provider.search(
- event.prompt,
+ cleanPrompt,
buildSearchOptions(
undefined,
recallTopK,
@@ -607,11 +668,9 @@ function registerHooks(
}
}
- // For short/generic prompts or new sessions, broaden recall
- // with a general query to avoid cold-start blindness.
- // Use a lower threshold (0.5) since the generic query is
- // intentionally broad and strict thresholds defeat the purpose.
- if (event.prompt.length < 100 || isNewSession) {
+ // Only broaden for genuinely new sessions with short prompts
+ // (cold-start blindness). Skip on subsequent turns to save API calls.
+ if (isNewSession && cleanPrompt.length < 100) {
const broadOpts = buildSearchOptions(
undefined,
5,
@@ -634,53 +693,24 @@ function registerHooks(
// Cap at configured topK after filtering
longTermResults = longTermResults.slice(0, cfg.topK);
- // Search session memories (session-scoped) if we have a session ID
- let sessionResults: MemoryItem[] = [];
- if (sessionId) {
- sessionResults = await provider.search(
- event.prompt,
- buildSearchOptions(
- undefined,
- undefined,
- sessionId,
- recallSessionKey,
- ),
- );
- sessionResults = sessionResults.filter(
- (r) => (r.score ?? 0) >= cfg.searchThreshold,
- );
- }
-
- // Deduplicate session results against long-term
- const longTermIds = new Set(longTermResults.map((r) => r.id));
- const uniqueSessionResults = sessionResults.filter(
- (r) => !longTermIds.has(r.id),
- );
-
- if (longTermResults.length === 0 && uniqueSessionResults.length === 0)
- return undefined;
+ if (longTermResults.length === 0) return undefined;
// Build context with clear labels
- let memoryContext = "";
- if (longTermResults.length > 0) {
- memoryContext += longTermResults
- .map(
- (r) =>
- `- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`,
- )
- .join("\n");
- }
- if (uniqueSessionResults.length > 0) {
- if (memoryContext) memoryContext += "\n";
- memoryContext += "\nSession memories:\n";
- memoryContext += uniqueSessionResults
- .map((r) => `- ${r.memory}`)
- .join("\n");
- }
+ const memoryContext = longTermResults
+ .map(
+ (r) =>
+ `- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`,
+ )
+ .join("\n");
+
+ _captureEvent("openclaw.hook.recall", {
+ strategy: "legacy",
+ memory_count: longTermResults.length,
+ latency_ms: Date.now() - recallStart,
+ });
- const totalCount = longTermResults.length + uniqueSessionResults.length;
api.logger.info(
- `openclaw-mem0: injecting ${totalCount} memories into context (${longTermResults.length} long-term, ${uniqueSessionResults.length} session)`,
+ `openclaw-mem0: injecting ${longTermResults.length} memories into context`,
);
const preamble = isSubagent
@@ -800,6 +830,16 @@ function registerHooks(
.trim();
if (!textContent) continue;
}
+ // Strip OpenClaw sender metadata prefix (prevents storing TUI identity as memory)
+ if (textContent.includes("Sender") && textContent.includes("untrusted metadata")) {
+ textContent = textContent
+ .replace(
+ /Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
+ "",
+ )
+ .trim();
+ if (!textContent) continue;
+ }
const isSummary =
role === "assistant" &&
@@ -873,10 +913,15 @@ function registerHooks(
});
const addOpts = buildAddOptions(undefined, sessionId, sessionId);
+ const captureStart = Date.now();
provider
.add(formattedMessages, addOpts)
.then((result) => {
const capturedCount = result.results?.length ?? 0;
+ _captureEvent("openclaw.hook.capture", {
+ captured_count: capturedCount,
+ latency_ms: Date.now() - captureStart,
+ });
if (capturedCount > 0) {
api.logger.info(
`openclaw-mem0: auto-captured ${capturedCount} memories`,
diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json
index 88146fc00..11e934f23 100644
--- a/openclaw/openclaw.plugin.json
+++ b/openclaw/openclaw.plugin.json
@@ -6,10 +6,15 @@
"skills": ["skills"],
"contracts": {
"tools": [
- "memory_search", "memory_store", "memory_get", "memory_list",
+ "memory_search", "memory_add", "memory_get", "memory_list",
"memory_update", "memory_delete", "memory_history"
]
},
+ "providerAuthEnvVars": {
+ "mem0": ["MEM0_API_KEY"],
+ "mem0-oss-openai": ["OPENAI_API_KEY"],
+ "mem0-oss-anthropic": ["ANTHROPIC_API_KEY"]
+ },
"uiHints": {
"mode": {
"label": "Mode",
@@ -19,7 +24,7 @@
"label": "Mem0 API Key",
"sensitive": true,
"placeholder": "m0-...",
- "help": "API key from app.mem0.ai (or use ${MEM0_API_KEY}). Only needed for platform mode."
+ "help": "Platform mode only. Use a SecretRef ({\"source\":\"env\",\"provider\":\"default\",\"id\":\"MEM0_API_KEY\"}) or ${MEM0_API_KEY} instead of storing the key directly."
},
"baseUrl": {
"label": "API Base URL",
@@ -82,7 +87,7 @@
"oss": {
"label": "Open-Source Configuration",
"advanced": true,
- "help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. Has sensible defaults — only override what you need."
+ "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."
},
"skills": {
"label": "Agentic Memory Skills",
@@ -98,8 +103,7 @@
"type": "string",
"enum": [
"platform",
- "open-source",
- "oss"
+ "open-source"
]
},
"apiKey": {
@@ -216,12 +220,10 @@
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
- "schedule": { "type": "string" },
- "mergeThreshold": { "type": "number" },
- "maxMemoriesPerUser": { "type": "number" },
- "preserveImmutable": { "type": "boolean" },
- "credentialScan": { "type": "boolean" },
- "expireStaleAfterDays": { "type": "number" }
+ "auto": { "type": "boolean" },
+ "minHours": { "type": "number" },
+ "minSessions": { "type": "number" },
+ "minMemories": { "type": "number" }
}
},
"domain": { "type": "string" },
diff --git a/openclaw/package.json b/openclaw/package.json
index 0e3015e75..125141bce 100644
--- a/openclaw/package.json
+++ b/openclaw/package.json
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
- "version": "1.0.4-beta.0",
+ "version": "1.0.4",
"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 afa7e62a7..ace456270 100644
--- a/openclaw/providers.ts
+++ b/openclaw/providers.ts
@@ -280,7 +280,7 @@ class OSSProvider implements Mem0Provider {
}
// Force the SDK's internal auto-initialization to complete now.
- // Without this, concurrent method calls (e.g. auto-recall + stats)
+ // Without this, concurrent method calls (e.g. auto-recall + search)
// both trigger _autoInitialize() simultaneously, causing PGVector's
// pg client to call connect() twice → "Client has already been
// connected" crash. (#4638)
@@ -370,7 +370,7 @@ class OSSProvider implements Mem0Provider {
async update(memoryId: string, text: string): Promise {
await this.ensureMemory();
- await this.memory.update(memoryId, { data: text });
+ await this.memory.update(memoryId, text);
}
async delete(memoryId: string): Promise {
diff --git a/openclaw/skill-loader.ts b/openclaw/skill-loader.ts
index 3bedf261a..b1224b629 100644
--- a/openclaw/skill-loader.ts
+++ b/openclaw/skill-loader.ts
@@ -3,10 +3,10 @@
* injects user config, and produces the final injectable prompt string.
*/
-import * as fs from "fs";
-import * as path from "path";
-import { fileURLToPath } from "url";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
import type { SkillsConfig, CategoryConfig } from "./types.ts";
+import { readText, exists } from "./fs-safe.ts";
// ============================================================================
// Defaults
@@ -111,7 +111,7 @@ function resolveSkillsDir(): string {
// Validate: must contain the expected subdirectory structure
for (const dir of candidates) {
- if (fs.existsSync(path.join(dir, "memory-triage", "SKILL.md"))) {
+ if (exists(path.join(dir, "memory-triage", "SKILL.md"))) {
return dir;
}
}
@@ -143,7 +143,7 @@ function readSkillFile(skillName: string): string | null {
const filePath = safePath(skillName, "SKILL.md");
if (!filePath) return null;
try {
- return fs.readFileSync(filePath, "utf-8");
+ return readText(filePath);
} catch {
return null;
}
@@ -158,7 +158,7 @@ function readDomainOverlay(domain: string, targetSkill: string): string | null {
const filePath = safePath(targetSkill, "domains", `${domain}.md`);
if (!filePath) return null;
try {
- const content = fs.readFileSync(filePath, "utf-8");
+ const content = readText(filePath);
const parsed = parseSkillFile(content);
// Check applies_to for backward compat (skip if targeting a different skill)
const appliesTo = parsed.frontmatter.applies_to;
@@ -318,7 +318,7 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
const parts: string[] = [];
parts.push("");
parts.push(
- "IMPORTANT: Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
+ "IMPORTANT: Use `memory_add` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
);
parts.push("");
parts.push(triage.prompt);
@@ -326,19 +326,19 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
parts.push("## Tool Usage");
parts.push("");
parts.push(
- "Batch facts by CATEGORY. All facts in one memory_store call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.",
+ "Batch facts by CATEGORY. All facts in one memory_add call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.",
);
parts.push("");
parts.push("FORMAT (single category):");
parts.push(
- ' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
+ ' memory_add(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
);
parts.push("FORMAT (mixed categories in one turn, separate calls):");
parts.push(
- ' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
+ ' memory_add(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
);
parts.push(
- ' memory_store(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")',
+ ' memory_add(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")',
);
// Only include search instructions if recall is enabled
if (config.recall?.enabled !== false) {
@@ -434,7 +434,7 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
"You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.",
);
parts.push(
- "Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
+ "Use `memory_add` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
);
parts.push("Most turns produce ZERO memory operations. That is correct.");
parts.push(
@@ -444,7 +444,7 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
"Batch facts by CATEGORY. All facts in one call must share the same category.",
);
parts.push(
- 'Format: memory_store(facts: ["fact text"], category: "identity")',
+ 'Format: memory_add(facts: ["fact text"], category: "identity")',
);
parts.push(
"NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).",
diff --git a/openclaw/skills/memory-dream/SKILL.md b/openclaw/skills/memory-dream/SKILL.md
index 0958e9d45..4d6a54eff 100644
--- a/openclaw/skills/memory-dream/SKILL.md
+++ b/openclaw/skills/memory-dream/SKILL.md
@@ -48,7 +48,7 @@ Execute the actions identified in Phase 2. Work in this priority order:
### 3a. Delete dangerous and expired entries
-Delete immediately using `memory_forget`:
+Delete immediately using `memory_delete`:
- Credentials, API keys, tokens, passwords, secrets (patterns: sk-, m0-, ghp_, AKIA, Bearer, password=, token=, secret=)
- Pure timestamps with no context
- Raw tool output stored as memory
@@ -62,7 +62,7 @@ Delete immediately using `memory_forget`:
When two or more memories express the same fact:
1. Pick the most complete version as the base
2. Call `memory_update` on the best version to incorporate missing details from the others
-3. Call `memory_forget` on the redundant entries
+3. Call `memory_delete` on the redundant entries
`memory_update` is preferred over forget-then-store because it is atomic and preserves edit history.
diff --git a/openclaw/skills/memory-triage/SKILL.md b/openclaw/skills/memory-triage/SKILL.md
index 16caa455f..7f77bc652 100644
--- a/openclaw/skills/memory-triage/SKILL.md
+++ b/openclaw/skills/memory-triage/SKILL.md
@@ -3,8 +3,8 @@ name: memory-triage
description: >
ALWAYS USE THIS SKILL. Persistent long-term memory protocol powered by mem0.
After EVERY response, evaluate the conversation for durable facts worth storing
- via memory_store. Handles identity, preferences, decisions, configurations, rules,
- projects, and relationships. Use memory_store (NOT workspace files) for all user facts.
+ via memory_add. Handles identity, preferences, decisions, configurations, rules,
+ projects, and relationships. Use memory_add (NOT workspace files) for all user facts.
Read this skill at the start of every session.
user-invocable: false
metadata:
@@ -108,26 +108,26 @@ Each memory you store must be a **self-contained, independently understandable f
**ALWAYS group all information about the same entity, concept, event, or subject into a SINGLE unified memory.** If multiple pieces of information refer to the same entity (e.g., a conference, a project, a person, a system), they MUST be combined into one comprehensive memory.
-**DO NOT split requirements, specifications, or details about the same entity across multiple memory_store calls.** Even if information is phrased differently ("Budget for X", "X requires Y", "X needs Z"), if they all refer to the same entity, combine ALL into ONE call.
+**DO NOT split requirements, specifications, or details about the same entity across multiple memory_add calls.** Even if information is phrased differently ("Budget for X", "X requires Y", "X needs Z"), if they all refer to the same entity, combine ALL into ONE call.
**WRONG** — fragmented into separate facts:
```
-memory_store(facts: ["Conference requires at least 4 breakout rooms", "Conference requires vegan options", "Conference requires parking"], category: "project")
+memory_add(facts: ["Conference requires at least 4 breakout rooms", "Conference requires vegan options", "Conference requires parking"], category: "project")
```
**CORRECT** — grouped into one self-contained fact:
```
-memory_store(facts: ["Conference requires at least 4 breakout rooms for 30-40 people each, robust vegan and vegetarian options with allergen-free alternatives, parking for at least 100 vehicles, venue within walking distance of transit"], category: "project")
+memory_add(facts: ["Conference requires at least 4 breakout rooms for 30-40 people each, robust vegan and vegetarian options with allergen-free alternatives, parking for at least 100 vehicles, venue within walking distance of transit"], category: "project")
```
**WRONG** — same entity split into separate facts:
```
-memory_store(facts: ["Budget is $150-175 per person for TechForward event", "TechForward event requires strong WiFi", "TechForward event requires hybrid capabilities"], category: "project")
+memory_add(facts: ["Budget is $150-175 per person for TechForward event", "TechForward event requires strong WiFi", "TechForward event requires hybrid capabilities"], category: "project")
```
**CORRECT** — combined into one fact about TechForward:
```
-memory_store(facts: ["TechForward event has a budget of $150-175 per person per day including venue rental, standard AV setup, and catering. Requires strong WiFi and hybrid event capabilities for remote attendees."], category: "project")
+memory_add(facts: ["TechForward event has a budget of $150-175 per person per day including venue rental, standard AV setup, and catering. Requires strong WiFi and hybrid event capabilities for remote attendees."], category: "project")
```
**Only create separate memories when information refers to genuinely different entities, concepts, or unrelated topics** (e.g., "TechForward event" vs "Marketing campaign" are separate).
@@ -151,10 +151,10 @@ Do not store characterizations from assistant messages (e.g., "user seems excite
## How to Store
-Use `memory_store` with the `facts` array. All facts in one call MUST share the same category because category determines retention policy (TTL, immutability).
+Use `memory_add` with the `facts` array. All facts in one call MUST share the same category because category determines retention policy (TTL, immutability).
```
-memory_store(
+memory_add(
facts: ["fact one in third person", "fact two in third person"],
category: "identity"
)
@@ -163,8 +163,8 @@ memory_store(
If a turn produces facts in different categories, make one call per category:
```
-memory_store(facts: ["User is Alex, senior engineer at Stripe, PST timezone"], category: "identity")
-memory_store(facts: ["As of 2026-04-01, user decided to migrate from Postgres to CockroachDB"], category: "decision")
+memory_add(facts: ["User is Alex, senior engineer at Stripe, PST timezone"], category: "identity")
+memory_add(facts: ["As of 2026-04-01, user decided to migrate from Postgres to CockroachDB"], category: "decision")
```
Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `technical`, `relationship`, `project`
@@ -197,8 +197,8 @@ Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `tech
When a recalled memory needs updating (fact changed, status changed, new detail added):
1. `memory_search` to find the existing memory
-2. `memory_forget` on the old memory's ID
-3. `memory_store` with the corrected/expanded fact
+2. `memory_delete` on the old memory's ID
+3. `memory_add` with the corrected/expanded fact
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
@@ -240,7 +240,7 @@ When a recalled memory needs updating (fact changed, status changed, new detail
```
User: "I set up the research agent on Claude Sonnet with a 30-min cron. It checks HackerNews and sends summaries to #research-feed in Slack."
Agent: [responds helpfully]
-→ memory_store(facts: ["User's research agent runs on Claude Sonnet, cron every 30 minutes, monitors HackerNews and posts summaries to Slack #research-feed"], category: "configuration")
+→ memory_add(facts: ["User's research agent runs on Claude Sonnet, cron every 30 minutes, monitors HackerNews and posts summaries to Slack #research-feed"], category: "configuration")
```
### Example 2: NOOP — tool output
@@ -260,7 +260,7 @@ User: "Hey Chris here again"
### Example 4: Rule with rationale (preserving user's words)
```
User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac mini only has 256GB"
-→ memory_store(facts: ["User rule: avoid Docker for local dev. Reason: ate 40GB of disk on 256GB Mac mini"], category: "rule")
+→ memory_add(facts: ["User rule: avoid Docker for local dev. Reason: ate 40GB of disk on 256GB Mac mini"], category: "rule")
```
### Example 5: UPDATE — combining contexts from both versions
@@ -268,22 +268,22 @@ User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
→ memory_search("Paris trip planning")
-→ memory_forget("mem-id-of-old")
-→ memory_store(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
+→ memory_delete(memoryId: "mem-id-of-old")
+→ memory_add(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
```
### Example 6: Outcome over intent
```
User: "Update the call scripts sheet with the new truth-based templates"
Agent: [updates the sheet successfully]
-→ memory_store(facts: ["Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates (as of 2026-03-30)"], category: "configuration")
+→ memory_add(facts: ["Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates (as of 2026-03-30)"], category: "configuration")
```
### Example 7: Credential — store the fact, not the value
```
User: "Use this API key for the new service: sk-proj-abc123def456"
Agent: [configures the service]
-→ memory_store(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
+→ memory_add(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
```
### Example 8: NOOP — cosmetic difference, not material
@@ -296,7 +296,7 @@ User: "Yeah me and Poppy love our daily walks"
### Example 9: Entity grouping — single call, not fragmented
```
User: "The budget for the offsite is $200 per head. We need a venue with WiFi, parking for 50 cars, and a projector."
-→ memory_store(facts: ["Team offsite budget is $200 per person. Venue requirements: WiFi, parking for 50 vehicles, and projector setup."], category: "project")
+→ memory_add(facts: ["Team offsite budget is $200 per person. Venue requirements: WiFi, parking for 50 vehicles, and projector setup."], category: "project")
All details about the same entity (offsite) go in one fact, one call.
```
@@ -304,15 +304,15 @@ All details about the same entity (offsite) go in one fact, one call.
```
Recalled: ["User enjoys hiking on weekends and finds it therapeutic"]
User: "I hurt my knee last week, can't hike for a while"
-→ memory_store(facts: ["As of 2026-03-30, user has temporarily paused hiking due to knee injury"], category: "project")
+→ memory_add(facts: ["As of 2026-03-30, user has temporarily paused hiking due to knee injury"], category: "project")
DO NOT delete the hiking preference. It is temporarily paused, not contradicted.
```
### Example 11: Mixed categories in one turn — separate calls
```
User: "I'm Sarah, I work at Cloudflare. I just decided to switch our monitoring from Datadog to Grafana because of cost."
-→ memory_store(facts: ["User is Sarah, works at Cloudflare"], category: "identity")
-→ memory_store(facts: ["As of 2026-03-30, user decided to switch monitoring from Datadog to Grafana due to cost"], category: "decision")
+→ memory_add(facts: ["User is Sarah, works at Cloudflare"], category: "identity")
+→ memory_add(facts: ["As of 2026-03-30, user decided to switch monitoring from Datadog to Grafana due to cost"], category: "decision")
Two calls because identity and decision have different retention policies.
```
@@ -328,8 +328,8 @@ Agent: "Hello! How can I help?"
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
→ memory_search("dog Poppy walks") → find all three old memory IDs
-→ memory_forget(id-1), memory_forget(id-2), memory_forget(id-3)
-→ memory_store(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
+→ memory_delete(memoryId: "id-1"), memory_delete(memoryId: "id-2"), memory_delete(memoryId: "id-3")
+→ memory_add(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
```
### Example 12: NOOP — generic greeting, nothing to store
diff --git a/openclaw/telemetry.ts b/openclaw/telemetry.ts
new file mode 100644
index 000000000..21094891e
--- /dev/null
+++ b/openclaw/telemetry.ts
@@ -0,0 +1,113 @@
+/**
+ * Plugin telemetry — anonymous usage tracking via PostHog.
+ *
+ * Sends fire-and-forget events to PostHog using native fetch().
+ * Events are batched and flushed every 5 seconds or when the queue
+ * reaches 10 events, whichever comes first.
+ *
+ * Disable with: MEM0_TELEMETRY=false
+ */
+
+import { createHash } from "node:crypto";
+import { readPluginAuth } from "./cli/config-file.ts";
+
+export const PLUGIN_VERSION = "1.0.4";
+
+const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
+const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
+
+const FLUSH_INTERVAL_MS = 5_000;
+const FLUSH_THRESHOLD = 10;
+
+let eventQueue: Record[] = [];
+let flushTimer: ReturnType | undefined;
+
+function isTelemetryEnabled(): boolean {
+ const val = (process.env.MEM0_TELEMETRY ?? "true").toLowerCase();
+ return val !== "false" && val !== "0" && val !== "no";
+}
+
+/**
+ * Return a stable anonymous identifier for the current user.
+ *
+ * Priority: cached userEmail (from /v1/ping/) > MD5(apiKey) > fallback.
+ */
+function getDistinctId(apiKey?: string): string {
+ try {
+ const auth = readPluginAuth();
+ if (auth.userEmail) return auth.userEmail;
+ } catch {
+ /* ignore */
+ }
+ if (apiKey) {
+ return createHash("md5").update(apiKey).digest("hex");
+ }
+ return "anonymous-openclaw";
+}
+
+function ensureFlushTimer(): void {
+ if (flushTimer) return;
+ flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
+ if (typeof flushTimer === "object" && "unref" in flushTimer) {
+ flushTimer.unref();
+ }
+}
+
+function flushEvents(): void {
+ if (eventQueue.length === 0) return;
+ const batch = eventQueue;
+ eventQueue = [];
+
+ const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
+ fetch(POSTHOG_HOST, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": String(Buffer.byteLength(body)),
+ },
+ body,
+ signal: AbortSignal.timeout(3_000),
+ }).catch(() => {
+ /* silently swallow */
+ });
+}
+
+/**
+ * Capture a PostHog event (non-blocking, never throws).
+ */
+export function captureEvent(
+ eventName: string,
+ properties: Record = {},
+ ctx?: { apiKey?: string; mode?: string; skillsActive?: boolean },
+): void {
+ if (!isTelemetryEnabled()) return;
+
+ try {
+ const distinctId = getDistinctId(ctx?.apiKey);
+
+ eventQueue.push({
+ event: eventName,
+ distinct_id: distinctId,
+ properties: {
+ source: "OPENCLAW",
+ language: "node",
+ plugin_version: PLUGIN_VERSION,
+ node_version: process.version,
+ os: process.platform,
+ mode: ctx?.mode,
+ skills_active: ctx?.skillsActive,
+ $process_person_profile: false,
+ $lib: "posthog-node",
+ ...properties,
+ },
+ });
+
+ ensureFlushTimer();
+
+ if (eventQueue.length >= FLUSH_THRESHOLD) {
+ flushEvents();
+ }
+ } catch {
+ /* silently swallow */
+ }
+}
diff --git a/openclaw/tests/cli-commands.test.ts b/openclaw/tests/cli-commands.test.ts
new file mode 100644
index 000000000..b8dab9eb4
--- /dev/null
+++ b/openclaw/tests/cli-commands.test.ts
@@ -0,0 +1,1271 @@
+/**
+ * Tests for CLI subcommand registration and action handlers in cli/commands.ts.
+ *
+ * Since the helper functions (getSystemUsername, resolveUserId, apiPost, etc.)
+ * are module-private, we test them indirectly through registerCliCommands by
+ * building a mock Commander-like program that captures each subcommand's action
+ * callback, then invoking those callbacks with controlled arguments.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// ---------------------------------------------------------------------------
+// Module mocks — must be declared before importing the module under test
+// ---------------------------------------------------------------------------
+
+vi.mock("../cli/config-file.ts", () => ({
+ readPluginAuth: vi.fn().mockReturnValue({}),
+ writePluginAuth: vi.fn(),
+ writePluginConfigField: vi.fn(),
+ getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
+ OPENCLAW_CONFIG_FILE: "/mock/.openclaw/openclaw.json",
+}));
+
+vi.mock("../fs-safe.ts", () => ({
+ readText: vi.fn().mockReturnValue("{}"),
+ exists: vi.fn().mockReturnValue(true),
+ writeText: vi.fn(),
+ mkdirp: vi.fn(),
+ unlink: vi.fn(),
+}));
+
+vi.mock("../skill-loader.ts", () => ({
+ loadDreamPrompt: vi.fn().mockReturnValue("dream prompt"),
+}));
+
+// ---------------------------------------------------------------------------
+// Imports (after mocks)
+// ---------------------------------------------------------------------------
+
+import { registerCliCommands } from "../cli/commands.ts";
+import {
+ readPluginAuth,
+ writePluginAuth,
+ writePluginConfigField,
+ getBaseUrl,
+} from "../cli/config-file.ts";
+import { loadDreamPrompt } from "../skill-loader.ts";
+
+// ---------------------------------------------------------------------------
+// Mock Commander program builder
+// ---------------------------------------------------------------------------
+
+interface MockCommand {
+ _name: string;
+ _description: string;
+ _subcommands: MockCommand[];
+ _options: Array<{ flags: string; desc: string; defaultVal?: string }>;
+ _action: ((...args: any[]) => any) | null;
+ _args: Array<{ name: string; desc: string }>;
+ command(name: string): MockCommand;
+ description(desc: string): MockCommand;
+ option(flags: string, desc: string, defaultVal?: string): MockCommand;
+ argument(name: string, desc: string): MockCommand;
+ action(fn: (...args: any[]) => any): MockCommand;
+}
+
+function createMockCommand(name: string): MockCommand {
+ const cmd: MockCommand = {
+ _name: name,
+ _description: "",
+ _subcommands: [],
+ _options: [],
+ _action: null,
+ _args: [],
+ command(n: string) {
+ const sub = createMockCommand(n);
+ cmd._subcommands.push(sub);
+ return sub;
+ },
+ description(desc: string) {
+ cmd._description = desc;
+ return cmd;
+ },
+ option(flags: string, desc: string, defaultVal?: string) {
+ cmd._options.push({ flags, desc, defaultVal });
+ return cmd;
+ },
+ argument(n: string, desc: string) {
+ cmd._args.push({ name: n, desc });
+ return cmd;
+ },
+ action(fn: (...args: any[]) => any) {
+ cmd._action = fn;
+ return cmd;
+ },
+ };
+ return cmd;
+}
+
+/** Recursively find a subcommand by name. */
+function findCommand(root: MockCommand, name: string): MockCommand | undefined {
+ for (const sub of root._subcommands) {
+ if (sub._name === name) return sub;
+ const deep = findCommand(sub, name);
+ if (deep) return deep;
+ }
+ return undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Shared helpers
+// ---------------------------------------------------------------------------
+
+function createMockProvider() {
+ return {
+ add: vi.fn().mockResolvedValue({
+ results: [{ id: "new-1", event: "ADD", memory: "stored fact" }],
+ }),
+ search: vi.fn().mockResolvedValue([
+ { id: "m1", memory: "test memory", score: 0.9, categories: ["preference"], created_at: "2026-01-01" },
+ ]),
+ get: vi.fn().mockResolvedValue({
+ id: "m1",
+ memory: "test memory",
+ user_id: "testuser",
+ categories: ["preference"],
+ metadata: {},
+ created_at: "2026-01-01",
+ updated_at: "2026-01-02",
+ }),
+ getAll: vi.fn().mockResolvedValue([
+ { id: "m1", memory: "test memory", categories: ["preference"], created_at: "2026-01-01", updated_at: "2026-01-02" },
+ ]),
+ update: vi.fn().mockResolvedValue(undefined),
+ delete: vi.fn().mockResolvedValue(undefined),
+ deleteAll: vi.fn().mockResolvedValue(undefined),
+ history: vi.fn().mockResolvedValue([
+ { id: "h1", old_memory: "old", new_memory: "new", event: "UPDATE", created_at: "2026-01-01" },
+ ]),
+ };
+}
+
+function createMockBackend() {
+ return {
+ status: vi.fn().mockResolvedValue({
+ connected: true,
+ url: "https://api.mem0.ai",
+ }),
+ };
+}
+
+function createMockCfg() {
+ return {
+ mode: "platform" as const,
+ userId: "testuser",
+ apiKey: "m0-test-key-1234",
+ baseUrl: "https://api.mem0.ai",
+ topK: 5,
+ enableGraph: false,
+ autoCapture: true,
+ autoRecall: true,
+ searchThreshold: 0.5,
+ customInstructions: "",
+ customCategories: {},
+ skills: {},
+ };
+}
+
+/**
+ * Register CLI commands using mocked deps, and return the captured
+ * mem0 command tree plus all mock objects for assertions.
+ */
+function setup() {
+ const provider = createMockProvider();
+ const backend = createMockBackend();
+ const cfg = createMockCfg();
+ const effectiveUserId = vi.fn().mockReturnValue("testuser");
+ const agentUserId = vi.fn((id: string) => `testuser:agent:${id}`);
+ const buildSearchOptions = vi.fn().mockReturnValue({
+ user_id: "testuser",
+ top_k: 5,
+ source: "OPENCLAW",
+ });
+ const getCurrentSessionId = vi.fn().mockReturnValue(undefined);
+
+ let registeredCallback: any;
+ const mockApi = {
+ registerCli: vi.fn((cb: any) => {
+ registeredCallback = cb;
+ }),
+ logger: { info: vi.fn(), warn: vi.fn() },
+ } as any;
+
+ registerCliCommands(
+ mockApi,
+ backend as any,
+ provider as any,
+ cfg as any,
+ effectiveUserId,
+ agentUserId,
+ buildSearchOptions,
+ getCurrentSessionId,
+ );
+
+ // Build a mock program and invoke the captured callback
+ const rootProgram = createMockCommand("root");
+ registeredCallback({ program: rootProgram });
+
+ // The callback creates a "mem0" subcommand on the root program
+ const mem0 = findCommand(rootProgram, "mem0")!;
+
+ return {
+ mem0,
+ mockApi,
+ provider,
+ backend,
+ cfg,
+ effectiveUserId,
+ agentUserId,
+ buildSearchOptions,
+ getCurrentSessionId,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Test suites
+// ---------------------------------------------------------------------------
+
+describe("registerCliCommands", () => {
+ let consoleSpy: {
+ log: ReturnType;
+ error: ReturnType;
+ warn: ReturnType;
+ };
+ let stderrSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ // Re-set default mock return values after resetAllMocks
+ (readPluginAuth as ReturnType).mockReturnValue({});
+ (writePluginAuth as ReturnType).mockImplementation(() => {});
+ (getBaseUrl as ReturnType).mockReturnValue("https://api.mem0.ai");
+ (loadDreamPrompt as ReturnType).mockReturnValue("dream prompt");
+
+ consoleSpy = {
+ log: vi.spyOn(console, "log").mockImplementation(() => {}),
+ error: vi.spyOn(console, "error").mockImplementation(() => {}),
+ warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
+ };
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ consoleSpy.log.mockRestore();
+ consoleSpy.error.mockRestore();
+ consoleSpy.warn.mockRestore();
+ stderrSpy.mockRestore();
+ vi.restoreAllMocks();
+ });
+
+ // ========================================================================
+ // Registration
+ // ========================================================================
+
+ describe("command registration", () => {
+ it("calls api.registerCli exactly once", () => {
+ const { mockApi } = setup();
+ expect(mockApi.registerCli).toHaveBeenCalledTimes(1);
+ });
+
+ it("registers a mem0 parent command", () => {
+ const { mem0 } = setup();
+ expect(mem0).toBeDefined();
+ expect(mem0._name).toBe("mem0");
+ });
+
+ it("registers all expected subcommands under mem0", () => {
+ const { mem0 } = setup();
+ const names = mem0._subcommands.map((c) => c._name);
+ expect(names).toContain("init");
+ expect(names).toContain("add");
+ expect(names).toContain("search");
+ expect(names).toContain("get");
+ expect(names).toContain("list");
+ expect(names).toContain("update");
+ expect(names).toContain("delete");
+ expect(names).toContain("history");
+ expect(names).toContain("status");
+ expect(names).toContain("config");
+ expect(names).toContain("dream");
+ });
+
+ it("registers config subcommands: show, get, set", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const configSubs = configCmd._subcommands.map((c) => c._name);
+ expect(configSubs).toContain("show");
+ expect(configSubs).toContain("get");
+ expect(configSubs).toContain("set");
+ });
+ });
+
+ // ========================================================================
+ // init subcommand
+ // ========================================================================
+
+ describe("init subcommand", () => {
+ it("saves config and validates API key with --api-key flag", async () => {
+ const { mem0 } = setup();
+ const initCmd = findCommand(mem0, "init")!;
+
+ // Stub fetch for validateApiKey
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({}),
+ }));
+
+ await initCmd._action!({ apiKey: "m0-my-key-1234" });
+
+ // saveLoginConfig calls writePluginAuth with the API key
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ apiKey: "m0-my-key-1234",
+ baseUrl: "https://api.mem0.ai",
+ mode: "platform",
+ }),
+ );
+
+ // validateApiKey calls fetch with /v1/ping/
+ expect(fetch).toHaveBeenCalledWith(
+ "https://api.mem0.ai/v1/ping/",
+ expect.objectContaining({
+ headers: {
+ Authorization: "Token m0-my-key-1234",
+ "X-Mem0-Source": "OPENCLAW",
+ "X-Mem0-Client-Language": "node",
+ },
+ }),
+ );
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("API key validated"),
+ );
+
+ vi.unstubAllGlobals();
+ });
+
+ it("warns when API key validation returns non-ok status", 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" });
+
+ expect(consoleSpy.warn).toHaveBeenCalledWith(
+ expect.stringContaining("HTTP 401"),
+ );
+
+ vi.unstubAllGlobals();
+ });
+
+ it("warns when network error during API key validation", async () => {
+ const { mem0 } = setup();
+ const initCmd = findCommand(mem0, "init")!;
+
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
+
+ await initCmd._action!({ apiKey: "some-key" });
+
+ expect(consoleSpy.warn).toHaveBeenCalledWith(
+ expect.stringContaining("could not reach"),
+ );
+
+ vi.unstubAllGlobals();
+ });
+
+ it("rejects using both --api-key and --email together", async () => {
+ const { mem0 } = setup();
+ const initCmd = findCommand(mem0, "init")!;
+
+ await initCmd._action!({ apiKey: "key", email: "test@example.com" });
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ "Cannot use both --api-key and --email.",
+ );
+ expect(writePluginAuth).not.toHaveBeenCalled();
+ });
+
+ it("sends verification code with --email only", 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: "TEST@Example.Com" });
+
+ // sendVerificationCode calls apiPost which calls fetch
+ expect(fetch).toHaveBeenCalledWith(
+ "https://api.mem0.ai/api/v1/auth/email_code/",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ email: "test@example.com" }),
+ }),
+ );
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("Verification code sent"),
+ );
+
+ vi.unstubAllGlobals();
+ });
+
+ it("verifies email code with --email and --code, saves config", 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-key" }),
+ }));
+
+ await initCmd._action!({
+ email: "user@example.com",
+ code: "123456",
+ });
+
+ // verifyEmailCode POST
+ expect(fetch).toHaveBeenCalledWith(
+ "https://api.mem0.ai/api/v1/auth/email_code/verify/",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ email: "user@example.com", code: "123456" }),
+ }),
+ );
+
+ // saveLoginConfig writes the returned API key
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ apiKey: "m0-verified-key",
+ }),
+ );
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("Authenticated"),
+ );
+
+ vi.unstubAllGlobals();
+ });
+
+ it("does not save config when email verification returns no api_key", async () => {
+ const { mem0 } = setup();
+ const initCmd = findCommand(mem0, "init")!;
+
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({}), // no api_key field
+ }));
+
+ await initCmd._action!({ email: "user@example.com", code: "000000" });
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("no API key was returned"),
+ );
+ expect(writePluginAuth).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("shows usage in non-interactive mode with no flags", async () => {
+ const { mem0 } = setup();
+ const initCmd = findCommand(mem0, "init")!;
+
+ // Simulate non-TTY
+ const origIsTTY = process.stdin.isTTY;
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
+
+ await initCmd._action!({});
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("Usage (non-interactive)"),
+ );
+
+ Object.defineProperty(process.stdin, "isTTY", { value: origIsTTY, configurable: true });
+ });
+
+ it("preserves userId from --user-id flag during init", 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", userId: "custom-user" });
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: "custom-user",
+ }),
+ );
+
+ vi.unstubAllGlobals();
+ });
+ });
+
+ // ========================================================================
+ // add subcommand
+ // ========================================================================
+
+ describe("add subcommand", () => {
+ it("calls provider.add with text and prints result", async () => {
+ const { mem0, provider } = setup();
+ const addCmd = findCommand(mem0, "add")!;
+
+ await addCmd._action!("User likes TypeScript", {});
+
+ expect(provider.add).toHaveBeenCalledWith(
+ [{ role: "user", content: "User likes TypeScript" }],
+ expect.objectContaining({ user_id: "testuser" }),
+ );
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("Added 1 memory"),
+ );
+ });
+
+ it("uses agentUserId when --agent-id is provided", async () => {
+ const { mem0, provider, agentUserId } = setup();
+ const addCmd = findCommand(mem0, "add")!;
+
+ await addCmd._action!("agent fact", { agentId: "researcher" });
+
+ expect(agentUserId).toHaveBeenCalledWith("researcher");
+ expect(provider.add).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ user_id: "testuser:agent:researcher" }),
+ );
+ });
+
+ it("uses --user-id override when provided", async () => {
+ const { mem0, provider } = setup();
+ const addCmd = findCommand(mem0, "add")!;
+
+ await addCmd._action!("some fact", { userId: "alice" });
+
+ expect(provider.add).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ user_id: "alice" }),
+ );
+ });
+
+ it("prints message when no new memories are extracted", async () => {
+ const { mem0, provider } = setup();
+ provider.add.mockResolvedValueOnce({ results: [] });
+ const addCmd = findCommand(mem0, "add")!;
+
+ await addCmd._action!("the", {});
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("No new memories extracted"),
+ );
+ });
+
+ it("handles add errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.add.mockRejectedValueOnce(new Error("API timeout"));
+ const addCmd = findCommand(mem0, "add")!;
+
+ await addCmd._action!("failing fact", {});
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Add failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // search subcommand
+ // ========================================================================
+
+ describe("search subcommand", () => {
+ it("calls provider.search and outputs results as JSON", async () => {
+ const { mem0, provider } = setup();
+ const searchCmd = findCommand(mem0, "search")!;
+
+ await searchCmd._action!("user preferences", {
+ topK: "5",
+ scope: "all",
+ });
+
+ expect(provider.search).toHaveBeenCalled();
+ // The output is JSON.stringify of results
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("test memory"),
+ );
+ });
+
+ it("prints 'No memories found' when search returns empty", async () => {
+ const { mem0, provider } = setup();
+ provider.search.mockResolvedValue([]);
+ const searchCmd = findCommand(mem0, "search")!;
+
+ await searchCmd._action!("nothing", { topK: "5", scope: "all" });
+
+ expect(consoleSpy.log).toHaveBeenCalledWith("No memories found.");
+ });
+
+ it("uses --user-id override for search", async () => {
+ const { mem0, provider } = setup();
+ const searchCmd = findCommand(mem0, "search")!;
+
+ await searchCmd._action!("query", {
+ topK: "5",
+ scope: "all",
+ userId: "alice",
+ });
+
+ // The userId should flow through to the search options
+ expect(provider.search).toHaveBeenCalled();
+ });
+
+ it("uses agentUserId when --agent-id is provided", async () => {
+ const { mem0, agentUserId } = setup();
+ const searchCmd = findCommand(mem0, "search")!;
+
+ await searchCmd._action!("query", {
+ topK: "5",
+ scope: "all",
+ agentId: "researcher",
+ });
+
+ expect(agentUserId).toHaveBeenCalledWith("researcher");
+ });
+
+ it("handles search errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.search.mockRejectedValue(new Error("search boom"));
+ const searchCmd = findCommand(mem0, "search")!;
+
+ await searchCmd._action!("test", { topK: "5", scope: "all" });
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Search failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // get subcommand
+ // ========================================================================
+
+ describe("get subcommand", () => {
+ it("calls provider.get and outputs the memory as JSON", async () => {
+ const { mem0, provider } = setup();
+ const getCmd = findCommand(mem0, "get")!;
+
+ await getCmd._action!("m1");
+
+ expect(provider.get).toHaveBeenCalledWith("m1");
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("test memory"),
+ );
+ });
+
+ it("handles get errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.get.mockRejectedValueOnce(new Error("not found"));
+ const getCmd = findCommand(mem0, "get")!;
+
+ await getCmd._action!("bad-id");
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Get failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // list subcommand
+ // ========================================================================
+
+ describe("list subcommand", () => {
+ it("calls provider.getAll and prints memories as JSON", async () => {
+ const { mem0, provider } = setup();
+ const listCmd = findCommand(mem0, "list")!;
+
+ await listCmd._action!({ topK: "50" });
+
+ expect(provider.getAll).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user_id: "testuser",
+ page_size: 50,
+ source: "OPENCLAW",
+ }),
+ );
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("test memory"),
+ );
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("Total: 1 memories"),
+ );
+ });
+
+ it("prints 'No memories found' when list returns empty", async () => {
+ const { mem0, provider } = setup();
+ provider.getAll.mockResolvedValueOnce([]);
+ const listCmd = findCommand(mem0, "list")!;
+
+ await listCmd._action!({ topK: "50" });
+
+ expect(consoleSpy.log).toHaveBeenCalledWith("No memories found.");
+ });
+
+ it("uses agentUserId when --agent-id is provided", async () => {
+ const { mem0, provider, agentUserId } = setup();
+ const listCmd = findCommand(mem0, "list")!;
+
+ await listCmd._action!({ topK: "50", agentId: "builder" });
+
+ expect(agentUserId).toHaveBeenCalledWith("builder");
+ expect(provider.getAll).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user_id: "testuser:agent:builder",
+ source: "OPENCLAW",
+ }),
+ );
+ });
+
+ it("handles list errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.getAll.mockRejectedValueOnce(new Error("list boom"));
+ const listCmd = findCommand(mem0, "list")!;
+
+ await listCmd._action!({ topK: "50" });
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("List failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // update subcommand
+ // ========================================================================
+
+ describe("update subcommand", () => {
+ it("calls provider.update and prints confirmation", async () => {
+ const { mem0, provider } = setup();
+ const updateCmd = findCommand(mem0, "update")!;
+
+ await updateCmd._action!("m1", "updated text");
+
+ expect(provider.update).toHaveBeenCalledWith("m1", "updated text");
+ expect(consoleSpy.log).toHaveBeenCalledWith("Memory m1 updated.");
+ });
+
+ it("handles update errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.update.mockRejectedValueOnce(new Error("update boom"));
+ const updateCmd = findCommand(mem0, "update")!;
+
+ await updateCmd._action!("m1", "text");
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Update failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // delete subcommand
+ // ========================================================================
+
+ describe("delete subcommand", () => {
+ it("deletes a single memory by ID", async () => {
+ const { mem0, provider } = setup();
+ const deleteCmd = findCommand(mem0, "delete")!;
+
+ await deleteCmd._action!("m1", {});
+
+ expect(provider.delete).toHaveBeenCalledWith("m1");
+ expect(consoleSpy.log).toHaveBeenCalledWith("Memory m1 deleted.");
+ });
+
+ it("bulk deletes with --all and --confirm", async () => {
+ const { mem0, provider } = setup();
+ const deleteCmd = findCommand(mem0, "delete")!;
+
+ await deleteCmd._action!(undefined, { all: true, confirm: true });
+
+ expect(provider.deleteAll).toHaveBeenCalledWith("testuser");
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("All memories deleted"),
+ );
+ });
+
+ it("requires --confirm for bulk delete in non-interactive mode", async () => {
+ const { mem0, provider } = setup();
+ const deleteCmd = findCommand(mem0, "delete")!;
+
+ const origIsTTY = process.stdin.isTTY;
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
+
+ await deleteCmd._action!(undefined, { all: true });
+
+ expect(provider.deleteAll).not.toHaveBeenCalled();
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("--confirm flag"),
+ );
+
+ Object.defineProperty(process.stdin, "isTTY", { value: origIsTTY, configurable: true });
+ });
+
+ it("requires memory_id or --all flag", async () => {
+ const { mem0 } = setup();
+ const deleteCmd = findCommand(mem0, "delete")!;
+
+ await deleteCmd._action!(undefined, {});
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Provide a memory_id or use --all"),
+ );
+ });
+
+ it("uses agentUserId for --all --agent-id", async () => {
+ const { mem0, provider, agentUserId } = setup();
+ const deleteCmd = findCommand(mem0, "delete")!;
+
+ await deleteCmd._action!(undefined, {
+ all: true,
+ confirm: true,
+ agentId: "researcher",
+ });
+
+ expect(agentUserId).toHaveBeenCalledWith("researcher");
+ expect(provider.deleteAll).toHaveBeenCalledWith("testuser:agent:researcher");
+ });
+
+ it("handles delete errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.delete.mockRejectedValueOnce(new Error("delete failed"));
+ const deleteCmd = findCommand(mem0, "delete")!;
+
+ await deleteCmd._action!("m1", {});
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Delete failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // history subcommand
+ // ========================================================================
+
+ describe("history subcommand", () => {
+ it("calls provider.history and prints entries as JSON", async () => {
+ const { mem0, provider } = setup();
+ const historyCmd = findCommand(mem0, "history")!;
+
+ await historyCmd._action!("m1");
+
+ expect(provider.history).toHaveBeenCalledWith("m1");
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("UPDATE"),
+ );
+ });
+
+ it("prints message when no history found", async () => {
+ const { mem0, provider } = setup();
+ provider.history.mockResolvedValueOnce([]);
+ const historyCmd = findCommand(mem0, "history")!;
+
+ await historyCmd._action!("m1");
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ "No history found for this memory.",
+ );
+ });
+
+ it("handles history errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.history.mockRejectedValueOnce(new Error("history boom"));
+ const historyCmd = findCommand(mem0, "history")!;
+
+ await historyCmd._action!("m1");
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("History failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // status subcommand
+ // ========================================================================
+
+ describe("status subcommand", () => {
+ it("calls backend.status and prints connection info", async () => {
+ const { mem0, backend } = setup();
+ const statusCmd = findCommand(mem0, "status")!;
+
+ await statusCmd._action!();
+
+ expect(backend.status).toHaveBeenCalled();
+ expect(consoleSpy.log).toHaveBeenCalledWith("Mode: platform");
+ expect(consoleSpy.log).toHaveBeenCalledWith("User ID: testuser");
+ expect(consoleSpy.log).toHaveBeenCalledWith("Connected to Mem0");
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("https://api.mem0.ai"),
+ );
+ });
+
+ it("shows 'Not connected' when backend returns disconnected", async () => {
+ const { mem0, backend } = setup();
+ backend.status.mockResolvedValueOnce({
+ connected: false,
+ error: "ECONNREFUSED",
+ });
+ const statusCmd = findCommand(mem0, "status")!;
+
+ await statusCmd._action!();
+
+ expect(consoleSpy.log).toHaveBeenCalledWith("Not connected to Mem0");
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("ECONNREFUSED"),
+ );
+ });
+
+ it("handles status errors gracefully", async () => {
+ const { mem0, backend } = setup();
+ backend.status.mockRejectedValueOnce(new Error("status boom"));
+ const statusCmd = findCommand(mem0, "status")!;
+
+ await statusCmd._action!();
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Status check failed"),
+ );
+ });
+ });
+
+ // ========================================================================
+ // config show subcommand
+ // ========================================================================
+
+ describe("config show subcommand", () => {
+ it("displays all config keys with values", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const showCmd = findCommand(configCmd, "show")!;
+
+ showCmd._action!();
+
+ // Should print header, separator, and each key
+ const allOutput = consoleSpy.log.mock.calls.map((c) => c[0]).join("\n");
+ expect(allOutput).toContain("Key");
+ expect(allOutput).toContain("Value");
+ expect(allOutput).toContain("platform.api_key");
+ expect(allOutput).toContain("platform.base_url");
+ expect(allOutput).toContain("defaults.user_id");
+ expect(allOutput).toContain("Config file:");
+ });
+ });
+
+ // ========================================================================
+ // config get subcommand
+ // ========================================================================
+
+ describe("config get subcommand", () => {
+ it("prints value for a known config key", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const getCmd = findCommand(configCmd, "get")!;
+
+ getCmd._action!("mode");
+
+ expect(consoleSpy.log).toHaveBeenCalledWith("platform");
+ });
+
+ it("prints '(not set)' for an unset config key", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const getCmd = findCommand(configCmd, "get")!;
+
+ getCmd._action!("org_id");
+
+ expect(consoleSpy.log).toHaveBeenCalledWith("(not set)");
+ });
+
+ it("errors on unknown config key", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const getCmd = findCommand(configCmd, "get")!;
+
+ getCmd._action!("nonexistent_key");
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Unknown config key: nonexistent_key"),
+ );
+ });
+
+ it("redacts API key in display", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const getCmd = findCommand(configCmd, "get")!;
+
+ // readPluginAuth returns config with apiKey
+ (readPluginAuth as ReturnType).mockReturnValue({
+ apiKey: "m0-supersecretkey1234",
+ });
+
+ getCmd._action!("api_key");
+
+ // Should show redacted value (first 4 + ... + last 4)
+ const logged = consoleSpy.log.mock.calls[0][0] as string;
+ expect(logged).toContain("...");
+ expect(logged).not.toContain("supersecret");
+ });
+
+ it("supports dot-notation keys like platform.api_key", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const getCmd = findCommand(configCmd, "get")!;
+
+ getCmd._action!("platform.base_url");
+
+ expect(consoleSpy.log).toHaveBeenCalledWith("https://api.mem0.ai");
+ });
+ });
+
+ // ========================================================================
+ // config set subcommand
+ // ========================================================================
+
+ describe("config set subcommand", () => {
+ it("sets a string config value via writePluginAuth", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("user_id", "alice");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: "alice" }),
+ );
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ expect.stringContaining("user_id = alice"),
+ );
+ });
+
+ it("coerces 'true' to boolean for boolean keys", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("enable_graph", "true");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ enableGraph: true }),
+ );
+ });
+
+ it("coerces 'false' to boolean false for boolean keys", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("auto_recall", "false");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ autoRecall: false }),
+ );
+ });
+
+ it("coerces '1' to boolean true for boolean keys", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("auto_capture", "1");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ autoCapture: true }),
+ );
+ });
+
+ it("coerces 'yes' to boolean true for boolean keys", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("enable_graph", "yes");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ enableGraph: true }),
+ );
+ });
+
+ it("coerces integer string for integer keys", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("top_k", "10");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ topK: 10 }),
+ );
+ });
+
+ it("errors on invalid integer value for integer keys", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("top_k", "abc");
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Invalid integer value: abc"),
+ );
+ expect(writePluginAuth).not.toHaveBeenCalled();
+ });
+
+ it("errors on unknown config key", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("unknown_key", "value");
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Unknown config key: unknown_key"),
+ );
+ expect(writePluginAuth).not.toHaveBeenCalled();
+ });
+
+ it("supports dot-notation keys for set", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("defaults.user_id", "bob");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: "bob" }),
+ );
+ });
+
+ it("redacts API key value in set confirmation output", () => {
+ const { mem0 } = setup();
+ const configCmd = findCommand(mem0, "config")!;
+ const setCmd = findCommand(configCmd, "set")!;
+
+ setCmd._action!("api_key", "m0-new-secret-key-abcd1234");
+
+ expect(writePluginAuth).toHaveBeenCalledWith(
+ expect.objectContaining({ apiKey: "m0-new-secret-key-abcd1234" }),
+ );
+ const logged = consoleSpy.log.mock.calls[0][0] as string;
+ expect(logged).toContain("...");
+ expect(logged).not.toContain("new-secret-key");
+ });
+ });
+
+ // ========================================================================
+ // dream subcommand
+ // ========================================================================
+
+ describe("dream subcommand", () => {
+ it("fetches memories and outputs dream prompt to stdout", async () => {
+ const { mem0, provider } = setup();
+ provider.getAll.mockResolvedValueOnce([
+ {
+ id: "m1",
+ memory: "User is an engineer",
+ categories: ["identity"],
+ metadata: { category: "identity", importance: 0.9 },
+ created_at: "2026-01-01",
+ },
+ ]);
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ const dreamCmd = findCommand(mem0, "dream")!;
+
+ await dreamCmd._action!({});
+
+ expect(provider.getAll).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user_id: "testuser",
+ source: "OPENCLAW",
+ }),
+ );
+ expect(loadDreamPrompt).toHaveBeenCalled();
+
+ // stdout should contain the dream prompt
+ const stdoutOutput = stdoutSpy.mock.calls.map((c) => c[0]).join("");
+ expect(stdoutOutput).toContain("");
+ expect(stdoutOutput).toContain("dream prompt");
+ expect(stdoutOutput).toContain(" {
+ const { mem0, provider } = setup();
+ provider.getAll.mockResolvedValueOnce([
+ { id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
+ ]);
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ const dreamCmd = findCommand(mem0, "dream")!;
+
+ await dreamCmd._action!({ dryRun: true });
+
+ // Dry run should write inventory to stderr, NOT dream prompt to stdout
+ expect(stderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Dry run"),
+ );
+ expect(stdoutSpy).not.toHaveBeenCalled();
+
+ stdoutSpy.mockRestore();
+ });
+
+ it("prints message when no memories to consolidate", async () => {
+ const { mem0, provider } = setup();
+ provider.getAll.mockResolvedValueOnce([]);
+ const dreamCmd = findCommand(mem0, "dream")!;
+
+ await dreamCmd._action!({});
+
+ expect(consoleSpy.log).toHaveBeenCalledWith(
+ "No memories to consolidate.",
+ );
+ });
+
+ it("prints error when dream skill file is not found", async () => {
+ const { mem0, provider } = setup();
+ provider.getAll.mockResolvedValueOnce([
+ { id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
+ ]);
+ (loadDreamPrompt as ReturnType).mockReturnValueOnce("");
+ const dreamCmd = findCommand(mem0, "dream")!;
+
+ await dreamCmd._action!({});
+
+ expect(stderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Dream skill file not found"),
+ );
+ });
+
+ it("handles dream errors gracefully", async () => {
+ const { mem0, provider } = setup();
+ provider.getAll.mockRejectedValueOnce(new Error("dream boom"));
+ const dreamCmd = findCommand(mem0, "dream")!;
+
+ await dreamCmd._action!({});
+
+ expect(consoleSpy.error).toHaveBeenCalledWith(
+ expect.stringContaining("Dream failed"),
+ );
+ });
+ });
+});
diff --git a/openclaw/tests/config-file.test.ts b/openclaw/tests/config-file.test.ts
new file mode 100644
index 000000000..127a92415
--- /dev/null
+++ b/openclaw/tests/config-file.test.ts
@@ -0,0 +1,243 @@
+/**
+ * Tests for cli/config-file.ts — file-based config helpers.
+ *
+ * All filesystem operations are mocked via fs-safe.ts so tests never
+ * touch the real disk.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../fs-safe.ts", () => ({
+ readText: vi.fn(),
+ exists: vi.fn(),
+ writeText: vi.fn(),
+ mkdirp: vi.fn(),
+ unlink: vi.fn(),
+}));
+
+import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
+import {
+ readPluginAuth,
+ writePluginAuth,
+ getBaseUrl,
+ DEFAULT_BASE_URL,
+} from "../cli/config-file.ts";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const mockExists = exists as ReturnType;
+const mockReadText = readText as ReturnType;
+const mockWriteText = writeText as ReturnType;
+const mockMkdirp = mkdirp as ReturnType;
+
+function setConfigFile(obj: Record): void {
+ mockExists.mockReturnValue(true);
+ mockReadText.mockReturnValue(JSON.stringify(obj));
+}
+
+function setNoFile(): void {
+ mockExists.mockReturnValue(false);
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// readPluginAuth
+// ---------------------------------------------------------------------------
+
+describe("readPluginAuth", () => {
+ it("returns empty object when config file does not exist", () => {
+ setNoFile();
+ expect(readPluginAuth()).toEqual({});
+ });
+
+ it("returns empty object when config has no plugins section", () => {
+ setConfigFile({ someOtherKey: true });
+ expect(readPluginAuth()).toEqual({});
+ });
+
+ it("reads all fields correctly from nested config", () => {
+ setConfigFile({
+ plugins: {
+ entries: {
+ "openclaw-mem0": {
+ enabled: true,
+ config: {
+ apiKey: "sk-test-123",
+ baseUrl: "https://custom.api.com",
+ userId: "user-1",
+ orgId: "org-1",
+ projectId: "proj-1",
+ mode: "platform",
+ enableGraph: true,
+ autoRecall: true,
+ autoCapture: false,
+ topK: 10,
+ },
+ },
+ },
+ },
+ });
+
+ const auth = readPluginAuth();
+ expect(auth).toEqual({
+ apiKey: "sk-test-123",
+ baseUrl: "https://custom.api.com",
+ userId: "user-1",
+ orgId: "org-1",
+ projectId: "proj-1",
+ mode: "platform",
+ enableGraph: true,
+ autoRecall: true,
+ autoCapture: false,
+ topK: 10,
+ });
+ });
+
+ it("handles snake_case aliases (api_key, base_url, user_id, org_id, project_id)", () => {
+ setConfigFile({
+ plugins: {
+ entries: {
+ "openclaw-mem0": {
+ enabled: true,
+ config: {
+ api_key: "sk-snake",
+ base_url: "https://snake.api.com",
+ user_id: "user-snake",
+ org_id: "org-snake",
+ project_id: "proj-snake",
+ },
+ },
+ },
+ },
+ });
+
+ const auth = readPluginAuth();
+ expect(auth.apiKey).toBe("sk-snake");
+ expect(auth.baseUrl).toBe("https://snake.api.com");
+ expect(auth.userId).toBe("user-snake");
+ expect(auth.orgId).toBe("org-snake");
+ expect(auth.projectId).toBe("proj-snake");
+ });
+
+ it("returns empty object when JSON is invalid", () => {
+ mockExists.mockReturnValue(true);
+ mockReadText.mockReturnValue("not valid json {{{");
+ expect(readPluginAuth()).toEqual({});
+ });
+});
+
+// ---------------------------------------------------------------------------
+// writePluginAuth
+// ---------------------------------------------------------------------------
+
+describe("writePluginAuth", () => {
+ it("creates nested structure from scratch when no config exists", () => {
+ setNoFile();
+ // exists returns false for both the file (readFullConfig) and the dir (writeFullConfig)
+ mockExists.mockReturnValue(false);
+
+ writePluginAuth({ apiKey: "sk-new", userId: "u1" });
+
+ expect(mockMkdirp).toHaveBeenCalled();
+ expect(mockWriteText).toHaveBeenCalledOnce();
+
+ const written = JSON.parse(mockWriteText.mock.calls[0][1]);
+ expect(written.plugins.entries["openclaw-mem0"].enabled).toBe(true);
+ expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
+ "sk-new",
+ );
+ expect(written.plugins.entries["openclaw-mem0"].config.userId).toBe("u1");
+ });
+
+ it("merges into existing config preserving other data", () => {
+ setConfigFile({
+ otherSetting: "keep-me",
+ plugins: {
+ entries: {
+ "openclaw-mem0": {
+ enabled: true,
+ config: {
+ apiKey: "sk-old",
+ mode: "platform",
+ },
+ },
+ },
+ },
+ });
+
+ writePluginAuth({ baseUrl: "https://new.api.com" });
+
+ const written = JSON.parse(mockWriteText.mock.calls[0][1]);
+ // Existing fields preserved
+ expect(written.otherSetting).toBe("keep-me");
+ expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
+ "sk-old",
+ );
+ expect(written.plugins.entries["openclaw-mem0"].config.mode).toBe(
+ "platform",
+ );
+ // New field added
+ expect(written.plugins.entries["openclaw-mem0"].config.baseUrl).toBe(
+ "https://new.api.com",
+ );
+ });
+
+ it("creates directory if missing", () => {
+ // File doesn't exist (readFullConfig returns {}), dir doesn't exist
+ mockExists.mockReturnValue(false);
+
+ writePluginAuth({ apiKey: "sk-test" });
+
+ expect(mockMkdirp).toHaveBeenCalledWith(
+ expect.stringContaining(".openclaw"),
+ 0o700,
+ );
+ });
+
+ it("skips undefined values", () => {
+ setNoFile();
+ mockExists.mockReturnValue(false);
+
+ writePluginAuth({
+ apiKey: "sk-set",
+ baseUrl: undefined,
+ userId: undefined,
+ });
+
+ const written = JSON.parse(mockWriteText.mock.calls[0][1]);
+ const cfg = written.plugins.entries["openclaw-mem0"].config;
+ expect(cfg.apiKey).toBe("sk-set");
+ expect(cfg).not.toHaveProperty("baseUrl");
+ expect(cfg).not.toHaveProperty("userId");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getBaseUrl
+// ---------------------------------------------------------------------------
+
+describe("getBaseUrl", () => {
+ it("returns configured URL when baseUrl is set", () => {
+ setConfigFile({
+ plugins: {
+ entries: {
+ "openclaw-mem0": {
+ enabled: true,
+ config: { baseUrl: "https://custom.example.com" },
+ },
+ },
+ },
+ });
+
+ expect(getBaseUrl()).toBe("https://custom.example.com");
+ });
+
+ it("returns default URL when baseUrl is not configured", () => {
+ setNoFile();
+ expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
+ });
+});
diff --git a/openclaw/tests/config.test.ts b/openclaw/tests/config.test.ts
index 10bb12e01..188209311 100644
--- a/openclaw/tests/config.test.ts
+++ b/openclaw/tests/config.test.ts
@@ -99,9 +99,9 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
// mem0ConfigSchema.parse() — mode parsing
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — mode parsing", () => {
- it('"oss" is normalized to "open-source"', () => {
- const cfg = mem0ConfigSchema.parse({ mode: "oss" });
- expect(cfg.mode).toBe("open-source");
+ it('"oss" is not a valid mode and defaults to "platform"', () => {
+ const cfg = mem0ConfigSchema.parse({ mode: "oss", apiKey: "k" });
+ expect(cfg.mode).toBe("platform");
});
it('"open-source" stays as "open-source"', () => {
@@ -184,8 +184,8 @@ describe("mem0ConfigSchema.parse() — needsSetup", () => {
expect(cfg.needsSetup).toBe(false);
});
- it("is false when mode is open-source (no apiKey needed)", () => {
- const cfg = mem0ConfigSchema.parse({ mode: "oss" });
+ it("is false when mode is 'open-source' via explicit string (no apiKey needed)", () => {
+ const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
expect(cfg.needsSetup).toBe(false);
});
diff --git a/openclaw/tests/dream-gate.test.ts b/openclaw/tests/dream-gate.test.ts
new file mode 100644
index 000000000..cf8dc26db
--- /dev/null
+++ b/openclaw/tests/dream-gate.test.ts
@@ -0,0 +1,376 @@
+/**
+ * Tests for dream-gate.ts — activity tracking, gate logic, and lock mechanism
+ * for automatic memory consolidation.
+ *
+ * All filesystem operations are mocked via fs-safe.ts.
+ * Time-dependent tests use vi.useFakeTimers().
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("../fs-safe.ts", () => ({
+ readText: vi.fn(),
+ writeText: vi.fn(),
+ mkdirp: vi.fn(),
+ unlink: vi.fn(),
+}));
+
+import { readText, writeText, mkdirp, unlink } from "../fs-safe.ts";
+import {
+ incrementSessionCount,
+ checkCheapGates,
+ checkMemoryGate,
+ acquireDreamLock,
+ releaseDreamLock,
+ recordDreamCompletion,
+ getDreamState,
+} from "../dream-gate.ts";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const mockReadText = readText as ReturnType;
+const mockWriteText = writeText as ReturnType;
+const mockMkdirp = mkdirp as ReturnType;
+const mockUnlink = unlink as ReturnType;
+
+const STATE_DIR = "/tmp/test-state";
+
+interface DreamState {
+ lastConsolidatedAt: number;
+ sessionsSince: number;
+ lastSessionId: string | null;
+}
+
+function setDreamState(state: DreamState): void {
+ mockReadText.mockImplementation((filePath: string) => {
+ if (filePath.endsWith("dream-state.json")) {
+ return JSON.stringify(state);
+ }
+ throw new Error("ENOENT");
+ });
+}
+
+function setNoState(): void {
+ mockReadText.mockImplementation(() => {
+ throw new Error("ENOENT");
+ });
+}
+
+function getWrittenState(): DreamState {
+ const call = mockWriteText.mock.calls.find((c: unknown[]) =>
+ (c[0] as string).endsWith("dream-state.json"),
+ );
+ if (!call) throw new Error("No state file written");
+ return JSON.parse(call[1] as string);
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ mockMkdirp.mockReturnValue(undefined);
+ mockUnlink.mockReturnValue(undefined);
+});
+
+// ---------------------------------------------------------------------------
+// incrementSessionCount
+// ---------------------------------------------------------------------------
+
+describe("incrementSessionCount", () => {
+ it("increments counter for a new session", () => {
+ setDreamState({
+ lastConsolidatedAt: 0,
+ sessionsSince: 3,
+ lastSessionId: "session-old",
+ });
+
+ incrementSessionCount(STATE_DIR, "session-new");
+
+ const written = getWrittenState();
+ expect(written.sessionsSince).toBe(4);
+ expect(written.lastSessionId).toBe("session-new");
+ });
+
+ it("deduplicates same session (no increment)", () => {
+ setDreamState({
+ lastConsolidatedAt: 0,
+ sessionsSince: 3,
+ lastSessionId: "session-same",
+ });
+
+ incrementSessionCount(STATE_DIR, "session-same");
+
+ // writeText should NOT have been called for the state file
+ const stateWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
+ (c[0] as string).endsWith("dream-state.json"),
+ );
+ expect(stateWrites).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// checkCheapGates
+// ---------------------------------------------------------------------------
+
+describe("checkCheapGates", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("fails time gate when consolidation was too recent", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // Last consolidated 1 hour ago, but minHours is 24
+ setDreamState({
+ lastConsolidatedAt: now - 1 * 3_600_000,
+ sessionsSince: 100,
+ lastSessionId: null,
+ });
+
+ const result = checkCheapGates(STATE_DIR, { minHours: 24, minSessions: 5 });
+ expect(result.proceed).toBe(false);
+ expect(result.reason).toContain("time");
+ });
+
+ it("fails session gate when too few sessions", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // Last consolidated 48 hours ago (passes time gate), but only 2 sessions
+ setDreamState({
+ lastConsolidatedAt: now - 48 * 3_600_000,
+ sessionsSince: 2,
+ lastSessionId: null,
+ });
+
+ const result = checkCheapGates(STATE_DIR, {
+ minHours: 24,
+ minSessions: 5,
+ });
+ expect(result.proceed).toBe(false);
+ expect(result.reason).toContain("sessions");
+ });
+
+ it("passes both gates when conditions are met", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // 48 hours ago, 10 sessions — both gates pass
+ setDreamState({
+ lastConsolidatedAt: now - 48 * 3_600_000,
+ sessionsSince: 10,
+ lastSessionId: null,
+ });
+
+ const result = checkCheapGates(STATE_DIR, {
+ minHours: 24,
+ minSessions: 5,
+ });
+ expect(result.proceed).toBe(true);
+ expect(result.reason).toBeUndefined();
+ });
+
+ it("uses defaults when config is empty", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // Never consolidated (0), 100 sessions — should pass with defaults (24h, 5 sessions)
+ setDreamState({
+ lastConsolidatedAt: 0,
+ sessionsSince: 100,
+ lastSessionId: null,
+ });
+
+ const result = checkCheapGates(STATE_DIR, {});
+ expect(result.proceed).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// checkMemoryGate
+// ---------------------------------------------------------------------------
+
+describe("checkMemoryGate", () => {
+ it("fails when too few memories", () => {
+ const result = checkMemoryGate(5, { minMemories: 20 });
+ expect(result.pass).toBe(false);
+ expect(result.reason).toContain("memories");
+ expect(result.reason).toContain("5");
+ });
+
+ it("passes when enough memories", () => {
+ const result = checkMemoryGate(25, { minMemories: 20 });
+ expect(result.pass).toBe(true);
+ expect(result.reason).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// acquireDreamLock
+// ---------------------------------------------------------------------------
+
+describe("acquireDreamLock", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("succeeds when no lock exists", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // readText throws for lock file (not found), writeText succeeds for wx create
+ mockReadText.mockImplementation(() => {
+ throw new Error("ENOENT");
+ });
+ mockWriteText.mockReturnValue(undefined);
+
+ const result = acquireDreamLock(STATE_DIR);
+ expect(result).toBe(true);
+
+ // Verify it wrote a lock file with wx flag
+ const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
+ (c[0] as string).endsWith("dream.lock"),
+ );
+ expect(lockWrite).toBeDefined();
+ const lockData = JSON.parse(lockWrite![1] as string);
+ expect(lockData.pid).toBe(process.pid);
+ expect(lockData.startedAt).toBe(now);
+ expect(lockWrite![2]).toEqual({ flag: "wx" });
+ });
+
+ it("fails when lock exists and is fresh", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // Lock was created 10 minutes ago — still fresh (< 1 hour)
+ mockReadText.mockImplementation((filePath: string) => {
+ if (filePath.endsWith("dream.lock")) {
+ return JSON.stringify({
+ pid: 12345,
+ startedAt: now - 10 * 60 * 1000,
+ });
+ }
+ throw new Error("ENOENT");
+ });
+
+ const result = acquireDreamLock(STATE_DIR);
+ expect(result).toBe(false);
+
+ // Should NOT have written a new lock
+ const lockWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
+ (c[0] as string).endsWith("dream.lock"),
+ );
+ expect(lockWrites).toHaveLength(0);
+ });
+
+ it("succeeds when lock is stale (>1hr old)", () => {
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ // Lock was created 2 hours ago — stale
+ mockReadText.mockImplementation((filePath: string) => {
+ if (filePath.endsWith("dream.lock")) {
+ return JSON.stringify({
+ pid: 99999,
+ startedAt: now - 2 * 60 * 60 * 1000,
+ });
+ }
+ throw new Error("ENOENT");
+ });
+ mockWriteText.mockReturnValue(undefined);
+
+ const result = acquireDreamLock(STATE_DIR);
+ expect(result).toBe(true);
+
+ // Should have unlinked the stale lock
+ expect(mockUnlink).toHaveBeenCalled();
+
+ // Should have written a new lock
+ const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
+ (c[0] as string).endsWith("dream.lock"),
+ );
+ expect(lockWrite).toBeDefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// releaseDreamLock
+// ---------------------------------------------------------------------------
+
+describe("releaseDreamLock", () => {
+ it("removes lock file", () => {
+ releaseDreamLock(STATE_DIR);
+ expect(mockUnlink).toHaveBeenCalledWith(
+ expect.stringContaining("dream.lock"),
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// recordDreamCompletion
+// ---------------------------------------------------------------------------
+
+describe("recordDreamCompletion", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("resets session counter and records timestamp", () => {
+ const now = 1700000000000;
+ vi.setSystemTime(now);
+
+ setDreamState({
+ lastConsolidatedAt: 0,
+ sessionsSince: 15,
+ lastSessionId: "session-xyz",
+ });
+
+ recordDreamCompletion(STATE_DIR);
+
+ const written = getWrittenState();
+ expect(written.lastConsolidatedAt).toBe(now);
+ expect(written.sessionsSince).toBe(0);
+ expect(written.lastSessionId).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getDreamState
+// ---------------------------------------------------------------------------
+
+describe("getDreamState", () => {
+ it("returns default state when no file exists", () => {
+ setNoState();
+
+ const state = getDreamState(STATE_DIR);
+ expect(state).toEqual({
+ lastConsolidatedAt: 0,
+ sessionsSince: 0,
+ lastSessionId: null,
+ });
+ });
+
+ it("returns persisted state when file exists", () => {
+ const persisted = {
+ lastConsolidatedAt: 1700000000000,
+ sessionsSince: 7,
+ lastSessionId: "session-abc",
+ };
+ setDreamState(persisted);
+
+ const state = getDreamState(STATE_DIR);
+ expect(state).toEqual(persisted);
+ });
+});
diff --git a/openclaw/tests/providers.test.ts b/openclaw/tests/providers.test.ts
new file mode 100644
index 000000000..c4cc314ad
--- /dev/null
+++ b/openclaw/tests/providers.test.ts
@@ -0,0 +1,287 @@
+/**
+ * Tests for providers.ts — providerToBackend adapter layer.
+ *
+ * Verifies that the Backend wrapper correctly delegates to the
+ * underlying Mem0Provider methods with proper argument mapping.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { providerToBackend } from "../providers.ts";
+
+// ---------------------------------------------------------------------------
+// Mock provider factory
+// ---------------------------------------------------------------------------
+
+function createMockProvider() {
+ return {
+ search: vi
+ .fn()
+ .mockResolvedValue([{ id: "m1", memory: "found", score: 0.9 }]),
+ add: vi.fn().mockResolvedValue({
+ results: [{ id: "m1", event: "ADD", memory: "stored" }],
+ }),
+ get: vi
+ .fn()
+ .mockResolvedValue({ id: "m1", memory: "test", created_at: "2026-01-01" }),
+ getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "listed" }]),
+ update: vi.fn().mockResolvedValue(undefined),
+ delete: vi.fn().mockResolvedValue(undefined),
+ deleteAll: vi.fn().mockResolvedValue(undefined),
+ history: vi.fn().mockResolvedValue([]),
+ };
+}
+
+const DEFAULT_USER = "test-user";
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// search
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — search", () => {
+ it("delegates to provider.search with correct options", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const results = await backend.search("hello world", {
+ topK: 10,
+ threshold: 0.5,
+ keyword: true,
+ rerank: true,
+ filters: { category: "preference" },
+ });
+
+ expect(provider.search).toHaveBeenCalledWith("hello world", {
+ user_id: DEFAULT_USER,
+ top_k: 10,
+ threshold: 0.5,
+ keyword_search: true,
+ reranking: true,
+ filters: { category: "preference" },
+ });
+ expect(results).toHaveLength(1);
+ expect((results[0] as any).id).toBe("m1");
+ });
+
+ it("uses default userId when opts.userId is not provided", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ await backend.search("query");
+
+ expect(provider.search).toHaveBeenCalledWith("query", {
+ user_id: DEFAULT_USER,
+ top_k: undefined,
+ threshold: undefined,
+ keyword_search: undefined,
+ reranking: undefined,
+ filters: undefined,
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// add
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — add", () => {
+ it("delegates to provider.add with content as message", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.add("Remember this fact");
+
+ expect(provider.add).toHaveBeenCalledWith(
+ [{ role: "user", content: "Remember this fact" }],
+ expect.objectContaining({ user_id: DEFAULT_USER }),
+ );
+ expect(result).toBeDefined();
+ });
+
+ it("passes messages array when provided", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const messages = [
+ { role: "user", content: "Hi" },
+ { role: "assistant", content: "Hello" },
+ ];
+ await backend.add(undefined, messages);
+
+ expect(provider.add).toHaveBeenCalledWith(
+ messages,
+ expect.objectContaining({ user_id: DEFAULT_USER }),
+ );
+ });
+
+ it("forwards optional add options", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ await backend.add("fact", undefined, {
+ runId: "run-1",
+ metadata: { source: "test" },
+ immutable: true,
+ infer: false,
+ expires: "2027-01-01",
+ enableGraph: true,
+ });
+
+ expect(provider.add).toHaveBeenCalledWith(
+ [{ role: "user", content: "fact" }],
+ expect.objectContaining({
+ user_id: DEFAULT_USER,
+ run_id: "run-1",
+ metadata: { source: "test" },
+ immutable: true,
+ infer: false,
+ expiration_date: "2027-01-01",
+ enable_graph: true,
+ }),
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// get
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — get", () => {
+ it("delegates to provider.get", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.get("mem-123");
+
+ expect(provider.get).toHaveBeenCalledWith("mem-123");
+ expect((result as any).id).toBe("m1");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listMemories
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — listMemories", () => {
+ it("delegates to provider.getAll", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const results = await backend.listMemories({ pageSize: 50 });
+
+ expect(provider.getAll).toHaveBeenCalledWith({
+ user_id: DEFAULT_USER,
+ page_size: 50,
+ });
+ expect(results).toHaveLength(1);
+ });
+
+ it("uses default userId when opts.userId is not provided", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ await backend.listMemories();
+
+ expect(provider.getAll).toHaveBeenCalledWith({
+ user_id: DEFAULT_USER,
+ page_size: undefined,
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// update
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — update", () => {
+ it("calls provider.update with content", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.update("mem-123", "updated text");
+
+ expect(provider.update).toHaveBeenCalledWith("mem-123", "updated text");
+ expect((result as any).id).toBe("mem-123");
+ expect((result as any).updated).toBe(true);
+ });
+
+ it("warns and skips when only metadata is provided (no content)", async () => {
+ const provider = createMockProvider();
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.update("mem-123", undefined, {
+ tag: "important",
+ });
+
+ expect(provider.update).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining("metadata updates are not supported"),
+ );
+ expect((result as any).id).toBe("mem-123");
+ warnSpy.mockRestore();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// delete
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — delete", () => {
+ it("calls provider.delete with memoryId", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.delete("mem-456");
+
+ expect(provider.delete).toHaveBeenCalledWith("mem-456");
+ expect((result as any).deleted).toBe("mem-456");
+ });
+
+ it("calls provider.deleteAll when opts.all is true", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.delete(undefined, {
+ all: true,
+ userId: "custom-user",
+ });
+
+ expect(provider.deleteAll).toHaveBeenCalledWith("custom-user");
+ expect((result as any).deleted).toBe("all");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// deleteEntities (platform-only)
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — deleteEntities", () => {
+ it("throws platform-only error", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ await expect(
+ backend.deleteEntities({ userId: DEFAULT_USER }),
+ ).rejects.toThrow("platform mode");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// status
+// ---------------------------------------------------------------------------
+
+describe("providerToBackend — status", () => {
+ it("returns connected: true", async () => {
+ const provider = createMockProvider();
+ const backend = providerToBackend(provider as any, DEFAULT_USER);
+
+ const result = await backend.status();
+
+ expect((result as any).connected).toBe(true);
+ expect((result as any).backend).toBe("oss");
+ });
+});
diff --git a/openclaw/tests/tools.test.ts b/openclaw/tests/tools.test.ts
index bb8a2e2de..2285e6832 100644
--- a/openclaw/tests/tools.test.ts
+++ b/openclaw/tests/tools.test.ts
@@ -7,30 +7,26 @@
*/
import { describe, it, expect, vi } from "vitest";
-import type { ToolContext } from "../tools/index.ts";
+import type { ToolDeps } from "../tools/index.ts";
import { registerAllTools } from "../tools/index.ts";
import { createMemorySearchTool } from "../tools/memory-search.ts";
-import { createMemoryStoreTool } from "../tools/memory-store.ts";
+import { createMemoryAddTool } from "../tools/memory-add.ts";
import { createMemoryGetTool } from "../tools/memory-get.ts";
import { createMemoryDeleteTool } from "../tools/memory-delete.ts";
import { createMemoryListTool } from "../tools/memory-list.ts";
+import { createMemoryUpdateTool } from "../tools/memory-update.ts";
+import { createMemoryHistoryTool } from "../tools/memory-history.ts";
// ---------------------------------------------------------------------------
// Mock helper
// ---------------------------------------------------------------------------
-function createMockToolContext(overrides = {}): ToolContext {
+function createMockToolDeps(overrides = {}): ToolDeps {
return {
api: {
registerTool: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn() },
} as any,
- backend: {
- get: vi.fn().mockResolvedValue({ id: "test-id", memory: "test memory" }),
- delete: vi.fn().mockResolvedValue(undefined),
- status: vi.fn().mockResolvedValue({ connected: true }),
- deleteEntities: vi.fn().mockResolvedValue(undefined),
- } as any,
cfg: {
mode: "platform",
userId: "testuser",
@@ -68,6 +64,7 @@ function createMockToolContext(overrides = {}): ToolContext {
agentUserId: vi.fn().mockReturnValue("testuser:agent:test"),
getCurrentSessionId: vi.fn().mockReturnValue(undefined),
skillsActive: false,
+ captureToolEvent: vi.fn(),
buildAddOptions: vi
.fn()
.mockReturnValue({ user_id: "testuser", source: "OPENCLAW" }),
@@ -84,22 +81,24 @@ function createMockToolContext(overrides = {}): ToolContext {
describe("registerAllTools", () => {
it("calls api.registerTool exactly 7 times", () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
registerAllTools(ctx);
expect(ctx.api.registerTool).toHaveBeenCalledTimes(7);
});
it("registers tools with the correct names", () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
registerAllTools(ctx);
+ // Tools are registered as required (single argument — no metadata object).
+ // The name comes from the tool definition itself (call[0]).
const names = (
ctx.api.registerTool as ReturnType
- ).mock.calls.map((call: unknown[]) => (call[1] as { name: string }).name);
+ ).mock.calls.map((call: unknown[]) => (call[0] as { name: string }).name);
expect(names).toEqual([
"memory_search",
- "memory_store",
+ "memory_add",
"memory_get",
"memory_list",
"memory_update",
@@ -107,6 +106,16 @@ describe("registerAllTools", () => {
"memory_history",
]);
});
+
+ it("registers tools without a second argument (required, not optional)", () => {
+ const ctx = createMockToolDeps();
+ registerAllTools(ctx);
+
+ const calls = (ctx.api.registerTool as ReturnType).mock.calls;
+ for (const call of calls) {
+ expect(call).toHaveLength(1);
+ }
+ });
});
// ---------------------------------------------------------------------------
@@ -116,7 +125,7 @@ describe("registerAllTools", () => {
describe("tool factory shape", () => {
const factories = [
{ fn: createMemorySearchTool, expectedName: "memory_search" },
- { fn: createMemoryStoreTool, expectedName: "memory_store" },
+ { fn: createMemoryAddTool, expectedName: "memory_add" },
{ fn: createMemoryGetTool, expectedName: "memory_get" },
{ fn: createMemoryDeleteTool, expectedName: "memory_delete" },
{ fn: createMemoryListTool, expectedName: "memory_list" },
@@ -125,7 +134,7 @@ describe("tool factory shape", () => {
for (const { fn, expectedName } of factories) {
describe(expectedName, () => {
it("returns an object with name, label, description, parameters, and execute", () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = fn(ctx);
expect(tool.name).toBe(expectedName);
@@ -146,7 +155,7 @@ describe("tool factory shape", () => {
describe("memory_search execute", () => {
it("returns formatted results when provider returns matches", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-1", {
@@ -163,7 +172,7 @@ describe("memory_search execute", () => {
});
it("returns 'no relevant memories' when provider returns empty", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
@@ -183,7 +192,7 @@ describe("memory_search execute", () => {
});
it("handles errors gracefully", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockRejectedValue(new Error("network failure")),
add: vi.fn(),
@@ -204,7 +213,7 @@ describe("memory_search execute", () => {
});
it("calls resolveUserId with provided agentId and userId", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-4", {
@@ -220,7 +229,7 @@ describe("memory_search execute", () => {
});
it("passes limit to buildSearchOptions", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-5", { query: "test", limit: 10 });
@@ -232,7 +241,7 @@ describe("memory_search execute", () => {
const searchMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem", score: 0.8 }]);
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("session-abc"),
provider: {
search: searchMock,
@@ -273,7 +282,7 @@ describe("memory_search execute", () => {
{ id: "m2", memory: "session only", score: 0.7 },
]);
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("session-xyz"),
provider: {
search: searchMock,
@@ -300,13 +309,13 @@ describe("memory_search execute", () => {
});
// ---------------------------------------------------------------------------
-// memory_store execute
+// memory_add execute
// ---------------------------------------------------------------------------
-describe("memory_store execute", () => {
+describe("memory_add execute", () => {
it("calls provider.add with the text and returns stored result", async () => {
- const ctx = createMockToolContext();
- const tool = createMemoryStoreTool(ctx);
+ const ctx = createMockToolDeps();
+ const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-1", {
text: "User prefers dark mode",
@@ -323,8 +332,8 @@ describe("memory_store execute", () => {
});
it("returns error when no text or facts are provided", async () => {
- const ctx = createMockToolContext();
- const tool = createMemoryStoreTool(ctx);
+ const ctx = createMockToolDeps();
+ const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-2", {});
@@ -333,8 +342,8 @@ describe("memory_store execute", () => {
});
it("supports facts array", async () => {
- const ctx = createMockToolContext();
- const tool = createMemoryStoreTool(ctx);
+ const ctx = createMockToolDeps();
+ const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-3", {
facts: ["fact one", "fact two"],
@@ -350,7 +359,7 @@ describe("memory_store execute", () => {
});
it("handles errors gracefully", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn().mockRejectedValue(new Error("API error")),
@@ -361,11 +370,11 @@ describe("memory_store execute", () => {
history: vi.fn(),
},
});
- const tool = createMemoryStoreTool(ctx);
+ const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-4", { text: "test" });
- expect(result.content[0].text).toContain("Memory store failed");
+ expect(result.content[0].text).toContain("Memory add failed");
expect(result.details.error).toContain("API error");
});
@@ -373,7 +382,7 @@ describe("memory_store execute", () => {
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored in skills mode" }],
});
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
skillsActive: true,
provider: {
search: vi.fn().mockResolvedValue([]),
@@ -385,7 +394,7 @@ describe("memory_store execute", () => {
history: vi.fn(),
},
});
- const tool = createMemoryStoreTool(ctx);
+ const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-5", {
text: "skills fact",
@@ -395,18 +404,17 @@ describe("memory_store execute", () => {
expect(addMock).toHaveBeenCalledOnce();
const addOpts = addMock.mock.calls[0][1];
expect(addOpts.infer).toBe(false);
- expect(addOpts.source).toBe("OPENCLAW");
expect(result.details.mode).toBe("skills");
expect(result.details.category).toBe("preference");
});
it("blocks subagent sessions from storing", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-123"),
});
- const tool = createMemoryStoreTool(ctx);
+ const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-6", { text: "subagent fact" });
@@ -419,7 +427,7 @@ describe("memory_store execute", () => {
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored" }],
});
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
skillsActive: false,
provider: {
search: searchMock,
@@ -431,7 +439,7 @@ describe("memory_store execute", () => {
history: vi.fn(),
},
});
- const tool = createMemoryStoreTool(ctx);
+ const tool = createMemoryAddTool(ctx);
await tool.execute("call-7", { text: "new fact" });
@@ -447,7 +455,7 @@ describe("memory_store execute", () => {
describe("memory_get execute", () => {
it("calls provider.get with the memoryId and returns formatted result", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemoryGetTool(ctx);
const result = await tool.execute("call-1", { memoryId: "test-id" });
@@ -461,7 +469,7 @@ describe("memory_get execute", () => {
});
it("handles errors gracefully", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -486,11 +494,11 @@ describe("memory_get execute", () => {
// ---------------------------------------------------------------------------
describe("memory_delete execute", () => {
- it("deletes by memory_id via provider.delete", async () => {
- const ctx = createMockToolContext();
+ it("deletes by memoryId via provider.delete", async () => {
+ const ctx = createMockToolDeps();
const tool = createMemoryDeleteTool(ctx);
- const result = await tool.execute("call-1", { memory_id: "mem-abc" });
+ const result = await tool.execute("call-1", { memoryId: "mem-abc" });
expect(ctx.provider!.delete).toHaveBeenCalledWith("mem-abc");
expect(result.content[0].text).toBe("Memory mem-abc deleted.");
@@ -503,7 +511,7 @@ describe("memory_delete execute", () => {
.fn()
.mockResolvedValue([{ id: "m1", memory: "match", score: 0.95 }]);
const deleteMock = vi.fn().mockResolvedValue(undefined);
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: searchMock,
add: vi.fn(),
@@ -532,7 +540,7 @@ describe("memory_delete execute", () => {
{ id: "m2", memory: "candidate two", score: 0.6 },
]);
const deleteMock = vi.fn();
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: searchMock,
add: vi.fn(),
@@ -557,7 +565,7 @@ describe("memory_delete execute", () => {
});
it("returns no matching memories when query yields empty results", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
@@ -577,7 +585,7 @@ describe("memory_delete execute", () => {
});
it("requires confirm:true for bulk delete (all)", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-5", { all: true });
@@ -588,7 +596,7 @@ describe("memory_delete execute", () => {
it("performs bulk delete when all:true and confirm:true", async () => {
const deleteAllMock = vi.fn().mockResolvedValue(undefined);
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -612,59 +620,34 @@ describe("memory_delete execute", () => {
expect(result.details.action).toBe("deleted_all");
});
- it("requires confirm:true for entity cascade delete", async () => {
- const ctx = createMockToolContext();
- const tool = createMemoryDeleteTool(ctx);
-
- const result = await tool.execute("call-7", { entity: true });
-
- expect(result.details.error).toBe("confirmation_required");
- });
-
- it("performs entity cascade delete when entity:true and confirm:true", async () => {
- const ctx = createMockToolContext();
- const tool = createMemoryDeleteTool(ctx);
-
- const result = await tool.execute("call-8", {
- entity: true,
- confirm: true,
- user_id: "alice",
- });
-
- expect(ctx.backend.deleteEntities).toHaveBeenCalledWith({
- userId: "alice",
- });
- expect(result.details.action).toBe("entity_deleted");
- });
-
it("returns error when no mode param is specified", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-9", {});
expect(result.content[0].text).toContain(
- "Provide memory_id, query, all, or entity",
+ "Provide memoryId, query, or all:true",
);
expect(result.details.error).toBe("missing_param");
});
it("blocks subagent sessions from deleting", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-456"),
});
const tool = createMemoryDeleteTool(ctx);
- const result = await tool.execute("call-10", { memory_id: "m1" });
+ const result = await tool.execute("call-10", { memoryId: "m1" });
expect(ctx.provider!.delete).not.toHaveBeenCalled();
expect(result.details.error).toBe("subagent_blocked");
});
it("handles errors gracefully", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -677,7 +660,7 @@ describe("memory_delete execute", () => {
});
const tool = createMemoryDeleteTool(ctx);
- const result = await tool.execute("call-11", { memory_id: "m1" });
+ const result = await tool.execute("call-11", { memoryId: "m1" });
expect(result.content[0].text).toContain("Memory delete failed");
expect(result.details.error).toContain("delete failed");
@@ -690,7 +673,7 @@ describe("memory_delete execute", () => {
describe("memory_list execute", () => {
it("calls provider.getAll and returns formatted list", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-1", {});
@@ -703,7 +686,7 @@ describe("memory_list execute", () => {
});
it("returns 'no memories stored' when provider returns empty", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -723,7 +706,7 @@ describe("memory_list execute", () => {
});
it("handles errors gracefully", async () => {
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -743,7 +726,7 @@ describe("memory_list execute", () => {
});
it("resolves userId from agentId", async () => {
- const ctx = createMockToolContext();
+ const ctx = createMockToolDeps();
const tool = createMemoryListTool(ctx);
await tool.execute("call-4", { agentId: "researcher" });
@@ -765,7 +748,7 @@ describe("memory_list execute", () => {
{ id: "m2", memory: "session only" },
]);
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("session-123"),
provider: {
search: vi.fn(),
@@ -790,7 +773,7 @@ describe("memory_list execute", () => {
const getAllMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem" }]);
- const ctx = createMockToolContext({
+ const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("sess-abc"),
provider: {
search: vi.fn(),
@@ -813,3 +796,202 @@ describe("memory_list execute", () => {
expect(result.details.count).toBe(1);
});
});
+
+// ---------------------------------------------------------------------------
+// memory_update execute
+// ---------------------------------------------------------------------------
+
+describe("memory_update execute", () => {
+ it("calls provider.update and returns success", async () => {
+ const ctx = createMockToolDeps();
+ const tool = createMemoryUpdateTool(ctx);
+
+ const result = await tool.execute("call-1", {
+ memoryId: "mem-123",
+ text: "Updated preference",
+ });
+
+ expect(ctx.provider!.update).toHaveBeenCalledWith(
+ "mem-123",
+ "Updated preference",
+ );
+ expect(result.content[0].text).toContain("Updated memory mem-123");
+ expect(result.content[0].text).toContain("Updated preference");
+ expect(result.details.action).toBe("updated");
+ expect(result.details.id).toBe("mem-123");
+ });
+
+ it("truncates long text in response", async () => {
+ const ctx = createMockToolDeps();
+ const tool = createMemoryUpdateTool(ctx);
+
+ const longText = "A".repeat(120);
+ const result = await tool.execute("call-2", {
+ memoryId: "mem-456",
+ text: longText,
+ });
+
+ expect(ctx.provider!.update).toHaveBeenCalledWith("mem-456", longText);
+ // The response text should contain the first 80 chars followed by "..."
+ expect(result.content[0].text).toContain("A".repeat(80) + "...");
+ expect(result.content[0].text).not.toContain("A".repeat(81));
+ expect(result.details.action).toBe("updated");
+ });
+
+ it("blocks subagent sessions", async () => {
+ const ctx = createMockToolDeps({
+ getCurrentSessionId: vi
+ .fn()
+ .mockReturnValue("agent:main:subagent:uuid-789"),
+ });
+ const tool = createMemoryUpdateTool(ctx);
+
+ const result = await tool.execute("call-3", {
+ memoryId: "mem-123",
+ text: "should not update",
+ });
+
+ expect(ctx.provider!.update).not.toHaveBeenCalled();
+ expect(result.content[0].text).toContain(
+ "not available in subagent sessions",
+ );
+ expect(result.details.error).toBe("subagent_blocked");
+ });
+
+ it("handles errors gracefully", async () => {
+ const ctx = createMockToolDeps({
+ provider: {
+ search: vi.fn(),
+ add: vi.fn(),
+ getAll: vi.fn(),
+ update: vi.fn().mockRejectedValue(new Error("update conflict")),
+ delete: vi.fn(),
+ get: vi.fn(),
+ history: vi.fn(),
+ },
+ });
+ const tool = createMemoryUpdateTool(ctx);
+
+ const result = await tool.execute("call-4", {
+ memoryId: "mem-123",
+ text: "new text",
+ });
+
+ expect(result.content[0].text).toContain("Memory update failed");
+ expect(result.content[0].text).toContain("update conflict");
+ expect(result.details.error).toContain("update conflict");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// memory_history execute
+// ---------------------------------------------------------------------------
+
+describe("memory_history execute", () => {
+ it("returns formatted history", async () => {
+ const historyEntries = [
+ {
+ event: "ADD",
+ created_at: "2026-01-01T00:00:00Z",
+ old_memory: null,
+ new_memory: "Initial memory text",
+ },
+ {
+ event: "UPDATE",
+ created_at: "2026-01-02T00:00:00Z",
+ old_memory: "Initial memory text",
+ new_memory: "Updated memory text",
+ },
+ ];
+ const ctx = createMockToolDeps({
+ provider: {
+ search: vi.fn(),
+ add: vi.fn(),
+ getAll: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ get: vi.fn(),
+ history: vi.fn().mockResolvedValue(historyEntries),
+ },
+ });
+ const tool = createMemoryHistoryTool(ctx);
+
+ const result = await tool.execute("call-1", { memoryId: "mem-abc" });
+
+ expect(ctx.provider!.history).toHaveBeenCalledWith("mem-abc");
+ expect(result.content[0].text).toContain("History for memory mem-abc");
+ expect(result.content[0].text).toContain("2 entries");
+ expect(result.content[0].text).toContain("[ADD]");
+ expect(result.content[0].text).toContain("[UPDATE]");
+ expect(result.content[0].text).toContain("Initial memory text");
+ expect(result.content[0].text).toContain("Updated memory text");
+ expect(result.content[0].text).toContain("Old: (none)");
+ expect(result.details.count).toBe(2);
+ expect(result.details.history).toEqual(historyEntries);
+ });
+
+ it("handles empty history", async () => {
+ const ctx = createMockToolDeps({
+ provider: {
+ search: vi.fn(),
+ add: vi.fn(),
+ getAll: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ get: vi.fn(),
+ history: vi.fn().mockResolvedValue([]),
+ },
+ });
+ const tool = createMemoryHistoryTool(ctx);
+
+ const result = await tool.execute("call-2", { memoryId: "mem-empty" });
+
+ expect(result.content[0].text).toBe(
+ "No history found for memory mem-empty.",
+ );
+ expect(result.details.count).toBe(0);
+ });
+
+ it("handles null history", async () => {
+ const ctx = createMockToolDeps({
+ provider: {
+ search: vi.fn(),
+ add: vi.fn(),
+ getAll: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ get: vi.fn(),
+ history: vi.fn().mockResolvedValue(null),
+ },
+ });
+ const tool = createMemoryHistoryTool(ctx);
+
+ const result = await tool.execute("call-3", { memoryId: "mem-null" });
+
+ expect(result.content[0].text).toBe(
+ "No history found for memory mem-null.",
+ );
+ expect(result.details.count).toBe(0);
+ });
+
+ it("handles errors gracefully", async () => {
+ const ctx = createMockToolDeps({
+ provider: {
+ search: vi.fn(),
+ add: vi.fn(),
+ getAll: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ get: vi.fn(),
+ history: vi.fn().mockRejectedValue(new Error("history unavailable")),
+ },
+ });
+ const tool = createMemoryHistoryTool(ctx);
+
+ const result = await tool.execute("call-4", { memoryId: "mem-err" });
+
+ expect(result.content[0].text).toContain("Memory history failed");
+ expect(result.content[0].text).toContain("history unavailable");
+ expect(result.details.error).toContain("history unavailable");
+ });
+});
diff --git a/openclaw/tools/index.ts b/openclaw/tools/index.ts
index e50d3a6fc..db4130667 100644
--- a/openclaw/tools/index.ts
+++ b/openclaw/tools/index.ts
@@ -1,76 +1,36 @@
-/**
- * Tool aggregator — single entry-point for registering all Mem0 tools.
- *
- * Re-exports the canonical `ToolContext` interface and provides
- * `registerAllTools(ctx)` which wires up every tool in one call.
- */
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
-import type { Backend } from "../backend/base.ts";
-import type {
- Mem0Config,
- Mem0Provider,
- AddOptions,
- SearchOptions,
-} from "../types.ts";
-
-// ---------------------------------------------------------------------------
-// Canonical ToolContext definition
-// ---------------------------------------------------------------------------
-
-export interface ToolContext {
- api: OpenClawPluginApi;
- backend: Backend;
- cfg: Mem0Config;
- provider?: Mem0Provider;
- resolveUserId: (opts: { agentId?: string; userId?: string }) => string;
- effectiveUserId: (sessionKey?: string) => string;
- agentUserId: (id: string) => string;
- getCurrentSessionId: () => string | undefined;
- skillsActive: boolean;
- buildAddOptions: (
- userIdOverride?: string,
- runId?: string,
- sessionKey?: string,
- ) => AddOptions;
- buildSearchOptions: (
- userIdOverride?: string,
- limit?: number,
- runId?: string,
- sessionKey?: string,
- ) => SearchOptions;
-}
-
-// ---------------------------------------------------------------------------
-// Tool factory imports
-// ---------------------------------------------------------------------------
+import type { Mem0Config, Mem0Provider, AddOptions, SearchOptions } from "../types.ts";
import { createMemorySearchTool } from "./memory-search.ts";
-import { createMemoryStoreTool } from "./memory-store.ts";
+import { createMemoryAddTool } from "./memory-add.ts";
import { createMemoryGetTool } from "./memory-get.ts";
import { createMemoryListTool } from "./memory-list.ts";
import { createMemoryUpdateTool } from "./memory-update.ts";
import { createMemoryDeleteTool } from "./memory-delete.ts";
import { createMemoryHistoryTool } from "./memory-history.ts";
-// ---------------------------------------------------------------------------
-// Aggregator
-// ---------------------------------------------------------------------------
-
-/**
- * Registers all Mem0 tools with the plugin API.
- *
- * 7 core tools for memory management:
- * search, store, get, list, update, delete, history
- */
-export function registerAllTools(ctx: ToolContext): void {
- const { api } = ctx;
-
- api.registerTool(createMemorySearchTool(ctx), { name: "memory_search" });
- api.registerTool(createMemoryStoreTool(ctx), { name: "memory_store" });
- api.registerTool(createMemoryGetTool(ctx), { name: "memory_get" });
- api.registerTool(createMemoryListTool(ctx), { name: "memory_list" });
- api.registerTool(createMemoryUpdateTool(ctx), { name: "memory_update" });
- api.registerTool(createMemoryDeleteTool(ctx), { name: "memory_delete" });
- api.registerTool(createMemoryHistoryTool(ctx), { name: "memory_history" });
+export interface ToolDeps {
+ api: OpenClawPluginApi;
+ provider: Mem0Provider;
+ cfg: Mem0Config;
+ resolveUserId: (opts: { agentId?: string; userId?: string }) => string;
+ effectiveUserId: (sessionKey?: string) => string;
+ agentUserId: (id: string) => string;
+ buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions;
+ buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions;
+ getCurrentSessionId: () => string | undefined;
+ skillsActive: boolean;
+ captureToolEvent: (toolName: string, properties: Record) => void;
+}
+
+export function registerAllTools(deps: ToolDeps): void {
+ const { api } = deps;
+
+ api.registerTool(createMemorySearchTool(deps));
+ api.registerTool(createMemoryAddTool(deps));
+ api.registerTool(createMemoryGetTool(deps));
+ api.registerTool(createMemoryListTool(deps));
+ api.registerTool(createMemoryUpdateTool(deps));
+ api.registerTool(createMemoryDeleteTool(deps));
+ api.registerTool(createMemoryHistoryTool(deps));
}
diff --git a/openclaw/tools/memory-add.ts b/openclaw/tools/memory-add.ts
new file mode 100644
index 000000000..ae5af952f
--- /dev/null
+++ b/openclaw/tools/memory-add.ts
@@ -0,0 +1,108 @@
+import { Type } from "@sinclair/typebox";
+import type { AddOptions } from "../types.ts";
+import { isSubagentSession } from "../isolation.ts";
+import { resolveCategories, ttlToExpirationDate } from "../skill-loader.ts";
+import type { ToolDeps } from "./index.ts";
+
+export function createMemoryAddTool(deps: ToolDeps) {
+ const { api, cfg, provider, resolveUserId, getCurrentSessionId, buildAddOptions, buildSearchOptions, skillsActive } = deps;
+
+ return {
+ name: "memory_add",
+ label: "Memory Add",
+ description: "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
+ parameters: Type.Object({
+ text: Type.Optional(Type.String({ description: "Single fact to remember" })),
+ facts: Type.Optional(Type.Array(Type.String(), { description: "Array of facts to store. ALL must share the same category." })),
+ category: Type.Optional(Type.String({ description: 'Category: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"' })),
+ importance: Type.Optional(Type.Number({ description: "Importance (0.0-1.0), omit for category default" })),
+ userId: Type.Optional(Type.String({ description: "User ID to scope this memory" })),
+ agentId: Type.Optional(Type.String({ description: "Agent ID namespace" })),
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Additional metadata" })),
+ longTerm: Type.Optional(Type.Boolean({ description: "Long-term (default: true). Set false for session-scoped." })),
+ }),
+
+ async execute(_toolCallId: string, params: Record) {
+ const p = params as {
+ text?: string; facts?: string[]; category?: string; importance?: number;
+ userId?: string; agentId?: string; metadata?: Record; longTerm?: boolean;
+ };
+
+ const allFacts: string[] = p.facts?.length ? p.facts : (p.text ? [p.text] : []);
+ if (allFacts.length === 0) {
+ return { content: [{ type: "text", text: "No facts provided. Pass 'text' or 'facts' array." }], details: { error: "missing_facts" } };
+ }
+
+ const start = Date.now();
+ try {
+ const currentSessionId = getCurrentSessionId();
+
+ if (isSubagentSession(currentSessionId)) {
+ return { content: [{ type: "text", text: "Memory storage is not available in subagent sessions." }], details: { error: "subagent_blocked" } };
+ }
+
+ const uid = resolveUserId({ agentId: p.agentId, userId: p.userId });
+ const runId = !(p.longTerm ?? true) && currentSessionId ? currentSessionId : undefined;
+
+ if (skillsActive) {
+ const rawMetadata = p.metadata;
+ const category = p.category ?? rawMetadata?.category as string | undefined;
+ const importance = p.importance ?? rawMetadata?.importance as number | undefined;
+ const parsedMetadata: Record = {
+ ...(rawMetadata ?? {}),
+ ...(category && { category }),
+ ...(importance !== undefined && { importance }),
+ };
+ const categories = resolveCategories(cfg.skills);
+ const catConfig = category ? categories[category] : undefined;
+ const expirationDate = catConfig ? ttlToExpirationDate(catConfig.ttl) : undefined;
+ const isImmutable = catConfig?.immutable ?? false;
+
+ const addOpts: AddOptions = {
+ user_id: uid, source: "OPENCLAW", infer: false,
+ deduced_memories: allFacts, metadata: parsedMetadata ?? {},
+ ...(expirationDate && { expiration_date: expirationDate }),
+ ...(isImmutable && { immutable: true }),
+ };
+ if (runId) addOpts.run_id = runId;
+ if (cfg.mode === "platform") {
+ addOpts.output_format = "v1.1";
+ if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) addOpts.enable_graph = true;
+ }
+
+ const result = await provider.add([{ role: "user", content: allFacts.join("\n") }], addOpts);
+ const count = result.results?.length ?? 0;
+ api.logger.info(`openclaw-mem0: stored ${count} memor${count === 1 ? "y" : "ies"} (infer=false, category=${category ?? "none"})`);
+
+ deps.captureToolEvent("memory_add", { success: true, latency_ms: Date.now() - start, fact_count: allFacts.length, mode: "skills" });
+ return {
+ content: [{ type: "text", text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map(f => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}` }],
+ details: { action: "stored", mode: "skills", category, factCount: allFacts.length, results: result.results },
+ };
+ }
+
+ 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") ?? [];
+ const updated = result.results?.filter((r) => r.event === "UPDATE") ?? [];
+ const summary = [];
+ if (added.length > 0) summary.push(`${added.length} added`);
+ if (updated.length > 0) summary.push(`${updated.length} updated`);
+ if (summary.length === 0) summary.push("No new memories extracted");
+
+ deps.captureToolEvent("memory_add", { success: true, latency_ms: Date.now() - start, fact_count: allFacts.length });
+ return {
+ content: [{ type: "text", text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}` }],
+ details: { action: "stored", results: result.results },
+ };
+ } catch (err) {
+ deps.captureToolEvent("memory_add", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory add failed: ${String(err)}` }], details: { error: String(err) } };
+ }
+ },
+ };
+}
diff --git a/openclaw/tools/memory-delete.ts b/openclaw/tools/memory-delete.ts
index f22db92b9..bd812efca 100644
--- a/openclaw/tools/memory-delete.ts
+++ b/openclaw/tools/memory-delete.ts
@@ -1,256 +1,75 @@
-/**
- * memory_delete tool — unified delete tool replacing memory_forget and memory_delete_all.
- *
- * Supports four modes:
- * 1. By memory_id — direct deletion of a specific memory
- * 2. By query — search-and-delete (auto-deletes high-confidence match, otherwise lists candidates)
- * 3. all:true — bulk-delete all memories for a user (requires confirm:true)
- * 4. entity:true — cascade-delete an entity and all its memories (requires confirm:true, platform only)
- */
-
import { Type } from "@sinclair/typebox";
import { isSubagentSession } from "../isolation.ts";
+import type { ToolDeps } from "./index.ts";
-import type { ToolContext } from "./index.ts";
-
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_delete` tool config object suitable for
- * `api.registerTool(config, { name })`.
- *
- * This replaces both `memory_forget` and `memory_delete_all` from the
- * original index.ts implementation.
- */
-export function createMemoryDeleteTool(ctx: ToolContext) {
- const {
- api,
- provider,
- resolveUserId,
- getCurrentSessionId,
- buildSearchOptions,
- backend,
- } = ctx;
+export function createMemoryDeleteTool(deps: ToolDeps) {
+ const { api, provider, resolveUserId, getCurrentSessionId, buildSearchOptions } = deps;
return {
name: "memory_delete",
label: "Memory Delete",
- description:
- "Delete memories from Mem0. Provide a specific memoryId for direct deletion, a query to search and delete, all:true for bulk deletion, or entity:true to cascade-delete an entity. Bulk operations require confirm:true. GDPR-compliant.",
+ description: "Delete memories. Provide memoryId, query to search-and-delete, or all:true for bulk deletion (requires confirm:true).",
parameters: Type.Object({
- memory_id: Type.Optional(
- Type.String({ description: "Specific memory ID to delete" }),
- ),
- query: Type.Optional(
- Type.String({
- description:
- "Search query to find memory to delete (searches and deletes best match)",
- }),
- ),
- all: Type.Optional(
- Type.Boolean({
- description:
- "Delete ALL memories matching scope filters. Requires confirm: true",
- }),
- ),
- entity: Type.Optional(
- Type.Boolean({
- description:
- "Delete entity and all its memories (cascade). Requires confirm: true",
- }),
- ),
- confirm: Type.Optional(
- Type.Boolean({
- description:
- "Must be true for bulk operations (all/entity). Safety gate.",
- }),
- ),
- user_id: Type.Optional(Type.String({ description: "Scope to user" })),
- agent_id: Type.Optional(Type.String({ description: "Scope to agent" })),
+ memoryId: Type.Optional(Type.String({ description: "Specific memory ID to delete" })),
+ query: Type.Optional(Type.String({ description: "Search query to find and delete" })),
+ agentId: Type.Optional(Type.String({ description: "Agent ID to scope deletion" })),
+ all: Type.Optional(Type.Boolean({ description: "Delete ALL memories. Requires confirm: true." })),
+ confirm: Type.Optional(Type.Boolean({ description: "Safety gate for bulk operations" })),
+ userId: Type.Optional(Type.String({ description: "User ID scope" })),
}),
async execute(_toolCallId: string, params: Record) {
- const { memory_id, query, all, entity, confirm, user_id, agent_id } =
- params as {
- memory_id?: string;
- query?: string;
- all?: boolean;
- entity?: boolean;
- confirm?: boolean;
- user_id?: string;
- agent_id?: string;
- };
+ const { memoryId, query, agentId, all, confirm, userId } = params as {
+ memoryId?: string; query?: string; agentId?: string;
+ all?: boolean; confirm?: boolean; userId?: string;
+ };
+ const start = Date.now();
try {
- // Block subagent deletes at the tool level.
- const currentSessionId = getCurrentSessionId();
- if (isSubagentSession(currentSessionId)) {
- api.logger.warn(
- "openclaw-mem0: blocked memory_delete from subagent session",
- );
- return {
- content: [
- {
- type: "text",
- text: "Memory deletion is not available in subagent sessions. The main agent handles memory.",
- },
- ],
- details: { error: "subagent_blocked" },
- };
+ if (isSubagentSession(getCurrentSessionId())) {
+ return { content: [{ type: "text", text: "Memory deletion is not available in subagent sessions." }], details: { error: "subagent_blocked" } };
}
- // Mode 1: Delete by specific memory ID
- if (memory_id) {
- await provider!.delete(memory_id);
- return {
- content: [{ type: "text", text: `Memory ${memory_id} deleted.` }],
- details: { action: "deleted", id: memory_id },
- };
+ if (memoryId) {
+ await provider.delete(memoryId);
+ deps.captureToolEvent("memory_delete", { success: true, latency_ms: Date.now() - start, delete_mode: "single" });
+ return { content: [{ type: "text", text: `Memory ${memoryId} deleted.` }], details: { action: "deleted", id: memoryId } };
}
- // Mode 2: Search-and-delete by query
if (query) {
- const uid = resolveUserId({ agentId: agent_id, userId: user_id });
- const results = await provider!.search(
- query,
- buildSearchOptions(uid, 5),
- );
-
+ const uid = resolveUserId({ agentId, userId });
+ const results = await provider.search(query, buildSearchOptions(uid, 5));
if (!results || results.length === 0) {
- return {
- content: [{ type: "text", text: "No matching memories found." }],
- details: { found: 0 },
- };
+ return { content: [{ type: "text", text: "No matching memories found." }], details: { found: 0 } };
}
-
- // If single high-confidence match, delete directly
if (results.length === 1 || (results[0].score ?? 0) > 0.9) {
- await provider!.delete(results[0].id);
- return {
- content: [
- {
- type: "text",
- text: `Deleted: "${results[0].memory}"`,
- },
- ],
- details: { action: "deleted", id: results[0].id },
- };
+ await provider.delete(results[0].id);
+ return { content: [{ type: "text", text: `Deleted: "${results[0].memory}"` }], details: { action: "deleted", id: results[0].id } };
}
-
- // Multiple ambiguous results — return candidates for user to pick
- const list = results
- .map(
- (r) =>
- `- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (score: ${((r.score ?? 0) * 100).toFixed(0)}%)`,
- )
- .join("\n");
-
- const candidates = results.map((r) => ({
- id: r.id,
- memory: r.memory,
- score: r.score,
- }));
-
+ const list = results.map((r) =>
+ `- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (${((r.score ?? 0) * 100).toFixed(0)}%)`
+ ).join("\n");
return {
- content: [
- {
- type: "text",
- text: `Found ${results.length} candidates. Specify memory_id to delete:\n${list}`,
- },
- ],
- details: { action: "candidates", candidates },
+ content: [{ type: "text", text: `Found ${results.length} candidates. Specify memoryId:\n${list}` }],
+ details: { action: "candidates", candidates: results.map((r) => ({ id: r.id, memory: r.memory, score: r.score })) },
};
}
- // Mode 3: Bulk-delete all memories for a user
if (all) {
if (!confirm) {
- return {
- content: [
- {
- type: "text",
- text: "Bulk deletion requires confirm: true. Ask the user to confirm before proceeding.",
- },
- ],
- details: { error: "confirmation_required" },
- };
+ return { content: [{ type: "text", text: "Bulk deletion requires confirm: true." }], details: { error: "confirmation_required" } };
}
-
- const uid = resolveUserId({ agentId: agent_id, userId: user_id });
- await provider!.deleteAll(uid);
- api.logger.info(
- `openclaw-mem0: deleted all memories for user ${uid}`,
- );
- return {
- content: [
- {
- type: "text",
- text: `All memories deleted for user "${uid}".`,
- },
- ],
- details: { action: "deleted_all", user_id: uid },
- };
+ const uid = resolveUserId({ agentId, userId });
+ await provider.deleteAll(uid);
+ deps.captureToolEvent("memory_delete", { success: true, latency_ms: Date.now() - start, delete_mode: "all" });
+ api.logger.info(`openclaw-mem0: deleted all memories for user ${uid}`);
+ return { content: [{ type: "text", text: `All memories deleted for user "${uid}".` }], details: { action: "deleted_all", user_id: uid } };
}
- // Mode 4: Cascade-delete entity (platform only)
- if (entity) {
- if (!confirm) {
- return {
- content: [
- {
- type: "text",
- text: "Entity deletion requires confirm: true. Ask the user to confirm before proceeding.",
- },
- ],
- details: { error: "confirmation_required" },
- };
- }
-
- const entityOpts: { userId?: string; agentId?: string } = {};
- if (user_id) entityOpts.userId = user_id;
- if (agent_id) entityOpts.agentId = agent_id;
-
- await backend.deleteEntities(entityOpts);
- api.logger.info(
- `openclaw-mem0: deleted entity (userId=${user_id ?? "default"}, agentId=${agent_id ?? "none"})`,
- );
- return {
- content: [
- {
- type: "text",
- text: `Entity and all associated memories deleted (userId=${user_id ?? "default"}, agentId=${agent_id ?? "none"}).`,
- },
- ],
- details: {
- action: "entity_deleted",
- user_id: user_id ?? "default",
- agent_id: agent_id,
- },
- };
- }
-
- // No valid mode specified
- return {
- content: [
- {
- type: "text",
- text: "Provide memory_id, query, all, or entity to specify what to delete.",
- },
- ],
- details: { error: "missing_param" },
- };
+ return { content: [{ type: "text", text: "Provide memoryId, query, or all:true." }], details: { error: "missing_param" } };
} catch (err) {
- return {
- content: [
- {
- type: "text",
- text: `Memory delete failed: ${String(err)}`,
- },
- ],
- details: { error: String(err) },
- };
+ deps.captureToolEvent("memory_delete", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory delete failed: ${String(err)}` }], details: { error: String(err) } };
}
},
};
diff --git a/openclaw/tools/memory-get.ts b/openclaw/tools/memory-get.ts
index 538a6d29f..b42e121e1 100644
--- a/openclaw/tools/memory-get.ts
+++ b/openclaw/tools/memory-get.ts
@@ -1,22 +1,8 @@
-/**
- * memory_get tool — extracted from index.ts registerTools().
- *
- * Retrieves a specific memory by its ID from Mem0.
- */
-
import { Type } from "@sinclair/typebox";
-import type { ToolContext } from "./index.ts";
+import type { ToolDeps } from "./index.ts";
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_get` tool config object suitable for
- * `api.registerTool(config, { name })`.
- */
-export function createMemoryGetTool(ctx: ToolContext) {
- const { provider } = ctx;
+export function createMemoryGetTool(deps: ToolDeps) {
+ const { provider } = deps;
return {
name: "memory_get",
@@ -28,29 +14,17 @@ export function createMemoryGetTool(ctx: ToolContext) {
async execute(_toolCallId: string, params: Record) {
const { memoryId } = params as { memoryId: string };
-
+ const start = Date.now();
try {
- const memory = await provider!.get(memoryId);
-
+ const memory = await provider.get(memoryId);
+ deps.captureToolEvent("memory_get", { success: true, latency_ms: Date.now() - start });
return {
- content: [
- {
- type: "text",
- text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`,
- },
- ],
+ content: [{ type: "text", text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}` }],
details: { memory },
};
} catch (err) {
- return {
- content: [
- {
- type: "text",
- text: `Memory get failed: ${String(err)}`,
- },
- ],
- details: { error: String(err) },
- };
+ deps.captureToolEvent("memory_get", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory get failed: ${String(err)}` }], details: { error: String(err) } };
}
},
};
diff --git a/openclaw/tools/memory-history.ts b/openclaw/tools/memory-history.ts
index 02a15f1c0..ae8c797b9 100644
--- a/openclaw/tools/memory-history.ts
+++ b/openclaw/tools/memory-history.ts
@@ -1,76 +1,36 @@
-/**
- * memory_history tool — extracted from index.ts registerTools().
- *
- * Views the edit history of a specific memory, showing all changes
- * over time including previous values, new values, and timestamps.
- */
-
import { Type } from "@sinclair/typebox";
-import type { ToolContext } from "./index.ts";
+import type { ToolDeps } from "./index.ts";
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_history` tool config object suitable for
- * `api.registerTool(config, { name })`.
- */
-export function createMemoryHistoryTool(ctx: ToolContext) {
- const { provider } = ctx;
+export function createMemoryHistoryTool(deps: ToolDeps) {
+ const { provider } = deps;
return {
name: "memory_history",
label: "Memory History",
- description:
- "View the edit history of a specific memory. Shows all changes over time including previous values, new values, and timestamps. Useful for understanding how a memory evolved.",
+ description: "View the edit history of a specific memory.",
parameters: Type.Object({
- memoryId: Type.String({
- description: "The memory ID to view history for",
- }),
+ memoryId: Type.String({ description: "The memory ID to view history for" }),
}),
async execute(_toolCallId: string, params: Record) {
const { memoryId } = params as { memoryId: string };
-
+ const start = Date.now();
try {
- const history = await provider!.history(memoryId);
-
+ const history = await provider.history(memoryId);
+ deps.captureToolEvent("memory_history", { success: true, latency_ms: Date.now() - start, result_count: history?.length ?? 0 });
if (!history || history.length === 0) {
- return {
- content: [
- {
- type: "text",
- text: `No history found for memory ${memoryId}.`,
- },
- ],
- details: { count: 0 },
- };
+ return { content: [{ type: "text", text: `No history found for memory ${memoryId}.` }], details: { count: 0 } };
}
-
- const text = history
- .map(
- (h, i) =>
- `${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`,
- )
- .join("\n\n");
-
+ const text = history.map((h, i) =>
+ `${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`
+ ).join("\n\n");
return {
- content: [
- {
- type: "text",
- text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}`,
- },
- ],
+ content: [{ type: "text", text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}` }],
details: { count: history.length, history },
};
} catch (err) {
- return {
- content: [
- { type: "text", text: `Memory history failed: ${String(err)}` },
- ],
- details: { error: String(err) },
- };
+ deps.captureToolEvent("memory_history", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory history failed: ${String(err)}` }], details: { error: String(err) } };
}
},
};
diff --git a/openclaw/tools/memory-list.ts b/openclaw/tools/memory-list.ts
index 2b0b4264c..eeac5140d 100644
--- a/openclaw/tools/memory-list.ts
+++ b/openclaw/tools/memory-list.ts
@@ -1,183 +1,64 @@
-/**
- * memory_list tool — extracted from index.ts registerTools().
- *
- * Lists all stored memories for a user or agent. Supports scope filtering
- * (session/long-term/all), deduplication, and CLI-parity parameters for
- * pagination, category filtering, date ranges, and graph support.
- */
-
import { Type } from "@sinclair/typebox";
-import type { Mem0Config, Mem0Provider, MemoryItem } from "../types.ts";
+import type { MemoryItem } from "../types.ts";
+import type { ToolDeps } from "./index.ts";
-import type { ListOptions } from "../types.ts";
-import type { ToolContext } from "./index.ts";
-
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_list` tool config object suitable for
- * `api.registerTool(config, { name })`.
- */
-export function createMemoryListTool(ctx: ToolContext) {
- const { provider, resolveUserId, getCurrentSessionId } = ctx;
+export function createMemoryListTool(deps: ToolDeps) {
+ const { provider, resolveUserId, getCurrentSessionId } = deps;
return {
name: "memory_list",
label: "Memory List",
- description:
- "List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
+ description: "List all stored memories for a user or agent.",
parameters: Type.Object({
- userId: Type.Optional(
- Type.String({
- description:
- "User ID to list memories for (default: configured userId)",
- }),
- ),
- agentId: Type.Optional(
- Type.String({
- description:
- 'Agent ID to list memories for a specific agent (e.g. "researcher"). Overrides userId.',
- }),
- ),
+ userId: Type.Optional(Type.String({ description: "User ID (default: configured)" })),
+ agentId: Type.Optional(Type.String({ description: "Agent ID namespace" })),
scope: Type.Optional(
- Type.Union(
- [
- Type.Literal("session"),
- Type.Literal("long-term"),
- Type.Literal("all"),
- ],
- {
- description:
- 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
- },
- ),
- ),
- // --- NEW CLI-parity parameters ---
- page: Type.Optional(
- Type.Number({ description: "Page number (default: 1)" }),
- ),
- pageSize: Type.Optional(
- Type.Number({ description: "Results per page (default: 100)" }),
- ),
- category: Type.Optional(
- Type.String({ description: "Filter by category" }),
- ),
- after: Type.Optional(
- Type.String({ description: "Created after date (YYYY-MM-DD)" }),
- ),
- before: Type.Optional(
- Type.String({ description: "Created before date (YYYY-MM-DD)" }),
- ),
- enableGraph: Type.Optional(
- Type.Boolean({ description: "Enable graph in listing" }),
+ Type.Union([Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")], {
+ description: 'Scope: "all" (default), "session", or "long-term"',
+ }),
),
}),
async execute(_toolCallId: string, params: Record) {
- const {
- userId,
- agentId,
- scope = "all",
- // New CLI-parity params
- page,
- pageSize,
- category,
- after,
- before,
- enableGraph,
- } = params as {
- userId?: string;
- agentId?: string;
- scope?: "session" | "long-term" | "all";
- page?: number;
- pageSize?: number;
- category?: string;
- after?: string;
- before?: string;
- enableGraph?: boolean;
+ const { userId, agentId, scope = "all" } = params as {
+ userId?: string; agentId?: string; scope?: "session" | "long-term" | "all";
};
+ const start = Date.now();
try {
let memories: MemoryItem[] = [];
const uid = resolveUserId({ agentId, userId });
const currentSessionId = getCurrentSessionId();
- // Build base options for getAll, incorporating new CLI-parity params
- const buildGetAllOpts = (extra?: { run_id?: string }): ListOptions => {
- const opts: Record = {
- user_id: uid,
- source: "OPENCLAW",
- };
- if (extra?.run_id) opts.run_id = extra.run_id;
- if (pageSize !== undefined) opts.page_size = pageSize;
- if (page !== undefined) opts.page = page;
- if (category !== undefined) opts.category = category;
- if (after !== undefined) opts.after = after;
- if (before !== undefined) opts.before = before;
- if (enableGraph !== undefined) opts.enable_graph = enableGraph;
- return opts as unknown as ListOptions;
- };
-
if (scope === "session") {
- if (currentSessionId) {
- memories = await provider!.getAll(
- buildGetAllOpts({ run_id: currentSessionId }),
- );
- }
+ if (currentSessionId) memories = await provider.getAll({ user_id: uid, run_id: currentSessionId, source: "OPENCLAW" });
} else if (scope === "long-term") {
- memories = await provider!.getAll(buildGetAllOpts());
+ memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
} else {
- // "all" — combine both scopes
- const longTerm = await provider!.getAll(buildGetAllOpts());
+ const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
let session: MemoryItem[] = [];
- if (currentSessionId) {
- session = await provider!.getAll(
- buildGetAllOpts({ run_id: currentSessionId }),
- );
- }
+ if (currentSessionId) session = await provider.getAll({ user_id: uid, run_id: currentSessionId, source: "OPENCLAW" });
const seen = new Set(longTerm.map((r) => r.id));
memories = [...longTerm, ...session.filter((r) => !seen.has(r.id))];
}
+ deps.captureToolEvent("memory_list", { success: true, latency_ms: Date.now() - start, result_count: memories.length });
+
if (!memories || memories.length === 0) {
- return {
- content: [{ type: "text", text: "No memories stored yet." }],
- details: { count: 0 },
- };
+ return { content: [{ type: "text", text: "No memories stored yet." }], details: { count: 0 } };
}
- const text = memories
- .map((r, i) => `${i + 1}. ${r.memory} (id: ${r.id})`)
- .join("\n");
-
- const sanitized = memories.map((r) => ({
- id: r.id,
- memory: r.memory,
- categories: r.categories,
- created_at: r.created_at,
- }));
-
+ const text = memories.map((r, i) => `${i + 1}. ${r.memory} (id: ${r.id})`).join("\n");
return {
- content: [
- {
- type: "text",
- text: `${memories.length} memories:\n\n${text}`,
- },
- ],
- details: { count: memories.length, memories: sanitized },
+ content: [{ type: "text", text: `${memories.length} memories:\n\n${text}` }],
+ details: {
+ count: memories.length,
+ memories: memories.map((r) => ({ id: r.id, memory: r.memory, categories: r.categories, created_at: r.created_at })),
+ },
};
} catch (err) {
- return {
- content: [
- {
- type: "text",
- text: `Memory list failed: ${String(err)}`,
- },
- ],
- details: { error: String(err) },
- };
+ deps.captureToolEvent("memory_list", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory list failed: ${String(err)}` }], details: { error: String(err) } };
}
},
};
diff --git a/openclaw/tools/memory-search.ts b/openclaw/tools/memory-search.ts
index e57897b59..c9f666127 100644
--- a/openclaw/tools/memory-search.ts
+++ b/openclaw/tools/memory-search.ts
@@ -1,243 +1,86 @@
-/**
- * memory_search tool — extracted from index.ts registerTools().
- *
- * Searches long-term and session-scoped memories stored in Mem0.
- * Supports scope filtering (session/long-term/all), categories,
- * advanced filters, and deduplication.
- */
-
import { Type } from "@sinclair/typebox";
-import type {
- Mem0Config,
- Mem0Provider,
- MemoryItem,
- SearchOptions,
-} from "../types.ts";
+import type { MemoryItem, SearchOptions } from "../types.ts";
+import type { ToolDeps } from "./index.ts";
-import type { ToolContext } from "./index.ts";
-
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_search` tool config object suitable for
- * `api.registerTool(config, { name })`.
- */
-export function createMemorySearchTool(ctx: ToolContext) {
- const {
- cfg,
- provider,
- resolveUserId,
- getCurrentSessionId,
- buildSearchOptions,
- } = ctx;
+export function createMemorySearchTool(deps: ToolDeps) {
+ const { cfg, provider, resolveUserId, buildSearchOptions, getCurrentSessionId } = deps;
return {
name: "memory_search",
label: "Memory Search",
- description:
- "Search through long-term memories stored in Mem0. Use when you need context about user preferences, past decisions, or previously discussed topics.",
+ description: "Search through long-term memories stored in Mem0.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
- limit: Type.Optional(
- Type.Number({
- description: `Max results (default: ${cfg.topK})`,
- }),
- ),
- userId: Type.Optional(
- Type.String({
- description: "User ID to scope search (default: configured userId)",
- }),
- ),
- agentId: Type.Optional(
- Type.String({
- description:
- 'Agent ID to search memories for a specific agent (e.g. "researcher"). Overrides userId.',
- }),
- ),
+ limit: Type.Optional(Type.Number({ description: `Max results (default: ${cfg.topK})` })),
+ userId: Type.Optional(Type.String({ description: "User ID to scope search" })),
+ agentId: Type.Optional(Type.String({ description: "Agent ID to search a specific agent's memories" })),
scope: Type.Optional(
- Type.Union(
- [
- Type.Literal("session"),
- Type.Literal("long-term"),
- Type.Literal("all"),
- ],
- {
- description:
- 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
- },
- ),
- ),
- categories: Type.Optional(
- Type.Array(Type.String(), {
- description:
- 'Filter results by category (e.g. ["identity", "preference"]). Only returns memories tagged with these categories.',
+ Type.Union([Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")], {
+ description: 'Scope: "long-term" (default), "session", or "all"',
}),
),
- filters: Type.Optional(
- Type.Record(Type.String(), Type.Unknown(), {
- description:
- 'Advanced filters object. Supports date ranges and metadata filtering. Examples: {"created_at": {"gte": "2026-03-01"}} for recent memories, {"AND": [{"categories": {"contains": "decision"}}, {"created_at": {"gte": "2026-01-01"}}]} for decisions this year. Operators: eq, ne, gt, gte, lt, lte, in, contains, icontains. Logical: AND, OR, NOT.',
- }),
- ),
- // --- NEW CLI-parity parameters ---
- rerank: Type.Optional(
- Type.Boolean({ description: "Enable reranking (platform only)" }),
- ),
- keyword: Type.Optional(
- Type.Boolean({ description: "Use keyword search instead of semantic" }),
- ),
- threshold: Type.Optional(
- Type.Number({ description: "Minimum similarity score (0-1)" }),
- ),
- topK: Type.Optional(
- Type.Number({ description: "Maximum number of results" }),
- ),
- fields: Type.Optional(
- Type.Array(Type.String(), {
- description: "Specific fields to return",
- }),
- ),
- enableGraph: Type.Optional(
- Type.Boolean({ description: "Enable graph memory in search" }),
- ),
+ categories: Type.Optional(Type.Array(Type.String(), { description: "Filter by category" })),
+ filters: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Advanced filters" })),
}),
async execute(_toolCallId: string, params: Record) {
const {
- query,
- limit,
- userId,
- agentId,
- scope = "all",
- categories: filterCategories,
- filters: agentFilters,
- // New CLI-parity params
- rerank,
- keyword,
- threshold,
- topK,
- fields,
- enableGraph,
+ query, limit, userId, agentId, scope = "long-term",
+ categories: filterCategories, filters: agentFilters,
} = params as {
- query: string;
- limit?: number;
- userId?: string;
- agentId?: string;
- scope?: "session" | "long-term" | "all";
- categories?: string[];
+ query: string; limit?: number; userId?: string; agentId?: string;
+ scope?: "session" | "long-term" | "all"; categories?: string[];
filters?: Record;
- rerank?: boolean;
- keyword?: boolean;
- threshold?: number;
- topK?: number;
- fields?: string[];
- enableGraph?: boolean;
};
+ const start = Date.now();
try {
let results: MemoryItem[] = [];
const uid = resolveUserId({ agentId, userId });
const currentSessionId = getCurrentSessionId();
- // Apply agent-provided filters and new CLI-parity params to search options
const applyFilters = (opts: SearchOptions): SearchOptions => {
if (filterCategories?.length) opts.categories = filterCategories;
if (agentFilters) opts.filters = agentFilters;
- // Pass through new CLI-parity parameters
- if (rerank !== undefined) opts.reranking = rerank;
- if (keyword !== undefined) opts.keyword_search = keyword;
- if (threshold !== undefined) opts.threshold = threshold;
- if (topK !== undefined) {
- opts.top_k = topK;
- opts.limit = topK;
- }
- // Note: 'fields' and 'enableGraph' are not in the legacy SearchOptions
- // type from types.ts. They are passed through for forward compatibility
- // when the backend supports them.
- if (fields !== undefined)
- (opts as unknown as Record).fields = fields;
- if (enableGraph !== undefined)
- (opts as unknown as Record).enable_graph =
- enableGraph;
return opts;
};
if (scope === "session") {
if (currentSessionId) {
- results = await provider!.search(
- query,
- applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
- );
+ results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, currentSessionId)));
}
} else if (scope === "long-term") {
- results = await provider!.search(
- query,
- applyFilters(buildSearchOptions(uid, limit)),
- );
+ results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit)));
} else {
- // "all" -- search both scopes and combine
- const longTermResults = await provider!.search(
- query,
- applyFilters(buildSearchOptions(uid, limit)),
- );
- let sessionResults: MemoryItem[] = [];
+ const longTerm = await provider.search(query, applyFilters(buildSearchOptions(uid, limit)));
+ let session: MemoryItem[] = [];
if (currentSessionId) {
- sessionResults = await provider!.search(
- query,
- applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
- );
+ session = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, currentSessionId)));
}
- // Deduplicate by ID, preferring long-term
- const seen = new Set(longTermResults.map((r) => r.id));
- results = [
- ...longTermResults,
- ...sessionResults.filter((r) => !seen.has(r.id)),
- ];
+ const seen = new Set(longTerm.map((r) => r.id));
+ results = [...longTerm, ...session.filter((r) => !seen.has(r.id))];
}
+ deps.captureToolEvent("memory_search", { success: true, latency_ms: Date.now() - start, result_count: results.length });
+
if (!results || results.length === 0) {
- return {
- content: [{ type: "text", text: "No relevant memories found." }],
- details: { count: 0 },
- };
+ return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } };
}
- const text = results
- .map(
- (r, i) =>
- `${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`,
- )
- .join("\n");
-
- const sanitized = results.map((r) => ({
- id: r.id,
- memory: r.memory,
- score: r.score,
- categories: r.categories,
- created_at: r.created_at,
- }));
+ const text = results.map((r, i) =>
+ `${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`
+ ).join("\n");
return {
- content: [
- {
- type: "text",
- text: `Found ${results.length} memories:\n\n${text}`,
- },
- ],
- details: { count: results.length, memories: sanitized },
+ content: [{ type: "text", text: `Found ${results.length} memories:\n\n${text}` }],
+ details: {
+ count: results.length,
+ memories: results.map((r) => ({ id: r.id, memory: r.memory, score: r.score, categories: r.categories, created_at: r.created_at })),
+ },
};
} catch (err) {
- return {
- content: [
- {
- type: "text",
- text: `Memory search failed: ${String(err)}`,
- },
- ],
- details: { error: String(err) },
- };
+ deps.captureToolEvent("memory_search", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory search failed: ${String(err)}` }], details: { error: String(err) } };
}
},
};
diff --git a/openclaw/tools/memory-store.ts b/openclaw/tools/memory-store.ts
deleted file mode 100644
index 94dc635b7..000000000
--- a/openclaw/tools/memory-store.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-/**
- * memory_store tool — extracted from index.ts registerTools().
- *
- * Saves important information in long-term memory via Mem0.
- * Supports skills mode (direct storage with infer=false) and
- * legacy mode (mem0 extraction LLM handles deduplication).
- */
-
-import { Type } from "@sinclair/typebox";
-import type {
- Mem0Config,
- Mem0Provider,
- AddOptions,
- SearchOptions,
-} from "../types.ts";
-import { isSubagentSession } from "../isolation.ts";
-import { resolveCategories, ttlToExpirationDate } from "../skill-loader.ts";
-
-import type { ToolContext } from "./index.ts";
-
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_store` tool config object suitable for
- * `api.registerTool(config, { name })`.
- */
-export function createMemoryStoreTool(ctx: ToolContext) {
- const {
- api,
- cfg,
- provider,
- resolveUserId,
- getCurrentSessionId,
- buildAddOptions,
- buildSearchOptions,
- skillsActive,
- } = ctx;
-
- return {
- name: "memory_store",
- label: "Memory Store",
- description:
- "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
- parameters: Type.Object({
- text: Type.Optional(
- Type.String({
- description:
- "Single fact to remember. Use 'facts' array instead when storing multiple facts from one conversation turn.",
- }),
- ),
- facts: Type.Optional(
- Type.Array(Type.String(), {
- description:
- "Array of facts to store in one call. ALL facts MUST share the same category. If a turn has facts in different categories, make one call per category. Category determines retention policy (TTL, immutability).",
- }),
- ),
- category: Type.Optional(
- Type.String({
- description:
- 'Memory category. Determines retention policy (TTL, immutability). All facts in this call inherit this category. Options: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"',
- }),
- ),
- importance: Type.Optional(
- Type.Number({
- description:
- "Importance override (0.0-1.0). Omit to use category default. Applies to all facts in this call. Defaults: identity/config 0.95, rules 0.90, preferences 0.85, decisions 0.80, projects 0.75, operational 0.60",
- }),
- ),
- userId: Type.Optional(
- Type.String({
- description: "User ID to scope this memory",
- }),
- ),
- agentId: Type.Optional(
- Type.String({
- description:
- 'Agent ID to store memory under a specific agent\'s namespace (e.g. "researcher"). Overrides userId.',
- }),
- ),
- metadata: Type.Optional(
- Type.Record(Type.String(), Type.Unknown(), {
- description: "Additional metadata to attach to this memory",
- }),
- ),
- longTerm: Type.Optional(
- Type.Boolean({
- description:
- "Store as long-term (user-scoped) memory. Default: true. Set to false for session-scoped memory.",
- }),
- ),
- // --- NEW CLI-parity parameters ---
- immutable: Type.Optional(
- Type.Boolean({
- description: "Prevent future updates to this memory",
- }),
- ),
- infer: Type.Optional(
- Type.Boolean({
- description: "Set to false to skip inference and store raw",
- }),
- ),
- expires: Type.Optional(
- Type.String({
- description: "Expiration date (YYYY-MM-DD)",
- }),
- ),
- enableGraph: Type.Optional(
- Type.Boolean({
- description: "Enable graph memory extraction",
- }),
- ),
- categories: Type.Optional(
- Type.Array(Type.String(), {
- description: "Categories for this memory",
- }),
- ),
- }),
-
- async execute(_toolCallId: string, params: Record) {
- const p = params as {
- text?: string;
- facts?: string[];
- category?: string;
- importance?: number;
- userId?: string;
- agentId?: string;
- metadata?: Record;
- longTerm?: boolean;
- // New CLI-parity params
- immutable?: boolean;
- infer?: boolean;
- expires?: string;
- enableGraph?: boolean;
- categories?: string[];
- };
- const { userId, agentId, longTerm = true } = p;
-
- // Resolve facts: prefer 'facts' array, fall back to single 'text'
- const allFacts: string[] = p.facts?.length
- ? p.facts
- : p.text
- ? [p.text]
- : [];
- if (allFacts.length === 0) {
- return {
- content: [
- {
- type: "text",
- text: "No facts provided. Pass 'text' or 'facts' array.",
- },
- ],
- details: { error: "missing_facts" },
- };
- }
-
- try {
- const currentSessionId = getCurrentSessionId();
-
- // Block subagent writes at the tool level. The system prompt
- // instructs subagents not to store, but a disobedient tool call
- // would write to a transient namespace that is never read again.
- if (isSubagentSession(currentSessionId)) {
- api.logger.warn(
- "openclaw-mem0: blocked memory_store from subagent session",
- );
- return {
- content: [
- {
- type: "text",
- text: "Memory storage is not available in subagent sessions. The main agent handles memory.",
- },
- ],
- details: { error: "subagent_blocked" },
- };
- }
-
- const uid = resolveUserId({ agentId, userId });
- const runId =
- !longTerm && currentSessionId ? currentSessionId : undefined;
-
- // Skills mode: bypass extraction LLM, store directly via infer=false
- if (skillsActive) {
- // Enforce batch homogeneity: if no category provided for a multi-fact
- // batch, warn. The prompt teaches batch-by-category but this is the
- // runtime safety net.
- if (allFacts.length > 1 && !p.category) {
- api.logger.warn(
- `openclaw-mem0: multi-fact batch (${allFacts.length} facts) without category. Retention policy defaults to uncategorized. Prompt instructs batch-by-category.`,
- );
- }
-
- // Resolve metadata: prefer explicit params, fall back to metadata record
- const rawMetadata = p.metadata;
- const category =
- p.category ?? (rawMetadata?.category as string | undefined);
- const importance =
- p.importance ?? (rawMetadata?.importance as number | undefined);
- const parsedMetadata: Record = {
- ...(rawMetadata ?? {}),
- ...(category && { category }),
- ...(importance !== undefined && { importance }),
- };
- const categories = resolveCategories(cfg.skills);
- const catConfig = category ? categories[category] : undefined;
- const expirationDate = catConfig
- ? ttlToExpirationDate(catConfig.ttl)
- : undefined;
- const isImmutable = catConfig?.immutable ?? false;
-
- // Single API call: all facts go as deduced_memories array
- const addOpts: AddOptions = {
- user_id: uid,
- source: "OPENCLAW",
- infer: false,
- deduced_memories: allFacts,
- metadata: parsedMetadata ?? {},
- ...(expirationDate && { expiration_date: expirationDate }),
- ...(isImmutable && { immutable: true }),
- };
- if (runId) addOpts.run_id = runId;
- if (cfg.mode === "platform") {
- addOpts.output_format = "v1.1";
- if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) {
- addOpts.enable_graph = true;
- }
- }
-
- // Apply new CLI-parity params (override category-derived values)
- if (p.immutable !== undefined) addOpts.immutable = p.immutable;
- if (p.infer !== undefined) addOpts.infer = p.infer;
- if (p.expires !== undefined) addOpts.expiration_date = p.expires;
- if (p.enableGraph !== undefined) addOpts.enable_graph = p.enableGraph;
- if (p.categories !== undefined)
- (addOpts as unknown as Record).categories =
- p.categories;
-
- const result = await provider!.add(
- [{ role: "user", content: allFacts.join("\n") }],
- addOpts,
- );
-
- const count = result.results?.length ?? 0;
- api.logger.info(
- `openclaw-mem0: skills-mode stored ${count} memor${count === 1 ? "y" : "ies"} from ${allFacts.length} fact(s) in 1 API call (infer=false, category=${category ?? "none"})`,
- );
-
- return {
- content: [
- {
- type: "text",
- text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map((f) => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}`,
- },
- ],
- details: {
- action: "stored",
- mode: "skills",
- infer: false,
- category,
- factCount: allFacts.length,
- results: result.results,
- },
- };
- }
-
- // Legacy mode: let mem0 extraction LLM handle it
- const combinedText = allFacts.join("\n");
-
- // Pre-check for near-duplicates so the extraction model has
- // context about existing memories and can UPDATE rather than ADD
- const preview = combinedText.slice(0, 200);
- const dedupOpts = buildSearchOptions(uid, 3);
- dedupOpts.threshold = 0.85;
- const existing = await provider!.search(preview, dedupOpts);
- if (existing.length > 0) {
- api.logger.info(
- `openclaw-mem0: found ${existing.length} similar existing memories — mem0 may update instead of add`,
- );
- }
-
- const legacyAddOpts = buildAddOptions(uid, runId, currentSessionId);
-
- // Apply new CLI-parity params to legacy mode as well
- if (p.immutable !== undefined) legacyAddOpts.immutable = p.immutable;
- if (p.infer !== undefined) legacyAddOpts.infer = p.infer;
- if (p.expires !== undefined) legacyAddOpts.expiration_date = p.expires;
- if (p.enableGraph !== undefined)
- legacyAddOpts.enable_graph = p.enableGraph;
- if (p.categories !== undefined)
- (legacyAddOpts as unknown as Record).categories =
- p.categories;
-
- const result = await provider!.add(
- [{ role: "user", content: combinedText }],
- legacyAddOpts,
- );
-
- const added = result.results?.filter((r) => r.event === "ADD") ?? [];
- const updated =
- result.results?.filter((r) => r.event === "UPDATE") ?? [];
-
- const summary = [];
- if (added.length > 0)
- summary.push(
- `${added.length} new memor${added.length === 1 ? "y" : "ies"} added`,
- );
- if (updated.length > 0)
- summary.push(
- `${updated.length} memor${updated.length === 1 ? "y" : "ies"} updated`,
- );
- if (summary.length === 0) summary.push("No new memories extracted");
-
- return {
- content: [
- {
- type: "text",
- text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}`,
- },
- ],
- details: {
- action: "stored",
- results: result.results,
- },
- };
- } catch (err) {
- return {
- content: [
- {
- type: "text",
- text: `Memory store failed: ${String(err)}`,
- },
- ],
- details: { error: String(err) },
- };
- }
- },
- };
-}
diff --git a/openclaw/tools/memory-update.ts b/openclaw/tools/memory-update.ts
index 92e7f7e15..fbb8c4f4f 100644
--- a/openclaw/tools/memory-update.ts
+++ b/openclaw/tools/memory-update.ts
@@ -1,94 +1,35 @@
-/**
- * memory_update tool — extracted from index.ts registerTools().
- *
- * Updates an existing memory's text in place. Preserves the memory's
- * history and supports subagent blocking. Supports optional metadata
- * updates for CLI parity.
- */
-
import { Type } from "@sinclair/typebox";
import { isSubagentSession } from "../isolation.ts";
-import type { ToolContext } from "./index.ts";
+import type { ToolDeps } from "./index.ts";
-// ---------------------------------------------------------------------------
-// Tool factory
-// ---------------------------------------------------------------------------
-
-/**
- * Creates the `memory_update` tool config object suitable for
- * `api.registerTool(config, { name })`.
- */
-export function createMemoryUpdateTool(ctx: ToolContext) {
- const { api, provider, getCurrentSessionId } = ctx;
+export function createMemoryUpdateTool(deps: ToolDeps) {
+ const { api, provider, getCurrentSessionId } = deps;
return {
name: "memory_update",
label: "Memory Update",
- description:
- "Update an existing memory's text in place. Use when a fact has changed and you have the memory ID. This is atomic and preserves the memory's history. Preferred over delete-then-store for corrections.",
+ description: "Update an existing memory's text in place. Atomic and preserves history.",
parameters: Type.Object({
memoryId: Type.String({ description: "The memory ID to update" }),
- text: Type.String({
- description: "The new text for this memory (replaces the old text)",
- }),
- // --- NEW CLI-parity parameter ---
- metadata: Type.Optional(
- Type.Record(Type.String(), Type.Unknown(), {
- description: "Metadata to update (JSON object)",
- }),
- ),
+ text: Type.String({ description: "The new text (replaces old)" }),
}),
async execute(_toolCallId: string, params: Record) {
- const { memoryId, text, metadata } = params as {
- memoryId: string;
- text: string;
- metadata?: Record;
- };
-
+ const { memoryId, text } = params as { memoryId: string; text: string };
+ const start = Date.now();
try {
- const currentSessionId = getCurrentSessionId();
- if (isSubagentSession(currentSessionId)) {
- api.logger.warn(
- "openclaw-mem0: blocked memory_update from subagent session",
- );
- return {
- content: [
- {
- type: "text",
- text: "Memory update is not available in subagent sessions.",
- },
- ],
- details: { error: "subagent_blocked" },
- };
+ if (isSubagentSession(getCurrentSessionId())) {
+ return { content: [{ type: "text", text: "Memory update is not available in subagent sessions." }], details: { error: "subagent_blocked" } };
}
-
- await provider!.update(memoryId, text);
-
- // If metadata was provided, note it in the response. The provider's
- // update() currently only accepts (id, text). Metadata-only updates
- // may need to go through the backend for full support.
- let metadataNote = "";
- if (metadata && Object.keys(metadata).length > 0) {
- metadataNote = `\nNote: metadata was provided but the current provider only supports text updates. Metadata-only updates may require backend support.`;
- }
-
+ await provider.update(memoryId, text);
+ deps.captureToolEvent("memory_update", { success: true, latency_ms: Date.now() - start });
return {
- content: [
- {
- type: "text",
- text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"${metadataNote}`,
- },
- ],
- details: { action: "updated", id: memoryId, metadata: metadata },
+ content: [{ type: "text", text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"` }],
+ details: { action: "updated", id: memoryId },
};
} catch (err) {
- return {
- content: [
- { type: "text", text: `Memory update failed: ${String(err)}` },
- ],
- details: { error: String(err) },
- };
+ deps.captureToolEvent("memory_update", { success: false, latency_ms: Date.now() - start, error: String(err) });
+ return { content: [{ type: "text", text: `Memory update failed: ${String(err)}` }], details: { error: String(err) } };
}
},
};
diff --git a/openclaw/tsup.config.ts b/openclaw/tsup.config.ts
index 7219edb95..f47f6d802 100644
--- a/openclaw/tsup.config.ts
+++ b/openclaw/tsup.config.ts
@@ -1,9 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
- entry: ["index.ts"],
+ entry: ["index.ts", "fs-safe.ts"],
format: ["esm"],
+ splitting: true,
dts: true,
sourcemap: true,
clean: true,
+ external: [/^node:/, "fs", "os", "path", "url", "readline", "module"],
});