Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144627c4ce | ||
|
|
6984958138 | ||
|
|
b13748c446 | ||
|
|
4642a1d6e3 | ||
|
|
686d5e987d | ||
|
|
c55447c1e4 | ||
|
|
ee67602c58 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface Backend {
|
||||
|
||||
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
||||
|
||||
ping(): Promise<Record<string, unknown>>;
|
||||
|
||||
status(opts?: { userId?: string; agentId?: string }): Promise<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { PlatformConfig } from "../config.js";
|
||||
import { isAgentMode } from "../state.js";
|
||||
import { CLI_VERSION } from "../version.js";
|
||||
import {
|
||||
APIError,
|
||||
type AddOptions,
|
||||
@@ -24,6 +26,9 @@ export class PlatformBackend implements Backend {
|
||||
this.headers = {
|
||||
Authorization: `Token ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "node",
|
||||
"X-Mem0-Client-Version": CLI_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,9 +43,14 @@ export class PlatformBackend implements Backend {
|
||||
url += `?${qs}`;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...this.headers,
|
||||
"X-Mem0-Caller-Type": isAgentMode() ? "agent" : "user",
|
||||
};
|
||||
|
||||
const fetchOpts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
};
|
||||
if (opts?.json) {
|
||||
@@ -286,11 +296,15 @@ export class PlatformBackend implements Backend {
|
||||
return result;
|
||||
}
|
||||
|
||||
async ping(): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", "/v1/ping/")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async status(
|
||||
opts: { userId?: string; agentId?: string } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this._request("GET", "/v1/ping/");
|
||||
await this.ping();
|
||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
|
||||
@@ -41,10 +41,16 @@ async function emailLogin(
|
||||
const url = baseUrl.replace(/\/+$/, "");
|
||||
let codeValue = code;
|
||||
|
||||
const sourceHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "node",
|
||||
};
|
||||
|
||||
if (!codeValue) {
|
||||
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: sourceHeaders,
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
@@ -85,7 +91,7 @@ async function emailLogin(
|
||||
|
||||
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: sourceHeaders,
|
||||
body: JSON.stringify({ email, code: codeValue.trim() }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
@@ -215,6 +221,16 @@ async function validatePlatform(config: Mem0Config): Promise<void> {
|
||||
});
|
||||
if (status.connected) {
|
||||
printSuccess("Connected to mem0 Platform!");
|
||||
// Cache user_email from ping response for telemetry distinct_id
|
||||
try {
|
||||
const pingData = (await backend.ping()) as Record<string, unknown>;
|
||||
const userEmail = pingData?.user_email as string | undefined;
|
||||
if (userEmail) {
|
||||
config.platform.userEmail = userEmail;
|
||||
}
|
||||
} catch {
|
||||
/* ignore — telemetry ID will fall back to API key hash */
|
||||
}
|
||||
} else {
|
||||
printError(
|
||||
`Could not connect: ${status.error ?? "Unknown error"}`,
|
||||
@@ -307,6 +323,7 @@ export async function runInit(
|
||||
|
||||
config.platform.apiKey = apiKeyVal;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.platform.userEmail = email;
|
||||
config.defaults.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
|
||||
@@ -385,6 +402,7 @@ export async function runInit(
|
||||
|
||||
config.platform.apiKey = apiKeyVal;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.platform.userEmail = email;
|
||||
config.defaults.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const CONFIG_VERSION = 1;
|
||||
export interface PlatformConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
@@ -49,6 +50,7 @@ export function createDefaultConfig(): Mem0Config {
|
||||
platform: {
|
||||
apiKey: "",
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
userEmail: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -70,6 +72,7 @@ export function loadConfig(): Mem0Config {
|
||||
const plat = data.platform ?? {};
|
||||
config.platform.apiKey = plat.api_key ?? "";
|
||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||
config.platform.userEmail = plat.user_email ?? "";
|
||||
|
||||
const defaults = data.defaults ?? {};
|
||||
config.defaults.userId = defaults.user_id ?? "";
|
||||
@@ -114,6 +117,7 @@ export function saveConfig(config: Mem0Config): void {
|
||||
platform: {
|
||||
api_key: config.platform.apiKey,
|
||||
base_url: config.platform.baseUrl,
|
||||
user_email: config.platform.userEmail,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,6 +135,7 @@ export function redactKey(key: string): string {
|
||||
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
||||
"platform.api_key": ["platform", "apiKey"],
|
||||
"platform.base_url": ["platform", "baseUrl"],
|
||||
"platform.user_email": ["platform", "userEmail"],
|
||||
"defaults.user_id": ["defaults", "userId"],
|
||||
"defaults.agent_id": ["defaults", "agentId"],
|
||||
"defaults.app_id": ["defaults", "appId"],
|
||||
@@ -139,6 +144,7 @@ const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
||||
// Short-form aliases
|
||||
api_key: ["platform", "apiKey"],
|
||||
base_url: ["platform", "baseUrl"],
|
||||
user_email: ["platform", "userEmail"],
|
||||
user_id: ["defaults", "userId"],
|
||||
agent_id: ["defaults", "agentId"],
|
||||
app_id: ["defaults", "appId"],
|
||||
|
||||
+106
-22
@@ -8,22 +8,27 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { type Backend, getBackend } from "./backend/index.js";
|
||||
import { colors, printError } from "./branding.js";
|
||||
import { AuthError, type Backend, getBackend } from "./backend/index.js";
|
||||
import { colors, printError, printWarning } from "./branding.js";
|
||||
import type { Mem0Config } from "./config.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { loadConfig, saveConfig } from "./config.js";
|
||||
import { richFormatHelp } from "./help.js";
|
||||
import { setAgentMode } from "./state.js";
|
||||
import { captureEvent } from "./telemetry.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
// ── Validated user identity (set by getBackendAndConfig) ─────────────────
|
||||
|
||||
let _validatedUserEmail: string | undefined;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getBackendAndConfig(
|
||||
async function getBackendAndConfig(
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
): { backend: Backend; config: Mem0Config } {
|
||||
): Promise<{ backend: Backend; config: Mem0Config }> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (apiKey) config.platform.apiKey = apiKey;
|
||||
@@ -37,11 +42,51 @@ function getBackendAndConfig(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { backend: getBackend(config), config };
|
||||
const backend = getBackend(config);
|
||||
|
||||
// Validate the API key upfront with a fast timeout
|
||||
try {
|
||||
const pingData = (await Promise.race([
|
||||
backend.ping(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), 5000),
|
||||
),
|
||||
])) as Record<string, unknown>;
|
||||
|
||||
const email = pingData?.user_email as string | undefined;
|
||||
if (email) {
|
||||
_validatedUserEmail = email;
|
||||
if (config.platform.userEmail !== email) {
|
||||
config.platform.userEmail = email;
|
||||
try {
|
||||
saveConfig(config);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
printError(
|
||||
"Invalid or expired API key.",
|
||||
"Run 'mem0 init' or set MEM0_API_KEY environment variable.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Network error / timeout — warn but proceed
|
||||
printWarning(
|
||||
"Could not validate API key (network issue). Proceeding anyway.",
|
||||
);
|
||||
}
|
||||
|
||||
return { backend, config };
|
||||
}
|
||||
|
||||
function getBackendOnly(apiKey?: string, baseUrl?: string): Backend {
|
||||
return getBackendAndConfig(apiKey, baseUrl).backend;
|
||||
async function getBackendOnly(
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
): Promise<Backend> {
|
||||
return (await getBackendAndConfig(apiKey, baseUrl)).backend;
|
||||
}
|
||||
|
||||
function checkAgentMode(): boolean {
|
||||
@@ -123,6 +168,30 @@ program
|
||||
.addHelpCommand(false)
|
||||
.configureHelp({ formatHelp: richFormatHelp });
|
||||
|
||||
// ── Telemetry hook ───────────────────────────────────────────────────────
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
try {
|
||||
const commandName = actionCommand.name();
|
||||
const parentName = actionCommand.parent?.name();
|
||||
const fullCommand =
|
||||
parentName && parentName !== "mem0"
|
||||
? `${parentName}.${commandName}`
|
||||
: commandName;
|
||||
const isAgent = !!(program.opts().json || program.opts().agent);
|
||||
captureEvent(
|
||||
`cli.${fullCommand}`,
|
||||
{
|
||||
command: fullCommand,
|
||||
is_agent: isAgent,
|
||||
},
|
||||
_validatedUserEmail,
|
||||
);
|
||||
} catch {
|
||||
/* silently swallow */
|
||||
}
|
||||
});
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
@@ -179,7 +248,10 @@ program
|
||||
.action(async (text, opts) => {
|
||||
const { cmdAdd } = await import("./commands/memory.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const { backend, config } = await getBackendAndConfig(
|
||||
opts.apiKey,
|
||||
opts.baseUrl,
|
||||
);
|
||||
const ids = resolveIds(config, opts);
|
||||
const enableGraph = resolveGraph(config, opts);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
@@ -233,7 +305,10 @@ program
|
||||
}
|
||||
const { cmdSearch } = await import("./commands/memory.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const { backend, config } = await getBackendAndConfig(
|
||||
opts.apiKey,
|
||||
opts.baseUrl,
|
||||
);
|
||||
const ids = resolveIds(config, opts);
|
||||
const enableGraph = resolveGraph(config, opts);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
@@ -265,7 +340,7 @@ program
|
||||
.action(async (memoryId, opts) => {
|
||||
const { cmdGet } = await import("./commands/memory.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdGet(backend, memoryId, { output });
|
||||
});
|
||||
@@ -301,7 +376,10 @@ program
|
||||
.action(async (opts) => {
|
||||
const { cmdList } = await import("./commands/memory.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const { backend, config } = await getBackendAndConfig(
|
||||
opts.apiKey,
|
||||
opts.baseUrl,
|
||||
);
|
||||
const ids = resolveIds(config, opts);
|
||||
const enableGraph = resolveGraph(config, opts);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
@@ -337,7 +415,7 @@ program
|
||||
}
|
||||
const { cmdUpdate } = await import("./commands/memory.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdUpdate(backend, memoryId, resolvedText, {
|
||||
metadata: opts.metadata,
|
||||
@@ -407,7 +485,7 @@ program
|
||||
// ── Dispatch: single memory ──
|
||||
if (memoryId) {
|
||||
const { cmdDelete } = await import("./commands/memory.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdDelete(backend, memoryId, {
|
||||
output,
|
||||
dryRun: opts.dryRun,
|
||||
@@ -419,7 +497,7 @@ program
|
||||
// ── Dispatch: --all ──
|
||||
if (opts.all) {
|
||||
const { cmdDeleteAll } = await import("./commands/memory.js");
|
||||
const { backend, config } = getBackendAndConfig(
|
||||
const { backend, config } = await getBackendAndConfig(
|
||||
opts.apiKey,
|
||||
opts.baseUrl,
|
||||
);
|
||||
@@ -444,7 +522,7 @@ program
|
||||
// ── Dispatch: --entity ──
|
||||
if (opts.entity) {
|
||||
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
await cmdEntitiesDelete(backend, { ...opts, output });
|
||||
return;
|
||||
}
|
||||
@@ -519,7 +597,7 @@ entityCmd
|
||||
.action(async (entityType, opts) => {
|
||||
const { cmdEntitiesList } = await import("./commands/entities.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdEntitiesList(backend, entityType, { output });
|
||||
});
|
||||
@@ -543,7 +621,7 @@ entityCmd
|
||||
.action(async (opts) => {
|
||||
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdEntitiesDelete(backend, { ...opts, output });
|
||||
});
|
||||
@@ -569,7 +647,7 @@ eventCmd
|
||||
.action(async (opts) => {
|
||||
const { cmdEventList } = await import("./commands/events.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdEventList(backend, { output });
|
||||
});
|
||||
@@ -587,7 +665,7 @@ eventCmd
|
||||
.action(async (eventId, opts) => {
|
||||
const { cmdEventStatus } = await import("./commands/events.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdEventStatus(backend, eventId, { output });
|
||||
});
|
||||
@@ -604,7 +682,10 @@ program
|
||||
.action(async (opts) => {
|
||||
const { cmdStatus } = await import("./commands/utils.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const { backend, config } = await getBackendAndConfig(
|
||||
opts.apiKey,
|
||||
opts.baseUrl,
|
||||
);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdStatus(backend, {
|
||||
userId: config.defaults.userId || undefined,
|
||||
@@ -628,7 +709,10 @@ program
|
||||
.action(async (filePath, opts) => {
|
||||
const { cmdImport } = await import("./commands/utils.js");
|
||||
const isAgent = checkAgentMode();
|
||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
||||
const { backend, config } = await getBackendAndConfig(
|
||||
opts.apiKey,
|
||||
opts.baseUrl,
|
||||
);
|
||||
const ids = resolveIds(config, opts);
|
||||
const output = isAgent ? "agent" : opts.output;
|
||||
await cmdImport(backend, filePath, {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* CLI telemetry — anonymous usage tracking via PostHog.
|
||||
*
|
||||
* Sends fire-and-forget events by spawning a detached child process
|
||||
* (telemetry-sender.cjs). The parent CLI process exits immediately;
|
||||
* the child handles email resolution, caching, and the HTTP POST.
|
||||
*
|
||||
* Disable with: MEM0_TELEMETRY=false
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { CONFIG_FILE, loadConfig } from "./config.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SENDER_SCRIPT = path.join(__dirname, "..", "telemetry-sender.cjs");
|
||||
|
||||
function isTelemetryEnabled(): boolean {
|
||||
try {
|
||||
return process.env.MEM0_TELEMETRY !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stable anonymous identifier for the current user.
|
||||
*
|
||||
* Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback.
|
||||
* Matches the SDK pattern in mem0-ts/src/client/mem0.ts.
|
||||
*/
|
||||
function getDistinctId(): string {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (config.platform.userEmail) {
|
||||
return config.platform.userEmail;
|
||||
}
|
||||
if (config.platform.apiKey) {
|
||||
return createHash("md5").update(config.platform.apiKey).digest("hex");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "anonymous-cli";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a PostHog event (non-blocking, returns void, never throws).
|
||||
* Spawns telemetry-sender.cjs as a detached subprocess.
|
||||
*
|
||||
* When `preResolvedEmail` is provided (e.g. from an upfront ping
|
||||
* validation), it is used directly as the PostHog distinct ID and the
|
||||
* subprocess skips its own `/v1/ping/` call.
|
||||
*/
|
||||
export function captureEvent(
|
||||
eventName: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
preResolvedEmail?: string,
|
||||
): void {
|
||||
if (!isTelemetryEnabled()) return;
|
||||
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const distinctId = preResolvedEmail || getDistinctId();
|
||||
|
||||
const payload = {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId,
|
||||
event: eventName,
|
||||
properties: {
|
||||
source: "CLI",
|
||||
language: "node",
|
||||
cli_version: CLI_VERSION,
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
...properties,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
|
||||
const context = {
|
||||
payload,
|
||||
posthogHost: POSTHOG_HOST,
|
||||
needsEmail: !distinctId || !distinctId.includes("@"),
|
||||
mem0ApiKey: config.platform.apiKey || "",
|
||||
mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai",
|
||||
configPath: CONFIG_FILE,
|
||||
};
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[SENDER_SCRIPT, JSON.stringify(context)],
|
||||
{ detached: true, stdio: "ignore" },
|
||||
);
|
||||
child.unref();
|
||||
} catch {
|
||||
/* silently swallow */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Standalone telemetry sender — runs as a detached child process.
|
||||
*
|
||||
* Usage: node telemetry-sender.cjs '<json context>'
|
||||
*
|
||||
* This script is spawned by telemetry.captureEvent() and runs independently
|
||||
* of the parent CLI process. It:
|
||||
*
|
||||
* 1. Resolves the user's email via /v1/ping/ if not already cached
|
||||
* 2. Caches the email in ~/.mem0/config.json for future runs
|
||||
* 3. Sends the PostHog event
|
||||
*
|
||||
* All errors are silently swallowed — this process must never produce output
|
||||
* or affect the user experience.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const https = require("https");
|
||||
const fs = require("fs");
|
||||
|
||||
function httpsRequest(url, method, headers, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const opts = {
|
||||
hostname: u.hostname,
|
||||
path: u.pathname + u.search,
|
||||
method,
|
||||
headers,
|
||||
timeout: 10000,
|
||||
};
|
||||
const req = https.request(opts, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("timeout"));
|
||||
});
|
||||
if (body) {
|
||||
req.end(body);
|
||||
} else {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAndCacheEmail(ctx, payload) {
|
||||
try {
|
||||
const pingUrl = ctx.mem0BaseUrl.replace(/\/+$/, "") + "/v1/ping/";
|
||||
const data = await httpsRequest(pingUrl, "GET", {
|
||||
Authorization: "Token " + ctx.mem0ApiKey,
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
if (data.user_email) {
|
||||
payload.distinct_id = data.user_email;
|
||||
cacheEmail(ctx.configPath, data.user_email);
|
||||
}
|
||||
} catch {
|
||||
// silently swallow
|
||||
}
|
||||
}
|
||||
|
||||
function cacheEmail(configPath, email) {
|
||||
if (!configPath) return;
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const cfg = JSON.parse(raw);
|
||||
if (!cfg.platform) cfg.platform = {};
|
||||
cfg.platform.user_email = email;
|
||||
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
||||
} catch {
|
||||
// silently swallow
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPosthogEvent(posthogHost, payload) {
|
||||
try {
|
||||
const body = JSON.stringify(payload);
|
||||
await httpsRequest(posthogHost, "POST", {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
}, body);
|
||||
} catch {
|
||||
// silently swallow
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = JSON.parse(process.argv[2]);
|
||||
const payload = ctx.payload;
|
||||
|
||||
if (ctx.needsEmail && ctx.mem0ApiKey) {
|
||||
await resolveAndCacheEmail(ctx, payload);
|
||||
}
|
||||
|
||||
await sendPosthogEvent(ctx.posthogHost, payload);
|
||||
}
|
||||
|
||||
main().catch(() => {});
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0-cli"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json as _json
|
||||
import os
|
||||
import stat as _stat_mod
|
||||
@@ -12,7 +13,7 @@ import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.branding import BRAND_COLOR, print_error
|
||||
from mem0_cli.branding import BRAND_COLOR, print_error, print_warning
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
@@ -55,6 +56,44 @@ event_app = typer.Typer(
|
||||
# entity_app and event_app registered after Memory commands to control panel ordering
|
||||
|
||||
|
||||
# ── Validated user identity (set by _get_backend_and_config) ──────────────
|
||||
|
||||
_validated_user_email: str | None = None
|
||||
|
||||
# ── Telemetry helper ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fire_telemetry(command_name: str, extra: dict | None = None) -> None:
|
||||
"""Fire a PostHog telemetry event (non-blocking, never fails)."""
|
||||
try:
|
||||
from mem0_cli.telemetry import capture_event
|
||||
|
||||
props = {"command": command_name}
|
||||
if extra:
|
||||
props.update(extra)
|
||||
capture_event(f"cli.{command_name}", props, pre_resolved_email=_validated_user_email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@config_app.callback(invoke_without_command=True)
|
||||
def _config_callback(ctx: typer.Context) -> None:
|
||||
if ctx.invoked_subcommand:
|
||||
_fire_telemetry(f"config.{ctx.invoked_subcommand}")
|
||||
|
||||
|
||||
@entity_app.callback(invoke_without_command=True)
|
||||
def _entity_callback(ctx: typer.Context) -> None:
|
||||
if ctx.invoked_subcommand:
|
||||
_fire_telemetry(f"entity.{ctx.invoked_subcommand}")
|
||||
|
||||
|
||||
@event_app.callback(invoke_without_command=True)
|
||||
def _event_callback(ctx: typer.Context) -> None:
|
||||
if ctx.invoked_subcommand:
|
||||
_fire_telemetry(f"event.{ctx.invoked_subcommand}")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -62,9 +101,16 @@ def _get_backend_and_config(
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
):
|
||||
"""Build and return the Platform backend plus the loaded config."""
|
||||
"""Build and return the Platform backend plus the loaded config.
|
||||
|
||||
Validates the API key upfront via ``/v1/ping/`` and caches the
|
||||
resolved user email for telemetry.
|
||||
"""
|
||||
global _validated_user_email
|
||||
|
||||
from mem0_cli.backend import get_backend
|
||||
from mem0_cli.config import load_config
|
||||
from mem0_cli.backend.platform import AuthError
|
||||
from mem0_cli.config import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
|
||||
@@ -81,7 +127,29 @@ def _get_backend_and_config(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
return get_backend(config), config
|
||||
backend = get_backend(config)
|
||||
|
||||
# Validate the API key upfront with a fast timeout
|
||||
try:
|
||||
ping_data = backend.ping(timeout=5.0)
|
||||
email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
|
||||
if email:
|
||||
_validated_user_email = email
|
||||
if config.platform.user_email != email:
|
||||
config.platform.user_email = email
|
||||
with contextlib.suppress(Exception):
|
||||
save_config(config)
|
||||
except AuthError:
|
||||
print_error(
|
||||
err_console,
|
||||
"Invalid or expired API key.",
|
||||
hint="Run 'mem0 init' or set MEM0_API_KEY environment variable.",
|
||||
)
|
||||
raise typer.Exit(1) from None
|
||||
except Exception:
|
||||
print_warning(err_console, "Could not validate API key (network issue). Proceeding anyway.")
|
||||
|
||||
return backend, config
|
||||
|
||||
|
||||
def _get_backend(
|
||||
@@ -165,8 +233,11 @@ def main_callback(
|
||||
if version:
|
||||
from mem0_cli.commands.utils import cmd_version
|
||||
|
||||
_fire_telemetry("version")
|
||||
cmd_version()
|
||||
raise typer.Exit()
|
||||
if ctx.invoked_subcommand:
|
||||
_fire_telemetry(ctx.invoked_subcommand)
|
||||
|
||||
|
||||
# ── Memory: add ───────────────────────────────────────────────────────────
|
||||
@@ -571,12 +642,14 @@ def delete(
|
||||
|
||||
# ── Dispatch ─────────────────────────────────────────────────────
|
||||
if memory_id is not None:
|
||||
_fire_telemetry("delete", {"delete_mode": "single"})
|
||||
from mem0_cli.commands.memory import cmd_delete
|
||||
|
||||
backend = _get_backend(api_key, base_url)
|
||||
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
|
||||
|
||||
elif all_:
|
||||
_fire_telemetry("delete", {"delete_mode": "all"})
|
||||
from mem0_cli.commands.memory import cmd_delete_all
|
||||
|
||||
backend, config = _get_backend_and_config(api_key, base_url)
|
||||
@@ -584,6 +657,7 @@ def delete(
|
||||
cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output)
|
||||
|
||||
else: # --entity
|
||||
_fire_telemetry("delete", {"delete_mode": "entity"})
|
||||
from mem0_cli.commands.entities import cmd_entities_delete
|
||||
|
||||
backend = _get_backend(api_key, base_url)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.config import PlatformConfig
|
||||
|
||||
@@ -21,11 +22,17 @@ class PlatformBackend(Backend):
|
||||
headers={
|
||||
"Authorization": f"Token {config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
"X-Mem0-Client-Version": __version__,
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
|
||||
resp = self._client.request(method, path, **kwargs)
|
||||
if resp.status_code == 401:
|
||||
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
|
||||
@@ -281,6 +288,20 @@ class PlatformBackend(Backend):
|
||||
result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/")
|
||||
return result
|
||||
|
||||
def ping(self, timeout: float | None = None) -> dict:
|
||||
"""Call the ping endpoint and return the raw response.
|
||||
|
||||
When *timeout* is given it overrides the client-level timeout so that
|
||||
validation pings can fail fast without blocking the user.
|
||||
"""
|
||||
if timeout is not None:
|
||||
resp = self._client.get("/v1/ping/", timeout=timeout)
|
||||
if resp.status_code == 401:
|
||||
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return self._request("GET", "/v1/ping/")
|
||||
|
||||
def status(
|
||||
self,
|
||||
*,
|
||||
@@ -289,7 +310,7 @@ class PlatformBackend(Backend):
|
||||
) -> dict[str, Any]:
|
||||
"""Check connectivity using the ping endpoint."""
|
||||
try:
|
||||
self._request("GET", "/v1/ping/")
|
||||
self.ping()
|
||||
return {"connected": True, "backend": "platform", "base_url": self.base_url}
|
||||
except Exception as e:
|
||||
return {"connected": False, "backend": "platform", "error": str(e)}
|
||||
|
||||
@@ -108,6 +108,10 @@ def _email_login(
|
||||
The caller expects at minimum an ``api_key`` field.
|
||||
"""
|
||||
url = base_url.rstrip("/")
|
||||
_source_headers = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# If code is already provided, skip sending — user already has a code
|
||||
@@ -116,6 +120,7 @@ def _email_login(
|
||||
resp = client.post(
|
||||
f"{url}/api/v1/auth/email_code/",
|
||||
json={"email": email},
|
||||
headers=_source_headers,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
@@ -148,6 +153,7 @@ def _email_login(
|
||||
resp = client.post(
|
||||
f"{url}/api/v1/auth/email_code/verify/",
|
||||
json={"email": email, "code": code.strip()},
|
||||
headers=_source_headers,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
@@ -229,6 +235,7 @@ def run_init(
|
||||
raise typer.Exit(1)
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.platform.user_email = email
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
@@ -299,6 +306,7 @@ def run_init(
|
||||
raise typer.Exit(1)
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.platform.user_email = email_addr
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
@@ -384,6 +392,14 @@ def _validate_platform(config: Mem0Config) -> None:
|
||||
)
|
||||
if status.get("connected"):
|
||||
print_success(console, "Connected to mem0 Platform!")
|
||||
# Cache user_email from ping response for telemetry distinct_id
|
||||
try:
|
||||
ping_data = backend.ping()
|
||||
user_email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
|
||||
if user_email:
|
||||
config.platform.user_email = user_email
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print_error(
|
||||
err_console,
|
||||
|
||||
@@ -27,6 +27,7 @@ CONFIG_VERSION = 1
|
||||
class PlatformConfig:
|
||||
api_key: str = ""
|
||||
base_url: str = DEFAULT_BASE_URL
|
||||
user_email: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -48,6 +49,7 @@ class Mem0Config:
|
||||
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||
"api_key": "platform.api_key",
|
||||
"base_url": "platform.base_url",
|
||||
"user_email": "platform.user_email",
|
||||
"user_id": "defaults.user_id",
|
||||
"agent_id": "defaults.agent_id",
|
||||
"app_id": "defaults.app_id",
|
||||
@@ -76,6 +78,7 @@ def load_config() -> Mem0Config:
|
||||
plat = data.get("platform", {})
|
||||
config.platform.api_key = plat.get("api_key", "")
|
||||
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
||||
config.platform.user_email = plat.get("user_email", "")
|
||||
|
||||
defaults = data.get("defaults", {})
|
||||
config.defaults.user_id = defaults.get("user_id", "")
|
||||
@@ -132,6 +135,7 @@ def save_config(config: Mem0Config) -> None:
|
||||
"platform": {
|
||||
"api_key": config.platform.api_key,
|
||||
"base_url": config.platform.base_url,
|
||||
"user_email": config.platform.user_email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""CLI telemetry — anonymous usage tracking via PostHog.
|
||||
|
||||
Sends fire-and-forget events to PostHog by spawning a detached subprocess
|
||||
(telemetry_sender.py). The parent CLI process exits immediately; the
|
||||
subprocess handles email resolution, caching, and the HTTP POST.
|
||||
|
||||
Disable with: MEM0_TELEMETRY=false
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||
|
||||
|
||||
def _is_telemetry_enabled() -> bool:
|
||||
val = os.environ.get("MEM0_TELEMETRY", "true").lower()
|
||||
return val not in ("false", "0", "no")
|
||||
|
||||
|
||||
def _get_distinct_id() -> str:
|
||||
"""Return a stable anonymous identifier for the current user.
|
||||
|
||||
Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback.
|
||||
Matches the SDK pattern in mem0/client/main.py.
|
||||
"""
|
||||
try:
|
||||
from mem0_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
if config.platform.user_email:
|
||||
return config.platform.user_email
|
||||
if config.platform.api_key:
|
||||
return hashlib.md5(config.platform.api_key.encode()).hexdigest()
|
||||
except Exception:
|
||||
pass
|
||||
return "anonymous-cli"
|
||||
|
||||
|
||||
def capture_event(
|
||||
event_name: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
pre_resolved_email: str | None = None,
|
||||
) -> None:
|
||||
"""Fire a PostHog event via a detached subprocess (non-blocking).
|
||||
|
||||
When *pre_resolved_email* is provided (e.g. from an upfront ping
|
||||
validation), it is used directly as the PostHog distinct ID and the
|
||||
subprocess skips its own ``/v1/ping/`` call.
|
||||
"""
|
||||
if not _is_telemetry_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.config import CONFIG_FILE, load_config
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
config = load_config()
|
||||
distinct_id = pre_resolved_email or _get_distinct_id()
|
||||
|
||||
payload = {
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event_name,
|
||||
"properties": {
|
||||
"source": "CLI",
|
||||
"language": "python",
|
||||
"cli_version": __version__,
|
||||
"agent_mode": is_agent_mode(),
|
||||
"python_version": sys.version,
|
||||
"os": sys.platform,
|
||||
"os_version": platform.version(),
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(properties or {}),
|
||||
},
|
||||
}
|
||||
|
||||
context = {
|
||||
"payload": payload,
|
||||
"posthog_host": POSTHOG_HOST,
|
||||
"needs_email": not distinct_id or "@" not in distinct_id,
|
||||
"mem0_api_key": config.platform.api_key or "",
|
||||
"mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
|
||||
"config_path": str(CONFIG_FILE),
|
||||
}
|
||||
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
close_fds=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Standalone telemetry sender — runs as a detached subprocess.
|
||||
|
||||
Usage: python -m mem0_cli.telemetry_sender '<json context>'
|
||||
|
||||
This module is spawned by telemetry.capture_event() and runs independently
|
||||
of the parent CLI process. It:
|
||||
|
||||
1. Resolves the user's email via /v1/ping/ if not already cached
|
||||
2. Caches the email in ~/.mem0/config.json for future runs
|
||||
3. Sends the PostHog event
|
||||
|
||||
All errors are silently swallowed — this process must never produce output
|
||||
or affect the user experience.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ctx = json.loads(sys.argv[1])
|
||||
payload = ctx["payload"]
|
||||
|
||||
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
|
||||
_resolve_and_cache_email(ctx, payload)
|
||||
|
||||
_send_posthog_event(ctx["posthog_host"], payload)
|
||||
|
||||
|
||||
def _resolve_and_cache_email(ctx: dict, payload: dict) -> None:
|
||||
"""Call /v1/ping/ to get the user's email, update the payload, and cache it."""
|
||||
try:
|
||||
ping_url = ctx["mem0_base_url"].rstrip("/") + "/v1/ping/"
|
||||
req = urllib.request.Request(
|
||||
ping_url,
|
||||
headers={
|
||||
"Authorization": "Token " + ctx["mem0_api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())
|
||||
email = data.get("user_email")
|
||||
if email:
|
||||
payload["distinct_id"] = email
|
||||
_cache_email(ctx.get("config_path"), email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cache_email(config_path: str | None, email: str) -> None:
|
||||
"""Write user_email into the config file for future runs."""
|
||||
if not config_path:
|
||||
return
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
cfg.setdefault("platform", {})["user_email"] = email
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _send_posthog_event(posthog_host: str, payload: dict) -> None:
|
||||
"""POST the event to PostHog."""
|
||||
try:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
posthog_host,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
main()
|
||||
@@ -8,6 +8,19 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-04-04" description="v1.0.11">
|
||||
|
||||
**New Features & Updates:**
|
||||
- **SDK:** Added `multilingual` parameter to project update ([#4314](https://github.com/mem0ai/mem0/pull/4314))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **LLMs:** Fixed Groq model configuration ([#4700](https://github.com/mem0ai/mem0/pull/4700))
|
||||
- **Core:** Prevented thread and memory leaks from PostHog telemetry ([#4535](https://github.com/mem0ai/mem0/pull/4535))
|
||||
- **Vector Stores:** Used `DatetimeRange` for datetime string values in Qdrant range filters ([#4659](https://github.com/mem0ai/mem0/pull/4659))
|
||||
- **Configs:** Added missing `ConfigDict` to vector store configs (Elasticsearch, MongoDB, Neptune, OpenSearch, PGVector, Supabase, Valkey) ([#4656](https://github.com/mem0ai/mem0/pull/4656))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-01" description="v1.0.10">
|
||||
|
||||
**New Features & Updates:**
|
||||
@@ -832,6 +845,13 @@ mode: "wide"
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-04-04" description="v2.4.6">
|
||||
|
||||
**New Features & Updates:**
|
||||
- **Client:** Added `multilingual` parameter to project update types ([#4314](https://github.com/mem0ai/mem0/pull/4314))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-01" description="v2.4.5">
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
+12
-4
@@ -147,9 +147,11 @@
|
||||
"tab": "OpenClaw",
|
||||
"groups": [
|
||||
{
|
||||
"group": "OpenClaw",
|
||||
"group": "Agent Harness",
|
||||
"icon": "robot",
|
||||
"pages": [
|
||||
"integrations/openclaw"
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -411,8 +413,6 @@
|
||||
"integrations/autogen",
|
||||
"integrations/agno",
|
||||
"integrations/camel-ai",
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes",
|
||||
"integrations/openai-agents-sdk",
|
||||
"integrations/google-ai-adk",
|
||||
"integrations/mastra",
|
||||
@@ -460,6 +460,14 @@
|
||||
"integrations/cursor",
|
||||
"integrations/codex"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Agent Harness",
|
||||
"icon": "robot",
|
||||
"pages": [
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+16
-52
@@ -382,67 +382,31 @@ Here are the available integrations for Mem0:
|
||||
Build AI agents with persistent memory using Mastra's framework and tools.
|
||||
</Card>
|
||||
<Card
|
||||
title="Claude Code"
|
||||
icon={
|
||||
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.92888 16.2181L9.86008 14.0122L9.92585 13.8199L9.86008 13.7137H9.66782L9.01009 13.6732L6.76369 13.6125L4.81581 13.5315L2.92863 13.4303L2.45304 13.3292L2.00781 12.7423L2.05335 12.4488L2.45304 12.1807L3.02476 12.2313L4.28962 12.3173L6.18692 12.4488L7.56309 12.5298L9.60204 12.7423H9.92585L9.97138 12.6107L9.86008 12.5298L9.77407 12.4488L7.811 11.1182L5.68603 9.71165L4.57295 8.90214L3.97088 8.49233L3.66731 8.10781L3.53577 7.26794L4.08219 6.66587L4.81581 6.71646L5.00301 6.76706L5.74674 7.33877L7.33541 8.56822L9.40979 10.0962L9.71335 10.3491L9.83478 10.2631L9.84996 10.2024L9.71335 9.97475L8.5851 7.93579L7.38095 5.86141L6.84465 5.00131L6.70298 4.48524C6.65239 4.27275 6.61697 4.09567 6.61697 3.87811L7.23928 3.03318L7.58332 2.92188L8.41307 3.03318L8.76218 3.33675L9.27824 4.5156L10.113 6.37242L11.4083 8.89708L11.7877 9.64588L11.9901 10.339L12.066 10.5515H12.1975V10.4301L12.3038 9.00839L12.5011 7.26288L12.6934 5.01649L12.7591 4.38406L13.0728 3.62514L13.6951 3.21532L14.1808 3.44806L14.5805 4.01978L14.5249 4.38912L14.2871 5.93225L13.8216 8.35066L13.5181 9.96969H13.6951L13.8975 9.76731L14.7171 8.67953L16.0933 6.95931L16.7005 6.27629L17.4088 5.52243L17.8641 5.16321H18.7242L19.3567 6.10427L19.0733 7.07568L18.1879 8.19888L17.4543 9.15006L16.4019 10.5667L15.7442 11.7L15.8049 11.7911L15.9618 11.7759L18.3397 11.27L19.6248 11.0372L21.1578 10.7741L21.851 11.0979L21.9269 11.4268L21.6537 12.0997L20.0144 12.5045L18.0918 12.889L15.2282 13.567L15.1927 13.5923L15.2332 13.6428L16.5234 13.7643L17.0749 13.7946H18.4257L20.9403 13.9818L21.598 14.4169L21.9926 14.9482L21.9269 15.3529L20.915 15.869L19.5489 15.5452L16.3615 14.7863L15.2686 14.5131H15.1168V14.6041L16.0275 15.4946L17.6972 17.0023L19.7867 18.9451L19.893 19.4258L19.6248 19.8053L19.3415 19.7648L17.5049 18.3835L16.7966 17.7612L15.1927 16.4104H15.0865V16.552L15.4558 17.0934L17.4088 20.0279L17.51 20.9285L17.3683 21.2219L16.8624 21.399L16.3058 21.2978L15.1624 19.6939L13.9835 17.8877L13.0324 16.2687L12.916 16.3345L12.3544 22.3805L12.0913 22.6891L11.4842 22.9219L10.9782 22.5374L10.7101 21.915L10.9782 20.6856L11.302 19.0818L11.5651 17.8068L11.8029 16.2232L11.9446 15.697L11.9345 15.6616L11.8181 15.6767L10.6241 17.316L8.80771 19.7698L7.37083 21.3079L7.02679 21.4445L6.42977 21.1359L6.48542 20.5844L6.81935 20.0936L8.80771 17.5639L10.0068 15.9955L10.7809 15.0898L10.7758 14.9583H10.7303L5.44824 18.3886L4.50718 18.51L4.10242 18.1306L4.15302 17.5083L4.34528 17.3059L5.93394 16.213L5.92888 16.2181Z" fill="currentColor"/>
|
||||
</svg>
|
||||
}
|
||||
href="/integrations/claude-code"
|
||||
title="OpenAI Agents SDK"
|
||||
icon="robot"
|
||||
href="/integrations/openai-agents-sdk"
|
||||
>
|
||||
Add persistent memory to Claude Code and Cowork with MCP server, lifecycle hooks, and SDK skill.
|
||||
Integrate Mem0 with the OpenAI Agents SDK for persistent memory across multi-agent workflows.
|
||||
</Card>
|
||||
<Card
|
||||
title="Cursor"
|
||||
icon={
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.4216 6.73383L12.4151 2.11135C12.158 1.96288 11.8408 1.96288 11.5837 2.11135L3.57759 6.73383C3.36146 6.85862 3.22803 7.08941 3.22803 7.33937V16.6606C3.22803 16.9106 3.36146 17.1414 3.57759 17.2662L11.5841 21.8886C11.8412 22.0371 12.1584 22.0371 12.4155 21.8886L20.4219 17.2662C20.6381 17.1414 20.7715 16.9106 20.7715 16.6606V7.33937C20.7715 7.08941 20.6377 6.85862 20.4216 6.73383ZM19.9187 7.71298L12.1896 21.1001C12.1373 21.1903 11.9994 21.1534 11.9994 21.0489V12.2832C11.9994 12.1081 11.9058 11.9461 11.7539 11.8581L4.16282 7.47543C4.07261 7.42319 4.10945 7.28524 4.21394 7.28524H19.6721C19.8916 7.28524 20.0284 7.52279 19.9187 7.71298Z" fill="currentColor"/>
|
||||
</svg>
|
||||
}
|
||||
href="/integrations/cursor"
|
||||
title="Google ADK"
|
||||
icon="google"
|
||||
href="/integrations/google-ai-adk"
|
||||
>
|
||||
Add persistent memory to Cursor with MCP server, lifecycle hooks, and context-aware coding.
|
||||
Integrate Mem0 with Google Agent Development Kit for persistent memory across multi-agent workflows.
|
||||
</Card>
|
||||
<Card
|
||||
title="Codex"
|
||||
icon={
|
||||
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.5565 10.6338C21.0009 9.27575 20.8528 7.76958 20.1367 6.53501C19.0503 4.63378 16.8528 3.67081 14.7046 4.11526C13.7663 3.05353 12.3836 2.46094 10.9515 2.46094C8.75399 2.46094 6.82807 3.86835 6.13671 5.94242C4.7293 6.23872 3.51943 7.10291 2.80338 8.36217C1.71696 10.2634 1.96387 12.6338 3.42066 14.2634C2.97622 15.6461 3.14906 17.1276 3.8651 18.3622C4.95152 20.2634 7.14906 21.2511 9.2972 20.7819C10.2602 21.8437 11.6182 22.4609 13.0503 22.4609C15.2478 22.4609 17.1737 21.0535 17.8651 18.9795C19.2725 18.6832 20.4824 17.819 21.1984 16.5597C22.2849 14.6585 22.0379 12.2634 20.5565 10.6338ZM13.0503 21.1523C12.1614 21.1523 11.3219 20.856 10.6552 20.2881C10.6799 20.2634 10.754 20.2387 10.7787 20.214L14.754 17.9177C14.9515 17.7943 15.075 17.5967 15.075 17.3498V11.7449L16.754 12.7079C16.7787 12.7079 16.7787 12.7325 16.7787 12.7572V17.3992C16.8034 19.4733 15.1244 21.1523 13.0503 21.1523ZM5.00091 17.7202C4.55646 16.9548 4.40831 16.0659 4.55646 15.2017C4.58115 15.2264 4.63054 15.2511 4.67992 15.2758L8.65523 17.572C8.85276 17.6955 9.09967 17.6955 9.2972 17.572L14.1614 14.7572V16.7079C14.1614 16.7325 14.1614 16.7572 14.1367 16.7572L10.112 19.0782C8.33424 20.1153 6.03794 19.498 5.00091 17.7202ZM3.96387 9.02884C4.40831 8.26341 5.09967 7.69551 5.91449 7.37452V12.1153C5.91449 12.3375 6.03794 12.5597 6.23548 12.6832L11.0997 15.498L9.42066 16.4609C9.39597 16.4609 9.37128 16.4856 9.37128 16.4609L5.34659 14.1399C3.51943 13.1029 2.92683 10.8066 3.96387 9.02884ZM17.791 12.2387L12.9268 9.4239L14.6058 8.46094C14.6305 8.46094 14.6552 8.43625 14.6552 8.46094L18.6799 10.7819C20.4824 11.819 21.075 14.1153 20.0379 15.893C19.5935 16.6585 18.9021 17.2264 18.0873 17.5227V12.8066C18.112 12.5844 17.9886 12.3622 17.791 12.2387ZM19.4454 9.7202C19.4207 9.69551 19.3713 9.67081 19.3219 9.64612L15.3466 7.34983C15.1491 7.22637 14.9021 7.22637 14.7046 7.34983L9.84041 10.1646V8.21402C9.84041 8.18933 9.84041 8.16464 9.86511 8.16464L13.8898 5.84365C15.6923 4.80662 17.9639 5.4239 19.0009 7.22637C19.4454 7.96711 19.5935 8.856 19.4454 9.7202ZM8.92683 13.177L7.24782 12.214C7.22313 12.214 7.22313 12.1893 7.22313 12.1646V7.52267C7.22313 5.44859 8.90214 3.76958 10.9762 3.76958C11.8651 3.76958 12.7046 4.06588 13.3713 4.63378C13.3466 4.65847 13.2972 4.68316 13.2478 4.70785L9.27251 7.00415C9.07498 7.1276 8.95152 7.32514 8.95152 7.57205V13.177H8.92683ZM9.84041 11.2017L12.0133 9.94242L14.1861 11.2017V13.6955L12.0133 14.9548L9.84041 13.6955V11.2017Z" fill="currentColor"/>
|
||||
</svg>
|
||||
}
|
||||
href="/integrations/codex"
|
||||
title="Flowise"
|
||||
icon="diagram-project"
|
||||
href="/integrations/flowise"
|
||||
>
|
||||
Add persistent memory to OpenAI Codex with MCP server and skill-based memory protocol.
|
||||
Add persistent Mem0 memory to Flowise chatflows for context-aware conversations in the low-code builder.
|
||||
</Card>
|
||||
<Card
|
||||
title="OpenClaw"
|
||||
icon={
|
||||
<svg width="24" height="24" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z" fill="currentColor"/>
|
||||
</svg>
|
||||
}
|
||||
href="/integrations/openclaw"
|
||||
title="AWS Bedrock"
|
||||
icon="cloud"
|
||||
href="/integrations/aws-bedrock"
|
||||
>
|
||||
Add long-term memory to OpenClaw agents with auto-recall and auto-capture support.
|
||||
</Card>
|
||||
<Card
|
||||
title="Hermes Agent"
|
||||
icon="bolt"
|
||||
href="/integrations/hermes"
|
||||
>
|
||||
Add long-term memory to Hermes agents with automatic background sync and zero-latency prefetch.
|
||||
Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.4.5",
|
||||
"version": "2.4.6",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ class GroqLLM(LLMBase):
|
||||
super().__init__(config)
|
||||
|
||||
if not self.config.model:
|
||||
self.config.model = "llama3-70b-8192"
|
||||
self.config.model = "llama-3.3-70b-versatile"
|
||||
|
||||
api_key = self.config.api_key or os.getenv("GROQ_API_KEY")
|
||||
self.client = Groq(api_key=api_key)
|
||||
|
||||
+26
-17
@@ -2,32 +2,41 @@
|
||||
|
||||
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`
|
||||
- **`import` CLI command**: Bulk-import memories from a JSON file with `--user-id` and `--agent-id` overrides
|
||||
- **`event list` / `event status` CLI commands**: Monitor background processing events
|
||||
- **`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 (6 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
|
||||
- **Init defaults**: Choice defaults to `1` (email login) on Enter, User ID defaults to OS username — no more empty values
|
||||
- **Init no longer stores `baseUrl`**: Uses `https://api.mem0.ai` directly instead of persisting it to config
|
||||
- **Help output reordered**: `openclaw mem0 help` now shows high-value commands first (search, add) instead of alphabetical
|
||||
|
||||
### 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`
|
||||
- `memory_history` tool and `history` CLI command — deprecated
|
||||
- **`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
|
||||
|
||||
|
||||
+103
-151
@@ -2,223 +2,175 @@
|
||||
|
||||
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
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
|
||||
|
||||
**Auto-Capture** — After the agent responds, the plugin filters the conversation through a noise-removal pipeline, then sends the cleaned exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Both run silently. No prompting, no configuration, no manual calls.
|
||||
|
||||
### Message filtering
|
||||
|
||||
Before extraction, messages pass through a multi-stage filtering pipeline:
|
||||
|
||||
1. **Noise detection** — Drops entire messages that are system noise: heartbeats (`HEARTBEAT_OK`, `NO_REPLY`), timestamps, single-word acknowledgments (`ok`, `sure`, `done`), system routing metadata, and compaction audit logs.
|
||||
2. **Generic assistant detection** — Drops short assistant messages that are boilerplate acknowledgments with no extractable facts (e.g. "I see you've shared an update. How can I help?").
|
||||
3. **Content stripping** — Removes embedded noise fragments (media boilerplate, routing metadata, compaction blocks) from otherwise useful messages.
|
||||
4. **Truncation** — Caps messages at 2000 characters to avoid sending excessive context.
|
||||
|
||||
### Short-term vs long-term memory
|
||||
|
||||
Memories are organized into two scopes:
|
||||
|
||||
- **Session (short-term)** — Auto-capture stores memories scoped to the current session via Mem0's `run_id` / `runId` parameter. These are contextual to the ongoing conversation and automatically recalled alongside long-term memories.
|
||||
|
||||
- **User (long-term)** — The agent can explicitly store long-term memories using the `memory_store` tool (with `longTerm: true`, the default). These persist across all sessions for the user.
|
||||
|
||||
During **auto-recall**, the plugin searches both scopes and presents them separately — long-term memories first, then session memories — so the agent has full context.
|
||||
|
||||
The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"session"`, `"long-term"`, or `"all"`) to control which memories are queried. The `memory_store` tool accepts a `longTerm` boolean (default: `true`) to choose where to store.
|
||||
|
||||
All new parameters are optional and backward-compatible — existing configurations work without changes.
|
||||
|
||||
### Per-agent memory isolation
|
||||
|
||||
In multi-agent setups, each agent automatically gets its own memory namespace. Session keys following the pattern `agent:<agentId>:<uuid>` are parsed to derive isolated namespaces (`${userId}:agent:${agentId}`). Single-agent deployments are unaffected — plain session keys and `agent:main:*` keys resolve to the configured `userId`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
- The agent's session key is inspected on every recall/capture cycle
|
||||
- If the key matches `agent:<name>:<uuid>`, memories are stored under `userId:agent:<name>`
|
||||
- Different agents never see each other's memories unless explicitly queried
|
||||
|
||||
**Subagent handling:**
|
||||
|
||||
Ephemeral subagents (session keys like `agent:main:subagent:<uuid>`) are handled specially:
|
||||
- **Recall** is routed to the parent (main user) namespace — subagents get the user's long-term context instead of searching their empty ephemeral namespace
|
||||
- **Capture** is skipped entirely — the main agent's `agent_end` hook captures the consolidated result including subagent output, preventing orphaned memories
|
||||
- A **subagent-specific preamble** is used: "You are a subagent — use these memories for context but do not assume you are this user"
|
||||
|
||||
**Explicit cross-agent queries:**
|
||||
|
||||
All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace:
|
||||
|
||||
```
|
||||
memory_search({ query: "user's tech stack", agentId: "researcher" })
|
||||
```
|
||||
|
||||
The `agentId` is always namespaced under the configured `userId` (e.g. `agentId: "researcher"` → `utkarsh:agent:researcher`), so it cannot be used to access other users' namespaces.
|
||||
|
||||
### Concurrency safety
|
||||
|
||||
Lifecycle hooks (`before_agent_start`, `agent_end`) use `ctx.sessionKey` directly from the event context rather than shared mutable state. This prevents race conditions when multiple sessions run concurrently (e.g. multiple Telegram users chatting simultaneously).
|
||||
|
||||
Tools still read from a best-effort `currentSessionId` variable (since tools don't receive `ctx`), but hooks — where the critical recall and capture logic runs — are fully concurrency-safe.
|
||||
|
||||
### Non-interactive trigger filtering
|
||||
|
||||
The plugin automatically skips recall and capture for non-interactive triggers: `cron`, `heartbeat`, `automation`, and `schedule`. Detection works via both `ctx.trigger` and session key patterns (`:cron:`, `:heartbeat:`). This prevents system-generated noise from polluting long-term memory.
|
||||
|
||||
## Setup
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
openclaw plugins install @mem0/openclaw-mem0
|
||||
```
|
||||
|
||||
### Understanding `userId`
|
||||
|
||||
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
|
||||
|
||||
Pick any stable, unique identifier for the user. Common choices:
|
||||
|
||||
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
|
||||
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
|
||||
- A simple username (e.g. `"alice"`)
|
||||
|
||||
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
|
||||
|
||||
> **Tip:** In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.
|
||||
|
||||
### Platform (Mem0 Cloud)
|
||||
|
||||
Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `openclaw.json`:
|
||||
Get an API key from [app.mem0.ai](https://app.mem0.ai/dashboard/api-keys):
|
||||
|
||||
```bash
|
||||
openclaw mem0 init --api-key <your-key> --user-id <your-user-id>
|
||||
```
|
||||
|
||||
Or configure manually in `openclaw.json`:
|
||||
|
||||
```json5
|
||||
// plugins.entries
|
||||
"openclaw-mem0": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
"userId": "alice"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Open-Source (Self-hosted)
|
||||
|
||||
No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
|
||||
No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings and LLM. Vectors are stored locally in SQLite at `~/.mem0/vector_store.db` — no external database required.
|
||||
|
||||
Defaults: `text-embedding-3-small` for embeddings, `gpt-5.4` for fact extraction.
|
||||
|
||||
```json5
|
||||
"openclaw-mem0": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"mode": "open-source",
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
"userId": "alice"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sensible defaults out of the box. To customize the embedder, vector store, or LLM:
|
||||
Customize the embedder, vector store, or LLM via the `oss` block:
|
||||
|
||||
```json5
|
||||
"config": {
|
||||
"mode": "open-source",
|
||||
"userId": "your-user-id",
|
||||
"userId": "alice",
|
||||
"oss": {
|
||||
"embedder": { "provider": "openai", "config": { "model": "text-embedding-3-small" } },
|
||||
"vectorStore": { "provider": "qdrant", "config": { "host": "localhost", "port": 6333 } },
|
||||
"llm": { "provider": "openai", "config": { "model": "gpt-4o" } }
|
||||
"llm": { "provider": "openai", "config": { "model": "gpt-5.4" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All `oss` fields are optional. See [Mem0 OSS docs](https://docs.mem0.ai/open-source/node-quickstart) for providers.
|
||||
All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open-source/node-quickstart) for supported providers.
|
||||
|
||||
## Agent tools
|
||||
## How It Works
|
||||
|
||||
The agent gets five tools it can call during conversations:
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
|
||||
**Auto-Capture** — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Both run silently. No prompting, no manual calls required.
|
||||
|
||||
### Memory Scopes
|
||||
|
||||
- **Session (short-term)** — Scoped to the current conversation via `run_id`. Recalled alongside long-term memories.
|
||||
- **User (long-term)** — Persistent across all sessions. Default for `memory_add`.
|
||||
|
||||
### Multi-Agent Isolation
|
||||
|
||||
Each agent gets its own memory namespace automatically via session key routing (`agent:<name>:<uuid>` maps to `userId:agent:<name>`). Single-agent setups are unaffected.
|
||||
|
||||
## Agent Tools
|
||||
|
||||
Eight tools are registered for agent use:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent, `scope` to filter by session/long-term. |
|
||||
| `memory_list` | List all stored memories. Optional `agentId` to scope to a specific agent, `scope` to filter. |
|
||||
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace, `longTerm` to choose scope. |
|
||||
| `memory_get` | Retrieve a memory by ID. |
|
||||
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
|
||||
| ---- | ----------- |
|
||||
| `memory_search` | Search by natural language query. Supports `scope` (`session`, `long-term`, `all`), `categories`, `filters`, and `agentId`. |
|
||||
| `memory_add` | Store facts. Accepts `text` or `facts` array, `category`, `importance`, `longTerm`, `metadata`. |
|
||||
| `memory_get` | Retrieve a single memory by ID. |
|
||||
| `memory_list` | List all memories. Filter by `userId`, `agentId`, `scope`. |
|
||||
| `memory_update` | Update a memory's text in place. Preserves history. |
|
||||
| `memory_delete` | Delete by `memoryId`, `query` (search-and-delete), or `all: true` (requires `confirm: true`). |
|
||||
| `memory_event_list` | List recent background processing events. Platform mode only. |
|
||||
| `memory_event_status` | Get status of a specific event by ID. Platform mode only. |
|
||||
|
||||
## CLI
|
||||
|
||||
All commands: `openclaw mem0 <command>`.
|
||||
|
||||
```bash
|
||||
# Search all memories (long-term + session)
|
||||
# Memory operations
|
||||
openclaw mem0 add "User prefers TypeScript over JavaScript"
|
||||
openclaw mem0 search "what languages does the user know"
|
||||
openclaw mem0 search "preferences" --scope long-term
|
||||
openclaw mem0 get <memory_id>
|
||||
openclaw mem0 list --user-id alice --top-k 20
|
||||
openclaw mem0 update <memory_id> "Updated preference text"
|
||||
openclaw mem0 delete <memory_id>
|
||||
openclaw mem0 delete --all --user-id alice --confirm
|
||||
openclaw mem0 import memories.json
|
||||
|
||||
# Search only long-term memories
|
||||
openclaw mem0 search "what languages does the user know" --scope long-term
|
||||
# Management
|
||||
openclaw mem0 init
|
||||
openclaw mem0 init --api-key <key> --user-id alice
|
||||
openclaw mem0 status
|
||||
openclaw mem0 config show
|
||||
openclaw mem0 config get api_key
|
||||
openclaw mem0 config set user_id alice
|
||||
|
||||
# Search only session/short-term memories
|
||||
openclaw mem0 search "what languages does the user know" --scope session
|
||||
# Events (platform only)
|
||||
openclaw mem0 event list
|
||||
openclaw mem0 event status <event_id>
|
||||
|
||||
# Stats
|
||||
openclaw mem0 stats
|
||||
|
||||
# Search a specific agent's memories
|
||||
openclaw mem0 search "user preferences" --agent researcher
|
||||
|
||||
# Stats for a specific agent
|
||||
openclaw mem0 stats --agent researcher
|
||||
# Memory consolidation
|
||||
openclaw mem0 dream
|
||||
openclaw mem0 dream --dry-run
|
||||
```
|
||||
|
||||
## Options
|
||||
## Configuration Reference
|
||||
|
||||
### General
|
||||
|
||||
| Key | Type | Default | |
|
||||
|-----|------|---------|---|
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
|
||||
| `userId` | `string` | `"default"` | Any unique identifier you choose for the user (e.g. `"alice"`, `"user_123"`). All memories are scoped to this value. Not found in any dashboard — you define it yourself. |
|
||||
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
|
||||
| `topK` | `number` | `5` | Max memories per recall |
|
||||
| `searchThreshold` | `number` | `0.5` | Min similarity (0–1) |
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
|
||||
| `userId` | `string` | OS username | User identifier. All memories scoped to this value. |
|
||||
| `autoRecall` | `boolean` | `true` | Inject relevant memories before each turn |
|
||||
| `autoCapture` | `boolean` | `true` | Extract and store facts after each turn |
|
||||
| `topK` | `number` | `5` | Max memories returned per recall |
|
||||
| `searchThreshold` | `number` | `0.5` | Minimum similarity score (0-1) |
|
||||
|
||||
### Platform mode
|
||||
### Platform Mode
|
||||
|
||||
| Key | Type | Default | |
|
||||
|-----|------|---------|---|
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `apiKey` | `string` | — | **Required.** Mem0 API key (supports `${MEM0_API_KEY}`) |
|
||||
| `orgId` | `string` | — | Organization ID |
|
||||
| `projectId` | `string` | — | Project ID |
|
||||
| `enableGraph` | `boolean` | `false` | Entity graph for relationships |
|
||||
| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format. Built-in instructions include temporal anchoring, conciseness, outcome-over-intent, deduplication, and language preservation guidelines. |
|
||||
| `customCategories` | `object` | *(12 defaults)* | Category name → description map for tagging |
|
||||
| `enableGraph` | `boolean` | `false` | Entity graph for relationship tracking |
|
||||
| `customInstructions` | `string` | *(built-in)* | Custom extraction rules |
|
||||
| `customCategories` | `object` | *(12 defaults)* | Category name to description map |
|
||||
|
||||
### Open-source mode
|
||||
### Open-Source Mode
|
||||
|
||||
Works with zero extra config. The `oss` block lets you swap out any component:
|
||||
All fields optional. Defaults: `text-embedding-3-small` embeddings, local SQLite vector store (`~/.mem0/vector_store.db`), `gpt-5.4` LLM.
|
||||
|
||||
| Key | Type | Default | |
|
||||
|-----|------|---------|---|
|
||||
| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, `"lmstudio"`, etc.) |
|
||||
| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` |
|
||||
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) |
|
||||
| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) |
|
||||
| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` |
|
||||
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
|
||||
| `oss.disableHistory` | `boolean` | `false` | Skip history DB initialization (useful when native SQLite bindings fail) |
|
||||
|
||||
Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM. Override only what you need.
|
||||
|
||||
> **SQLite resilience:** If the history DB fails to initialize (e.g. native binding resolution under jiti), the plugin automatically retries with history disabled. Core memory operations (add, search, get, delete) work without the history DB.
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `customPrompt` | `string` | *(built-in)* | Extraction prompt |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider |
|
||||
| `oss.embedder.config` | `object` | — | Provider config (`apiKey`, `model`, `baseURL`) |
|
||||
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store provider (see list above) |
|
||||
| `oss.vectorStore.config` | `object` | — | Provider config (`host`, `port`, `collectionName`, `dbPath`) |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider |
|
||||
| `oss.llm.config` | `object` | — | Provider config (`apiKey`, `model`, `baseURL`) |
|
||||
| `oss.historyDbPath` | `string` | — | SQLite path for edit history |
|
||||
| `oss.disableHistory` | `boolean` | `false` | Skip history DB |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Platform (SaaS) backend — communicates with api.mem0.ai.
|
||||
*/
|
||||
|
||||
import { PLUGIN_VERSION } from "../telemetry.ts";
|
||||
import {
|
||||
APIError,
|
||||
type AddOptions,
|
||||
@@ -25,6 +26,10 @@ export class PlatformBackend implements Backend {
|
||||
this.headers = {
|
||||
Authorization: `Token ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": "OPENCLAW",
|
||||
"X-Mem0-Client-Language": "node",
|
||||
"X-Mem0-Client-Version": PLUGIN_VERSION,
|
||||
"X-Mem0-Caller-Type": "plugin",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,11 +292,15 @@ export class PlatformBackend implements Backend {
|
||||
return result;
|
||||
}
|
||||
|
||||
async ping(): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", "/v1/ping/")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async status(
|
||||
_opts: { userId?: string; agentId?: string } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this._request("GET", "/v1/ping/");
|
||||
await this.ping();
|
||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
|
||||
+1095
-275
File diff suppressed because it is too large
Load Diff
+125
-45
@@ -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<string, unknown>;
|
||||
defaults: Record<string, unknown>;
|
||||
[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<string, unknown> {
|
||||
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<string, unknown>): 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;
|
||||
}
|
||||
|
||||
+17
-36
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -168,6 +151,7 @@ const ALLOWED_KEYS = [
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"userId",
|
||||
"userEmail",
|
||||
"orgId",
|
||||
"projectId",
|
||||
"autoCapture",
|
||||
@@ -193,20 +177,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<string, unknown>;
|
||||
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 +196,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 +208,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"];
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
export function bootstrapTelemetryFlag(): void {
|
||||
const val = process.env.MEM0_TELEMETRY;
|
||||
if (val !== undefined) {
|
||||
(globalThis as any).__mem0_telemetry_override = val;
|
||||
}
|
||||
}
|
||||
+149
-87
@@ -5,28 +5,29 @@
|
||||
* and the open-source self-hosted SDK. Uses the official `mem0ai` package.
|
||||
*
|
||||
* Features:
|
||||
* - 7 core tools: memory_search, memory_store, memory_get, memory_list,
|
||||
* memory_update, memory_delete, memory_history
|
||||
* - 6 core tools: memory_search, memory_add, memory_get, memory_list,
|
||||
* memory_update, memory_delete
|
||||
* - 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)
|
||||
*/
|
||||
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
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 +52,14 @@ 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";
|
||||
import { bootstrapTelemetryFlag } from "./fs-safe.ts";
|
||||
|
||||
bootstrapTelemetryFlag();
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports (for tests and external consumers)
|
||||
@@ -74,44 +80,51 @@ export {
|
||||
filterMessagesForExtraction,
|
||||
} from "./filtering.ts";
|
||||
export { mem0ConfigSchema } from "./config.ts";
|
||||
export type { FileConfig } from "./config.ts";
|
||||
export { createProvider } from "./providers.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Convert Record<string, string> categories to the array format mem0ai expects */
|
||||
function categoriesToArray(
|
||||
cats: Record<string, string>,
|
||||
): Array<Record<string, string>> {
|
||||
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Definition
|
||||
// ============================================================================
|
||||
|
||||
const memoryPlugin = {
|
||||
const memoryPlugin = definePluginEntry({
|
||||
id: "openclaw-mem0",
|
||||
name: "Memory (Mem0)",
|
||||
description: "Mem0 memory backend — Mem0 platform or self-hosted open-source",
|
||||
kind: "memory" as const,
|
||||
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<string, unknown>) => {
|
||||
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 +132,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 +174,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 +197,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 +226,26 @@ const memoryPlugin = {
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tools
|
||||
// Tools (modular — each tool in its own file under tools/)
|
||||
// ========================================================================
|
||||
|
||||
const toolCtx: ToolContext = {
|
||||
const toolDeps: ToolDeps = {
|
||||
api,
|
||||
backend,
|
||||
cfg,
|
||||
provider,
|
||||
cfg,
|
||||
backend,
|
||||
resolveUserId: _resolveUserId,
|
||||
effectiveUserId: _effectiveUserId,
|
||||
agentUserId: _agentUserId,
|
||||
getCurrentSessionId: () => currentSessionId,
|
||||
skillsActive,
|
||||
buildAddOptions,
|
||||
buildSearchOptions,
|
||||
getCurrentSessionId: () => currentSessionId,
|
||||
skillsActive,
|
||||
captureToolEvent: (toolName: string, props: Record<string, unknown>) => {
|
||||
_captureEvent(`openclaw.tool.${toolName}`, { tool_name: toolName, ...props });
|
||||
},
|
||||
};
|
||||
registerAllTools(toolCtx);
|
||||
registerAllTools(toolDeps);
|
||||
|
||||
// ========================================================================
|
||||
// CLI Commands
|
||||
@@ -238,6 +260,7 @@ const memoryPlugin = {
|
||||
_agentUserId,
|
||||
buildSearchOptions,
|
||||
() => currentSessionId,
|
||||
(cmd: string) => _captureEvent(`openclaw.cli.${cmd}`, { command: cmd }),
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
@@ -258,6 +281,7 @@ const memoryPlugin = {
|
||||
getStateDir: () => pluginStateDir,
|
||||
},
|
||||
skillsActive,
|
||||
_captureEvent,
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
@@ -277,7 +301,7 @@ const memoryPlugin = {
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle Hook Registration
|
||||
@@ -304,6 +328,7 @@ function registerHooks(
|
||||
getStateDir: () => string | undefined;
|
||||
},
|
||||
skillsActive: boolean = false,
|
||||
_captureEvent: (event: string, props?: Record<string, unknown>) => void = () => {},
|
||||
) {
|
||||
// ========================================================================
|
||||
// SKILLS MODE: Agentic memory via before_prompt_build
|
||||
@@ -372,6 +397,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 +421,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 +472,7 @@ function registerHooks(
|
||||
"\n</auto-dream>";
|
||||
// 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 +520,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 +545,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 +579,14 @@ function registerHooks(
|
||||
// LEGACY MODE: Original auto-recall + auto-capture behavior
|
||||
// ========================================================================
|
||||
|
||||
// Auto-recall: inject relevant memories before agent starts
|
||||
// Track last seen session ID to detect actual new sessions (not every turn)
|
||||
let lastRecallSessionId: string | undefined;
|
||||
|
||||
// Auto-recall: inject relevant memories before prompt is built
|
||||
if (cfg.autoRecall) {
|
||||
const RECALL_TIMEOUT_MS = 8_000;
|
||||
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
||||
if (!event.prompt || event.prompt.length < 5) return;
|
||||
|
||||
// Skip non-interactive triggers (cron, heartbeat, automation)
|
||||
@@ -562,24 +599,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 +671,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 +696,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
|
||||
@@ -742,6 +775,20 @@ function registerHooks(
|
||||
// Update shared state for tools (best-effort — tools don't have ctx)
|
||||
if (sessionId) session.setCurrentSessionId(sessionId);
|
||||
|
||||
const MEMORY_MUTATE_TOOLS = new Set(["memory_add", "memory_update", "memory_delete"]);
|
||||
const agentUsedMemoryTool = event.messages.some((msg: any) => {
|
||||
if (msg?.role !== "assistant" || !Array.isArray(msg?.content)) return false;
|
||||
return msg.content.some(
|
||||
(block: any) => block?.type === "tool_use" && MEMORY_MUTATE_TOOLS.has(block.name),
|
||||
);
|
||||
});
|
||||
if (agentUsedMemoryTool) {
|
||||
api.logger.info(
|
||||
"openclaw-mem0: skipping auto-capture — agent already used memory tools this turn",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build capture payload synchronously (cheap), then fire-and-forget ---
|
||||
|
||||
// Patterns indicating an assistant message contains a summary of
|
||||
@@ -800,6 +847,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 +930,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`,
|
||||
|
||||
Vendored
+30
-3
@@ -9,19 +9,46 @@ declare module "openclaw/plugin-sdk" {
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(
|
||||
definition: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
definition: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<{ content: Array<{ type: string; text: string }>; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
},
|
||||
metadata?: { optional?: boolean; [key: string]: unknown },
|
||||
): void;
|
||||
on(event: string, handler: (event: any, ctx: any) => any): void;
|
||||
registerCli(
|
||||
handler: (context: { program: any }) => void,
|
||||
options?: Record<string, unknown>,
|
||||
): void;
|
||||
registerCommand?(definition: Record<string, unknown>): void;
|
||||
registerService(service: {
|
||||
id: string;
|
||||
start: () => void;
|
||||
start: (...args: any[]) => void;
|
||||
stop: () => void;
|
||||
}): void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "openclaw/plugin-sdk/plugin-entry" {
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
export interface PluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}
|
||||
|
||||
export function definePluginEntry<T extends PluginEntry>(entry: T): T;
|
||||
}
|
||||
|
||||
declare module "openclaw/plugin-sdk/core" {
|
||||
export * from "openclaw/plugin-sdk";
|
||||
}
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
"skills": ["skills"],
|
||||
"contracts": {
|
||||
"tools": [
|
||||
"memory_search", "memory_store", "memory_get", "memory_list",
|
||||
"memory_update", "memory_delete", "memory_history"
|
||||
"memory_search", "memory_add", "memory_get", "memory_list",
|
||||
"memory_update", "memory_delete", "memory_event_list", "memory_event_status"
|
||||
]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"mem0": ["MEM0_API_KEY"],
|
||||
"mem0-oss-openai": ["OPENAI_API_KEY"],
|
||||
"mem0-oss-anthropic": ["ANTHROPIC_API_KEY"]
|
||||
},
|
||||
"uiHints": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
@@ -19,13 +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."
|
||||
},
|
||||
"baseUrl": {
|
||||
"label": "API Base URL",
|
||||
"placeholder": "https://api.mem0.ai",
|
||||
"advanced": true,
|
||||
"help": "Override the Mem0 API base URL. Only needed for custom deployments."
|
||||
"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."
|
||||
},
|
||||
"userId": {
|
||||
"label": "Default User ID",
|
||||
@@ -82,7 +81,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,17 +97,16 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"platform",
|
||||
"open-source",
|
||||
"oss"
|
||||
"open-source"
|
||||
]
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"baseUrl": {
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"userEmail": {
|
||||
"type": "string"
|
||||
},
|
||||
"orgId": {
|
||||
@@ -216,12 +214,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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
+62
-22
@@ -81,6 +81,7 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly baseUrl?: string,
|
||||
private readonly orgId?: string,
|
||||
private readonly projectId?: string,
|
||||
) {}
|
||||
@@ -97,11 +98,17 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
private async _init(): Promise<void> {
|
||||
const { default: MemoryClient } = await import("mem0ai");
|
||||
const opts: { apiKey: string; org_id?: string; project_id?: string } = {
|
||||
const opts: {
|
||||
apiKey: string;
|
||||
host?: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
} = {
|
||||
apiKey: this.apiKey,
|
||||
};
|
||||
if (this.orgId) opts.org_id = this.orgId;
|
||||
if (this.projectId) opts.project_id = this.projectId;
|
||||
if (this.baseUrl) opts.host = this.baseUrl;
|
||||
if (this.orgId) opts.organizationId = this.orgId;
|
||||
if (this.projectId) opts.projectId = this.projectId;
|
||||
this.client = new MemoryClient(opts);
|
||||
}
|
||||
|
||||
@@ -133,20 +140,11 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
// Base filters: always scope by user_id, optionally by run_id
|
||||
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) baseFilters.run_id = options.run_id;
|
||||
|
||||
// Merge agent-provided filters (created_at ranges, metadata, etc.)
|
||||
// with base filters. Agent filters extend, never override user scoping.
|
||||
const mergedFilters = options.filters
|
||||
? { AND: [baseFilters, options.filters] }
|
||||
: baseFilters;
|
||||
|
||||
const opts: Record<string, unknown> = {
|
||||
api_version: "v2",
|
||||
filters: mergedFilters,
|
||||
user_id: options.user_id,
|
||||
};
|
||||
if (options.run_id) opts.run_id = options.run_id;
|
||||
if (options.top_k != null) opts.top_k = options.top_k;
|
||||
if (options.threshold != null) opts.threshold = options.threshold;
|
||||
if (options.keyword_search != null)
|
||||
@@ -155,6 +153,14 @@ class PlatformProvider implements Mem0Provider {
|
||||
if (options.filter_memories != null)
|
||||
opts.filter_memories = options.filter_memories;
|
||||
if (options.categories != null) opts.categories = options.categories;
|
||||
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) baseFilters.run_id = options.run_id;
|
||||
|
||||
if (options.filters) {
|
||||
opts.filters = { AND: [baseFilters, options.filters] };
|
||||
} else {
|
||||
opts.filters = baseFilters;
|
||||
}
|
||||
|
||||
const results = await this.client.search(query, opts);
|
||||
return normalizeSearchResults(results);
|
||||
@@ -168,10 +174,16 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
async getAll(options: ListOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
const opts: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) opts.run_id = options.run_id;
|
||||
const opts: Record<string, unknown> = {
|
||||
api_version: "v2",
|
||||
user_id: options.user_id,
|
||||
filters: { user_id: options.user_id },
|
||||
};
|
||||
if (options.run_id) {
|
||||
opts.run_id = options.run_id;
|
||||
(opts.filters as Record<string, unknown>).run_id = options.run_id;
|
||||
}
|
||||
if (options.page_size != null) opts.page_size = options.page_size;
|
||||
if (options.source) opts.source = options.source;
|
||||
|
||||
const results = await this.client.getAll(opts);
|
||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||
@@ -242,10 +254,38 @@ class OSSProvider implements Mem0Provider {
|
||||
|
||||
const config: Record<string, unknown> = { version: "v1.1" };
|
||||
|
||||
if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder;
|
||||
const defaultEmbedder = { provider: "openai", config: { model: "text-embedding-3-small" } };
|
||||
const defaultLlm = { provider: "openai", config: { model: "gpt-5.4" } };
|
||||
|
||||
// Helper: strip empty-string values so they don't clobber defaults
|
||||
const stripEmpty = (obj: Record<string, unknown>) => {
|
||||
const out = { ...obj };
|
||||
for (const k of Object.keys(out)) { if (out[k] === "") delete out[k]; }
|
||||
return out;
|
||||
};
|
||||
|
||||
if (this.ossConfig?.embedder) {
|
||||
const ec = stripEmpty(this.ossConfig.embedder.config ?? {});
|
||||
config.embedder = {
|
||||
provider: this.ossConfig.embedder.provider || defaultEmbedder.provider,
|
||||
config: { ...defaultEmbedder.config, ...ec },
|
||||
};
|
||||
} else {
|
||||
config.embedder = defaultEmbedder;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.llm) {
|
||||
const lc = stripEmpty(this.ossConfig.llm.config ?? {});
|
||||
config.llm = {
|
||||
provider: this.ossConfig.llm.provider || defaultLlm.provider,
|
||||
config: { ...defaultLlm.config, ...lc },
|
||||
};
|
||||
} else {
|
||||
config.llm = defaultLlm;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.vectorStore)
|
||||
config.vectorStore = this.ossConfig.vectorStore;
|
||||
if (this.ossConfig?.llm) config.llm = this.ossConfig.llm;
|
||||
|
||||
if (this.ossConfig?.historyDbPath) {
|
||||
const dbPath = this.resolvePath
|
||||
@@ -280,7 +320,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 +410,7 @@ class OSSProvider implements Mem0Provider {
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.update(memoryId, { data: text });
|
||||
await this.memory.update(memoryId, text);
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
@@ -419,7 +459,7 @@ export function createProvider(
|
||||
);
|
||||
}
|
||||
|
||||
return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId);
|
||||
return new PlatformProvider(cfg.apiKey!, cfg.baseUrl, cfg.orgId, cfg.projectId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+13
-13
@@ -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("<memory-system>");
|
||||
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).",
|
||||
|
||||
@@ -34,9 +34,6 @@ Identify which memories need action. Use the tools to investigate.
|
||||
**Search for recent additions:**
|
||||
Call `memory_search` with a `created_at` filter to find memories added since the last consolidation. These are the most likely to need merging or cleanup.
|
||||
|
||||
**Check edit history on suspicious entries:**
|
||||
If a memory looks like it was updated multiple times or has contradictory content, call `memory_history` on it to understand how it evolved.
|
||||
|
||||
**Classify each target into one of these actions:**
|
||||
- DELETE: contains credentials, expired by TTL, pure noise, raw tool output, standalone timestamps
|
||||
- MERGE: two or more memories express the same fact in different words, or a series tracks incremental changes to the same entity
|
||||
@@ -48,7 +45,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 +59,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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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<string, unknown>[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
let _telemetryEnabled: boolean | undefined;
|
||||
function isTelemetryEnabled(): boolean {
|
||||
if (_telemetryEnabled !== undefined) return _telemetryEnabled;
|
||||
try {
|
||||
const val = (globalThis as any).__mem0_telemetry_override;
|
||||
if (val !== undefined) {
|
||||
const s = String(val).toLowerCase();
|
||||
_telemetryEnabled = s !== "false" && s !== "0" && s !== "no";
|
||||
} else {
|
||||
_telemetryEnabled = true;
|
||||
}
|
||||
} catch {
|
||||
_telemetryEnabled = true;
|
||||
}
|
||||
return _telemetryEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> = {},
|
||||
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 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Test shim for openclaw/plugin-sdk/plugin-entry.
|
||||
* At runtime this is resolved from the OpenClaw gateway.
|
||||
*/
|
||||
export function definePluginEntry<T>(entry: T): T {
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Test shim for openclaw/plugin-sdk.
|
||||
* At runtime this is resolved from the OpenClaw gateway.
|
||||
*/
|
||||
export interface OpenClawPluginApi {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: {
|
||||
info(msg: string): void;
|
||||
warn(msg: string): void;
|
||||
error(msg: string): void;
|
||||
debug(msg: string): void;
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(definition: Record<string, unknown>, metadata?: Record<string, unknown>): void;
|
||||
on(event: string, handler: (event: any, ctx: any) => any): void;
|
||||
registerCli(handler: (context: { program: any }) => void, options?: Record<string, unknown>): void;
|
||||
registerService(service: { id: string; start: (...args: any[]) => void; stop: () => void }): void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<typeof vi.fn>;
|
||||
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
|
||||
function setConfigFile(obj: Record<string, unknown>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
const mockUnlink = unlink as ReturnType<typeof vi.fn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { bootstrapTelemetryFlag } from "../fs-safe.ts";
|
||||
|
||||
describe("bootstrapTelemetryFlag", () => {
|
||||
const originalEnv = process.env.MEM0_TELEMETRY;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.MEM0_TELEMETRY = originalEnv;
|
||||
} else {
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
}
|
||||
});
|
||||
|
||||
it("sets globalThis override when MEM0_TELEMETRY is set", () => {
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBe("false");
|
||||
});
|
||||
|
||||
it("does not set globalThis override when MEM0_TELEMETRY is unset", () => {
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through truthy values", () => {
|
||||
process.env.MEM0_TELEMETRY = "true";
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock config-file before importing telemetry
|
||||
vi.mock("../cli/config-file.ts", () => ({
|
||||
readPluginAuth: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
import { captureEvent, PLUGIN_VERSION } from "../telemetry.ts";
|
||||
import { readPluginAuth } from "../cli/config-file.ts";
|
||||
|
||||
describe("telemetry", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset telemetry enabled state
|
||||
(globalThis as any).__mem0_telemetry_override = undefined;
|
||||
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
});
|
||||
|
||||
it("exports PLUGIN_VERSION", () => {
|
||||
expect(PLUGIN_VERSION).toBe("1.0.4");
|
||||
});
|
||||
|
||||
it("captureEvent does not throw", () => {
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureEvent accepts properties and context", () => {
|
||||
expect(() =>
|
||||
captureEvent("test_event", { key: "val" }, { apiKey: "m0-key", mode: "platform" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureEvent is silent when telemetry disabled", () => {
|
||||
(globalThis as any).__mem0_telemetry_override = "false";
|
||||
// Force re-evaluation by resetting cached value
|
||||
// Since _telemetryEnabled is module-level, we test indirectly
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses userEmail as distinct ID when available", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||
userEmail: "test@example.com",
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to anonymous-openclaw when no apiKey", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({});
|
||||
expect(() => captureEvent("test_event", {}, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles readPluginAuth errors gracefully", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error("config read failed");
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
});
|
||||
+164
-90
@@ -7,30 +7,27 @@
|
||||
*/
|
||||
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 { createMemoryEventListTool } from "../tools/memory-event-list.ts";
|
||||
import { createMemoryEventStatusTool } from "../tools/memory-event-status.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 +65,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" }),
|
||||
@@ -83,30 +81,43 @@ function createMockToolContext(overrides = {}): ToolContext {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("registerAllTools", () => {
|
||||
it("calls api.registerTool exactly 7 times", () => {
|
||||
const ctx = createMockToolContext();
|
||||
it("calls api.registerTool exactly 8 times", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
expect(ctx.api.registerTool).toHaveBeenCalledTimes(7);
|
||||
expect(ctx.api.registerTool).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
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<typeof vi.fn>
|
||||
).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",
|
||||
"memory_delete",
|
||||
"memory_history",
|
||||
"memory_event_list",
|
||||
"memory_event_status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers tools without a second argument (required, not optional)", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
|
||||
const calls = (ctx.api.registerTool as ReturnType<typeof vi.fn>).mock.calls;
|
||||
for (const call of calls) {
|
||||
expect(call).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -116,16 +127,18 @@ 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" },
|
||||
{ fn: createMemoryEventListTool, expectedName: "memory_event_list" },
|
||||
{ fn: createMemoryEventStatusTool, expectedName: "memory_event_status" },
|
||||
];
|
||||
|
||||
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 +159,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 +176,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 +196,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 +217,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 +233,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 +245,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 +286,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 +313,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 +336,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 +346,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 +363,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 +374,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 +386,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 +398,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 +408,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 +431,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 +443,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 +459,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 +473,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 +498,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 +515,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 +544,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 +569,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 +589,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 +600,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 +624,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 +664,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 +677,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 +690,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 +710,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 +730,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 +752,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 +777,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 +800,90 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+28
-64
@@ -1,76 +1,40 @@
|
||||
/**
|
||||
* 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 { Mem0Config, Mem0Provider, AddOptions, SearchOptions } from "../types.ts";
|
||||
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 { 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";
|
||||
import { createMemoryEventListTool } from "./memory-event-list.ts";
|
||||
import { createMemoryEventStatusTool } from "./memory-event-status.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregator
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ToolDeps {
|
||||
api: OpenClawPluginApi;
|
||||
provider: Mem0Provider;
|
||||
cfg: Mem0Config;
|
||||
backend?: Backend;
|
||||
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<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export function registerAllTools(deps: ToolDeps): void {
|
||||
const { api } = deps;
|
||||
|
||||
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" });
|
||||
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(createMemoryEventListTool(deps));
|
||||
api.registerTool(createMemoryEventStatusTool(deps));
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const p = params as {
|
||||
text?: string; facts?: string[]; category?: string; importance?: number;
|
||||
userId?: string; agentId?: string; metadata?: Record<string, unknown>; 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<string, unknown> = {
|
||||
...(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) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+40
-221
@@ -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<string, unknown>) {
|
||||
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) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryEventListTool(deps: ToolDeps) {
|
||||
return {
|
||||
name: "memory_event_list",
|
||||
label: "Memory Event List",
|
||||
description:
|
||||
"List recent background processing events from the Mem0 Platform. Use to check whether memory operations (add, update, delete) were processed successfully.",
|
||||
parameters: Type.Object({}),
|
||||
|
||||
async execute(_toolCallId: string, _params: Record<string, unknown>) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (!deps.backend) {
|
||||
deps.captureToolEvent("memory_event_list", { success: false, latency_ms: 0, error: "not_platform" });
|
||||
return {
|
||||
content: [{ type: "text", text: "Event tracking is only available in platform mode." }],
|
||||
details: { error: "not_platform" },
|
||||
};
|
||||
}
|
||||
|
||||
const results = await deps.backend.listEvents();
|
||||
if (!results.length) {
|
||||
deps.captureToolEvent("memory_event_list", { success: true, latency_ms: Date.now() - start, count: 0 });
|
||||
return {
|
||||
content: [{ type: "text", text: "No events found." }],
|
||||
details: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const rows = results.map((ev) => {
|
||||
const evId = String(ev.id ?? "");
|
||||
const evType = String(ev.event_type ?? "—");
|
||||
const status = String(ev.status ?? "—");
|
||||
const latency =
|
||||
typeof ev.latency === "number" ? `${Math.round(ev.latency as number)}ms` : "—";
|
||||
const created = String(ev.created_at ?? "—").slice(0, 19).replace("T", " ");
|
||||
return { id: evId, type: evType, status, latency, created };
|
||||
});
|
||||
|
||||
const text = rows
|
||||
.map((r) => `- ${r.id} | ${r.type} | ${r.status} | ${r.latency} | ${r.created}`)
|
||||
.join("\n");
|
||||
|
||||
deps.captureToolEvent("memory_event_list", { success: true, latency_ms: Date.now() - start, count: results.length });
|
||||
return {
|
||||
content: [{ type: "text", text: `${results.length} event(s):\n${text}` }],
|
||||
details: { count: results.length, events: rows },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_event_list", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to list events: ${String(err)}` }],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryEventStatusTool(deps: ToolDeps) {
|
||||
return {
|
||||
name: "memory_event_status",
|
||||
label: "Memory Event Status",
|
||||
description:
|
||||
"Get detailed status of a specific background processing event. Use to verify whether a memory add/update/delete was processed, view latency, and inspect results.",
|
||||
parameters: Type.Object({
|
||||
event_id: Type.String({ description: "The event ID to check" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { event_id: eventId } = params as { event_id: string };
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (!deps.backend) {
|
||||
deps.captureToolEvent("memory_event_status", { success: false, latency_ms: 0, error: "not_platform" });
|
||||
return {
|
||||
content: [{ type: "text", text: "Event tracking is only available in platform mode." }],
|
||||
details: { error: "not_platform" },
|
||||
};
|
||||
}
|
||||
|
||||
const ev = await deps.backend.getEvent(eventId);
|
||||
|
||||
const status = String(ev.status ?? "—");
|
||||
const evType = String(ev.event_type ?? "—");
|
||||
const latency =
|
||||
typeof ev.latency === "number" ? `${Math.round(ev.latency as number)}ms` : "—";
|
||||
const created = String(ev.created_at ?? "—").slice(0, 19).replace("T", " ");
|
||||
const updated = String(ev.updated_at ?? "—").slice(0, 19).replace("T", " ");
|
||||
|
||||
let text = `Event: ${eventId}\nType: ${evType}\nStatus: ${status}\nLatency: ${latency}\nCreated: ${created}\nUpdated: ${updated}`;
|
||||
|
||||
const results = ev.results as Record<string, unknown>[] | undefined;
|
||||
if (results && Array.isArray(results) && results.length) {
|
||||
const resultLines = results.map((r) => {
|
||||
const memId = String(r.id ?? "").slice(0, 8);
|
||||
const data = r.data as Record<string, unknown> | undefined;
|
||||
const memory = data?.memory ?? "";
|
||||
const evName = String(r.event ?? "");
|
||||
return `- [${evName}] ${memory} (${memId})`;
|
||||
});
|
||||
text += `\n\nResults (${results.length}):\n${resultLines.join("\n")}`;
|
||||
}
|
||||
|
||||
deps.captureToolEvent("memory_event_status", { success: true, latency_ms: Date.now() - start });
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { event: ev },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_event_status", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to get event: ${String(err)}` }],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates the `memory_history` tool config object suitable for
|
||||
* `api.registerTool(config, { name })`.
|
||||
*/
|
||||
export function createMemoryHistoryTool(ctx: ToolContext) {
|
||||
const { provider } = ctx;
|
||||
|
||||
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.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({
|
||||
description: "The memory ID to view history for",
|
||||
}),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { memoryId } = params as { memoryId: string };
|
||||
|
||||
try {
|
||||
const history = await provider!.history(memoryId);
|
||||
|
||||
if (!history || history.length === 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");
|
||||
|
||||
return {
|
||||
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) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+28
-147
@@ -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<string, unknown>) {
|
||||
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<string, unknown> = {
|
||||
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) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+37
-194
@@ -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<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).fields = fields;
|
||||
if (enableGraph !== undefined)
|
||||
(opts as unknown as Record<string, unknown>).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) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const p = params as {
|
||||
text?: string;
|
||||
facts?: string[];
|
||||
category?: string;
|
||||
importance?: number;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
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<string, unknown> = {
|
||||
...(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<string, unknown>).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<string, unknown>).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) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
const { memoryId, text, metadata } = params as {
|
||||
memoryId: string;
|
||||
text: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts", "backend/**/*.ts", "tools/**/*.ts", "cli/**/*.ts", "skill-loader.ts", "recall.ts", "dream-gate.ts"],
|
||||
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts", "backend/**/*.ts", "tools/**/*.ts", "cli/**/*.ts", "skill-loader.ts", "recall.ts", "dream-gate.ts", "telemetry.ts", "fs-safe.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.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:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
alias: {
|
||||
// OpenClaw SDK modules are resolved from the gateway at runtime.
|
||||
// During unit tests we provide lightweight stubs.
|
||||
"openclaw/plugin-sdk/plugin-entry": new URL(
|
||||
"./test-shims/plugin-entry.ts",
|
||||
import.meta.url,
|
||||
).pathname,
|
||||
"openclaw/plugin-sdk": new URL(
|
||||
"./test-shims/plugin-sdk.ts",
|
||||
import.meta.url,
|
||||
).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "1.0.10"
|
||||
version = "1.0.11"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "support@mem0.ai" }
|
||||
|
||||
Reference in New Issue
Block a user