bfe730aa38
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1527 lines
53 KiB
TypeScript
1527 lines
53 KiB
TypeScript
/**
|
|
* OpenClaw Memory (Mem0) Plugin
|
|
*
|
|
* Long-term memory via Mem0 — supports both the Mem0 platform
|
|
* and the open-source self-hosted SDK. Uses the official `mem0ai` package.
|
|
*
|
|
* Features:
|
|
* - 5 tools: memory_search, memory_list, memory_store, memory_get, memory_forget
|
|
* (with session/long-term scope support via scope and longTerm parameters)
|
|
* - 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
|
|
* - Dual mode: platform or open-source (self-hosted)
|
|
*/
|
|
|
|
import { Type } from "@sinclair/typebox";
|
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
type Mem0Mode = "platform" | "open-source";
|
|
|
|
type Mem0Config = {
|
|
mode: Mem0Mode;
|
|
// Platform-specific
|
|
apiKey?: string;
|
|
orgId?: string;
|
|
projectId?: string;
|
|
customInstructions: string;
|
|
customCategories: Record<string, string>;
|
|
enableGraph: boolean;
|
|
// OSS-specific
|
|
customPrompt?: string;
|
|
oss?: {
|
|
embedder?: { provider: string; config: Record<string, unknown> };
|
|
vectorStore?: { provider: string; config: Record<string, unknown> };
|
|
llm?: { provider: string; config: Record<string, unknown> };
|
|
historyDbPath?: string;
|
|
disableHistory?: boolean;
|
|
};
|
|
// Shared
|
|
userId: string;
|
|
autoCapture: boolean;
|
|
autoRecall: boolean;
|
|
searchThreshold: number;
|
|
topK: number;
|
|
};
|
|
|
|
// Unified types for the provider interface
|
|
interface AddOptions {
|
|
user_id: string;
|
|
run_id?: string;
|
|
custom_instructions?: string;
|
|
custom_categories?: Array<Record<string, string>>;
|
|
enable_graph?: boolean;
|
|
output_format?: string;
|
|
source?: string;
|
|
}
|
|
|
|
interface SearchOptions {
|
|
user_id: string;
|
|
run_id?: string;
|
|
top_k?: number;
|
|
threshold?: number;
|
|
limit?: number;
|
|
keyword_search?: boolean;
|
|
reranking?: boolean;
|
|
source?: string;
|
|
}
|
|
|
|
interface ListOptions {
|
|
user_id: string;
|
|
run_id?: string;
|
|
page_size?: number;
|
|
source?: string;
|
|
}
|
|
|
|
interface MemoryItem {
|
|
id: string;
|
|
memory: string;
|
|
user_id?: string;
|
|
score?: number;
|
|
categories?: string[];
|
|
metadata?: Record<string, unknown>;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
interface AddResultItem {
|
|
id: string;
|
|
memory: string;
|
|
event: "ADD" | "UPDATE" | "DELETE" | "NOOP";
|
|
}
|
|
|
|
interface AddResult {
|
|
results: AddResultItem[];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unified Provider Interface
|
|
// ============================================================================
|
|
|
|
interface Mem0Provider {
|
|
add(
|
|
messages: Array<{ role: string; content: string }>,
|
|
options: AddOptions,
|
|
): Promise<AddResult>;
|
|
search(query: string, options: SearchOptions): Promise<MemoryItem[]>;
|
|
get(memoryId: string): Promise<MemoryItem>;
|
|
getAll(options: ListOptions): Promise<MemoryItem[]>;
|
|
delete(memoryId: string): Promise<void>;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Platform Provider (Mem0 Cloud)
|
|
// ============================================================================
|
|
|
|
class PlatformProvider implements Mem0Provider {
|
|
private client: any; // MemoryClient from mem0ai
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
constructor(
|
|
private readonly apiKey: string,
|
|
private readonly orgId?: string,
|
|
private readonly projectId?: string,
|
|
) { }
|
|
|
|
private async ensureClient(): Promise<void> {
|
|
if (this.client) return;
|
|
if (this.initPromise) return this.initPromise;
|
|
this.initPromise = this._init().catch((err) => {
|
|
this.initPromise = null;
|
|
throw err;
|
|
});
|
|
return this.initPromise;
|
|
}
|
|
|
|
private async _init(): Promise<void> {
|
|
const { default: MemoryClient } = await import("mem0ai");
|
|
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
|
|
if (this.orgId) opts.org_id = this.orgId;
|
|
if (this.projectId) opts.project_id = this.projectId;
|
|
this.client = new MemoryClient(opts);
|
|
}
|
|
|
|
async add(
|
|
messages: Array<{ role: string; content: string }>,
|
|
options: AddOptions,
|
|
): Promise<AddResult> {
|
|
await this.ensureClient();
|
|
const opts: Record<string, unknown> = { user_id: options.user_id };
|
|
if (options.run_id) opts.run_id = options.run_id;
|
|
if (options.custom_instructions)
|
|
opts.custom_instructions = options.custom_instructions;
|
|
if (options.custom_categories)
|
|
opts.custom_categories = options.custom_categories;
|
|
if (options.enable_graph) opts.enable_graph = options.enable_graph;
|
|
if (options.output_format) opts.output_format = options.output_format;
|
|
if (options.source) opts.source = options.source;
|
|
|
|
const result = await this.client.add(messages, opts);
|
|
return normalizeAddResult(result);
|
|
}
|
|
|
|
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
|
await this.ensureClient();
|
|
const filters: Record<string, unknown> = { user_id: options.user_id };
|
|
if (options.run_id) filters.run_id = options.run_id;
|
|
|
|
const opts: Record<string, unknown> = {
|
|
api_version: "v2",
|
|
filters,
|
|
};
|
|
if (options.top_k != null) opts.top_k = options.top_k;
|
|
if (options.threshold != null) opts.threshold = options.threshold;
|
|
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
|
if (options.reranking != null) opts.rerank = options.reranking;
|
|
|
|
const results = await this.client.search(query, opts);
|
|
return normalizeSearchResults(results);
|
|
}
|
|
|
|
async get(memoryId: string): Promise<MemoryItem> {
|
|
await this.ensureClient();
|
|
const result = await this.client.get(memoryId);
|
|
return normalizeMemoryItem(result);
|
|
}
|
|
|
|
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;
|
|
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);
|
|
// Some versions return { results: [...] }
|
|
if (results?.results && Array.isArray(results.results))
|
|
return results.results.map(normalizeMemoryItem);
|
|
return [];
|
|
}
|
|
|
|
async delete(memoryId: string): Promise<void> {
|
|
await this.ensureClient();
|
|
await this.client.delete(memoryId);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Open-Source Provider (Self-hosted)
|
|
// ============================================================================
|
|
|
|
class OSSProvider implements Mem0Provider {
|
|
private memory: any; // Memory from mem0ai/oss
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
constructor(
|
|
private readonly ossConfig?: Mem0Config["oss"],
|
|
private readonly customPrompt?: string,
|
|
private readonly resolvePath?: (p: string) => string,
|
|
) { }
|
|
|
|
private async ensureMemory(): Promise<void> {
|
|
if (this.memory) return;
|
|
if (this.initPromise) return this.initPromise;
|
|
this.initPromise = this._init().catch((err) => {
|
|
this.initPromise = null;
|
|
throw err;
|
|
});
|
|
return this.initPromise;
|
|
}
|
|
|
|
private async _init(): Promise<void> {
|
|
const { Memory } = await import("mem0ai/oss");
|
|
|
|
const config: Record<string, unknown> = { version: "v1.1" };
|
|
|
|
if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder;
|
|
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
|
|
? this.resolvePath(this.ossConfig.historyDbPath)
|
|
: this.ossConfig.historyDbPath;
|
|
config.historyDbPath = dbPath;
|
|
}
|
|
|
|
if (this.ossConfig?.disableHistory) {
|
|
config.disableHistory = true;
|
|
}
|
|
|
|
if (this.customPrompt) config.customPrompt = this.customPrompt;
|
|
|
|
try {
|
|
this.memory = new Memory(config);
|
|
} catch (err) {
|
|
// If initialization fails (e.g. native SQLite binding resolution under
|
|
// jiti), retry with history disabled — the history DB is the most common
|
|
// source of native-binding failures and is not required for core
|
|
// memory operations.
|
|
if (!config.disableHistory) {
|
|
console.warn(
|
|
"[mem0] Memory initialization failed, retrying with history disabled:",
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
config.disableHistory = true;
|
|
this.memory = new Memory(config);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
async add(
|
|
messages: Array<{ role: string; content: string }>,
|
|
options: AddOptions,
|
|
): Promise<AddResult> {
|
|
await this.ensureMemory();
|
|
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
|
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
|
if (options.run_id) addOpts.runId = options.run_id;
|
|
if (options.source) addOpts.source = options.source;
|
|
const result = await this.memory.add(messages, addOpts);
|
|
return normalizeAddResult(result);
|
|
}
|
|
|
|
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
|
await this.ensureMemory();
|
|
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
|
const opts: Record<string, unknown> = { userId: options.user_id };
|
|
if (options.run_id) opts.runId = options.run_id;
|
|
if (options.limit != null) opts.limit = options.limit;
|
|
else if (options.top_k != null) opts.limit = options.top_k;
|
|
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
|
if (options.reranking != null) opts.reranking = options.reranking;
|
|
if (options.source) opts.source = options.source;
|
|
if (options.threshold != null) opts.threshold = options.threshold;
|
|
|
|
const results = await this.memory.search(query, opts);
|
|
const normalized = normalizeSearchResults(results);
|
|
|
|
// Filter results by threshold if specified (client-side filtering as fallback)
|
|
if (options.threshold != null) {
|
|
return normalized.filter(item => (item.score ?? 0) >= options.threshold!);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
async get(memoryId: string): Promise<MemoryItem> {
|
|
await this.ensureMemory();
|
|
const result = await this.memory.get(memoryId);
|
|
return normalizeMemoryItem(result);
|
|
}
|
|
|
|
async getAll(options: ListOptions): Promise<MemoryItem[]> {
|
|
await this.ensureMemory();
|
|
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
|
const getAllOpts: Record<string, unknown> = { userId: options.user_id };
|
|
if (options.run_id) getAllOpts.runId = options.run_id;
|
|
if (options.source) getAllOpts.source = options.source;
|
|
const results = await this.memory.getAll(getAllOpts);
|
|
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
|
if (results?.results && Array.isArray(results.results))
|
|
return results.results.map(normalizeMemoryItem);
|
|
return [];
|
|
}
|
|
|
|
async delete(memoryId: string): Promise<void> {
|
|
await this.ensureMemory();
|
|
await this.memory.delete(memoryId);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Result Normalizers
|
|
// ============================================================================
|
|
|
|
function normalizeMemoryItem(raw: any): MemoryItem {
|
|
return {
|
|
id: raw.id ?? raw.memory_id ?? "",
|
|
memory: raw.memory ?? raw.text ?? raw.content ?? "",
|
|
// Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names
|
|
user_id: raw.user_id ?? raw.userId,
|
|
score: raw.score,
|
|
categories: raw.categories,
|
|
metadata: raw.metadata,
|
|
created_at: raw.created_at ?? raw.createdAt,
|
|
updated_at: raw.updated_at ?? raw.updatedAt,
|
|
};
|
|
}
|
|
|
|
function normalizeSearchResults(raw: any): MemoryItem[] {
|
|
// Platform API returns flat array, OSS returns { results: [...] }
|
|
if (Array.isArray(raw)) return raw.map(normalizeMemoryItem);
|
|
if (raw?.results && Array.isArray(raw.results))
|
|
return raw.results.map(normalizeMemoryItem);
|
|
return [];
|
|
}
|
|
|
|
function normalizeAddResult(raw: any): AddResult {
|
|
// Handle { results: [...] } shape (both platform and OSS)
|
|
if (raw?.results && Array.isArray(raw.results)) {
|
|
return {
|
|
results: raw.results.map((r: any) => ({
|
|
id: r.id ?? r.memory_id ?? "",
|
|
memory: r.memory ?? r.text ?? "",
|
|
// Platform API may return PENDING status (async processing)
|
|
// OSS stores event in metadata.event
|
|
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
|
|
})),
|
|
};
|
|
}
|
|
// Platform API without output_format returns flat array
|
|
if (Array.isArray(raw)) {
|
|
return {
|
|
results: raw.map((r: any) => ({
|
|
id: r.id ?? r.memory_id ?? "",
|
|
memory: r.memory ?? r.text ?? "",
|
|
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
|
|
})),
|
|
};
|
|
}
|
|
return { results: [] };
|
|
}
|
|
|
|
// ============================================================================
|
|
// Config Parser
|
|
// ============================================================================
|
|
|
|
function resolveEnvVars(value: string): string {
|
|
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
|
const envValue = process.env[envVar];
|
|
if (!envValue) {
|
|
throw new Error(`Environment variable ${envVar} is not set`);
|
|
}
|
|
return envValue;
|
|
});
|
|
}
|
|
|
|
function resolveEnvVarsDeep(obj: Record<string, unknown>): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (typeof value === "string") {
|
|
result[key] = resolveEnvVars(value);
|
|
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
result[key] = resolveEnvVarsDeep(value as Record<string, unknown>);
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Default Custom Instructions & Categories
|
|
// ============================================================================
|
|
|
|
const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract and maintain a structured, evolving profile of the user from their conversations with an AI assistant. Capture information that would help the assistant provide personalized, context-aware responses in future interactions.
|
|
|
|
Information to Extract:
|
|
|
|
1. Identity & Demographics:
|
|
- Name, age, location, timezone, language preferences
|
|
- Occupation, employer, job role, industry
|
|
- Education background
|
|
|
|
2. Preferences & Opinions:
|
|
- Communication style preferences (formal/casual, verbose/concise)
|
|
- Tool and technology preferences (languages, frameworks, editors, OS)
|
|
- Content preferences (topics of interest, learning style)
|
|
- Strong opinions or values they've expressed
|
|
- Likes and dislikes they've explicitly stated
|
|
|
|
3. Goals & Projects:
|
|
- Current projects they're working on (name, description, status)
|
|
- Short-term and long-term goals
|
|
- Deadlines and milestones mentioned
|
|
- Problems they're actively trying to solve
|
|
|
|
4. Technical Context:
|
|
- Tech stack and tools they use
|
|
- Skill level in different areas (beginner/intermediate/expert)
|
|
- Development environment and setup details
|
|
- Recurring technical challenges
|
|
|
|
5. Relationships & People:
|
|
- Names and roles of people they mention (colleagues, family, friends)
|
|
- Team structure and dynamics
|
|
- Key contacts and their relevance
|
|
|
|
6. Decisions & Lessons:
|
|
- Important decisions made and their reasoning
|
|
- Lessons learned from past experiences
|
|
- Strategies that worked or failed
|
|
- Changed opinions or updated beliefs
|
|
|
|
7. Routines & Habits:
|
|
- Daily routines and schedules mentioned
|
|
- Work patterns (when they're productive, how they organize work)
|
|
- Health and wellness habits if voluntarily shared
|
|
|
|
8. Life Events:
|
|
- Significant events (new job, moving, milestones)
|
|
- Upcoming events or plans
|
|
- Changes in circumstances
|
|
|
|
Guidelines:
|
|
- Store memories as clear, self-contained statements (each memory should make sense on its own)
|
|
- Use third person: "User prefers..." not "I prefer..."
|
|
- Include temporal context when relevant: "As of [date], user is working on..."
|
|
- When information updates, UPDATE the existing memory rather than creating duplicates
|
|
- Merge related facts into single coherent memories when possible
|
|
- Preserve specificity: "User uses Next.js 14 with App Router" is better than "User uses React"
|
|
- Capture the WHY behind preferences when stated: "User prefers Vim because of keyboard-driven workflow"
|
|
|
|
Exclude:
|
|
- Passwords, API keys, tokens, or any authentication credentials
|
|
- Exact financial amounts (account balances, salaries) unless the user explicitly asks to remember them
|
|
- Temporary or ephemeral information (one-time questions, debugging sessions with no lasting insight)
|
|
- Generic small talk with no informational content
|
|
- The assistant's own responses unless they contain a commitment or promise to the user
|
|
- Raw code snippets (capture the intent/decision, not the code itself)
|
|
- Information the user explicitly asks not to remember`;
|
|
|
|
const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = {
|
|
identity:
|
|
"Personal identity information: name, age, location, timezone, occupation, employer, education, demographics",
|
|
preferences:
|
|
"Explicitly stated likes, dislikes, preferences, opinions, and values across any domain",
|
|
goals:
|
|
"Current and future goals, aspirations, objectives, targets the user is working toward",
|
|
projects:
|
|
"Specific projects, initiatives, or endeavors the user is working on, including status and details",
|
|
technical:
|
|
"Technical skills, tools, tech stack, development environment, programming languages, frameworks",
|
|
decisions:
|
|
"Important decisions made, reasoning behind choices, strategy changes, and their outcomes",
|
|
relationships:
|
|
"People mentioned by the user: colleagues, family, friends, their roles and relevance",
|
|
routines:
|
|
"Daily habits, work patterns, schedules, productivity routines, health and wellness habits",
|
|
life_events:
|
|
"Significant life events, milestones, transitions, upcoming plans and changes",
|
|
lessons:
|
|
"Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs",
|
|
work:
|
|
"Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges",
|
|
health:
|
|
"Health-related information voluntarily shared: conditions, medications, fitness, wellness goals",
|
|
};
|
|
|
|
// ============================================================================
|
|
// Config Schema
|
|
// ============================================================================
|
|
|
|
const ALLOWED_KEYS = [
|
|
"mode",
|
|
"apiKey",
|
|
"userId",
|
|
"orgId",
|
|
"projectId",
|
|
"autoCapture",
|
|
"autoRecall",
|
|
"customInstructions",
|
|
"customCategories",
|
|
"customPrompt",
|
|
"enableGraph",
|
|
"searchThreshold",
|
|
"topK",
|
|
"oss",
|
|
];
|
|
|
|
function assertAllowedKeys(
|
|
value: Record<string, unknown>,
|
|
allowed: string[],
|
|
label: string,
|
|
) {
|
|
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
if (unknown.length === 0) return;
|
|
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
|
}
|
|
|
|
export const mem0ConfigSchema = {
|
|
parse(value: unknown): 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
|
|
const mode: Mem0Mode =
|
|
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
|
|
|
|
// Platform mode requires apiKey
|
|
if (mode === "platform") {
|
|
if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
|
|
throw new Error(
|
|
"apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)",
|
|
);
|
|
}
|
|
}
|
|
|
|
// Resolve env vars in oss config
|
|
let ossConfig: Mem0Config["oss"];
|
|
if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) {
|
|
ossConfig = resolveEnvVarsDeep(
|
|
cfg.oss as Record<string, unknown>,
|
|
) as unknown as Mem0Config["oss"];
|
|
}
|
|
|
|
return {
|
|
mode,
|
|
apiKey:
|
|
typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined,
|
|
userId:
|
|
typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default",
|
|
orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined,
|
|
projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined,
|
|
autoCapture: cfg.autoCapture !== false,
|
|
autoRecall: cfg.autoRecall !== false,
|
|
customInstructions:
|
|
typeof cfg.customInstructions === "string"
|
|
? cfg.customInstructions
|
|
: DEFAULT_CUSTOM_INSTRUCTIONS,
|
|
customCategories:
|
|
cfg.customCategories &&
|
|
typeof cfg.customCategories === "object" &&
|
|
!Array.isArray(cfg.customCategories)
|
|
? (cfg.customCategories as Record<string, string>)
|
|
: DEFAULT_CUSTOM_CATEGORIES,
|
|
customPrompt:
|
|
typeof cfg.customPrompt === "string"
|
|
? cfg.customPrompt
|
|
: DEFAULT_CUSTOM_INSTRUCTIONS,
|
|
enableGraph: cfg.enableGraph === true,
|
|
searchThreshold:
|
|
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
|
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
|
oss: ossConfig,
|
|
};
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Provider Factory
|
|
// ============================================================================
|
|
|
|
export function createProvider(
|
|
cfg: Mem0Config,
|
|
api: OpenClawPluginApi,
|
|
): Mem0Provider {
|
|
if (cfg.mode === "open-source") {
|
|
return new OSSProvider(cfg.oss, cfg.customPrompt, (p) =>
|
|
api.resolvePath(p),
|
|
);
|
|
}
|
|
|
|
return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 }));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Per-agent isolation helpers (exported for testability)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`.
|
|
* Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys.
|
|
*/
|
|
export function extractAgentId(sessionKey: string | undefined): string | undefined {
|
|
if (!sessionKey) return undefined;
|
|
const match = sessionKey.match(/^agent:([^:]+):/);
|
|
const agentId = match?.[1];
|
|
// "main" is the primary session — fall back to configured userId
|
|
if (!agentId || agentId === "main") return undefined;
|
|
return agentId;
|
|
}
|
|
|
|
/**
|
|
* Derive the effective user_id from a session key, namespacing per-agent.
|
|
* Falls back to baseUserId when the session is not agent-scoped.
|
|
*/
|
|
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
|
|
const agentId = extractAgentId(sessionKey);
|
|
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
|
|
}
|
|
|
|
/** Build a user_id for an explicit agentId (e.g. from tool params). */
|
|
export function agentUserId(baseUserId: string, agentId: string): string {
|
|
return `${baseUserId}:agent:${agentId}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
|
|
*/
|
|
export function resolveUserId(
|
|
baseUserId: string,
|
|
opts: { agentId?: string; userId?: string },
|
|
currentSessionId?: string,
|
|
): string {
|
|
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
|
|
if (opts.userId) return opts.userId;
|
|
return effectiveUserId(baseUserId, currentSessionId);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Plugin Definition
|
|
// ============================================================================
|
|
|
|
const memoryPlugin = {
|
|
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);
|
|
const provider = createProvider(cfg, api);
|
|
|
|
// Track current session ID for tool-level session scoping
|
|
let currentSessionId: string | undefined;
|
|
|
|
// ========================================================================
|
|
// Per-agent isolation helpers (thin wrappers around exported functions)
|
|
// ========================================================================
|
|
const _effectiveUserId = (sessionKey?: string) =>
|
|
effectiveUserId(cfg.userId, sessionKey);
|
|
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
|
|
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
|
|
resolveUserId(cfg.userId, opts, currentSessionId);
|
|
|
|
api.logger.info(
|
|
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
|
);
|
|
|
|
// Helper: build add options
|
|
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
|
|
const opts: AddOptions = {
|
|
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
|
source: "OPENCLAW",
|
|
};
|
|
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";
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
// Helper: build search options
|
|
function buildSearchOptions(
|
|
userIdOverride?: string,
|
|
limit?: number,
|
|
runId?: string,
|
|
sessionKey?: string,
|
|
): SearchOptions {
|
|
const opts: SearchOptions = {
|
|
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
|
top_k: limit ?? cfg.topK,
|
|
limit: limit ?? cfg.topK,
|
|
threshold: cfg.searchThreshold,
|
|
keyword_search: true,
|
|
reranking: true,
|
|
source: "OPENCLAW",
|
|
};
|
|
if (runId) opts.run_id = runId;
|
|
return opts;
|
|
}
|
|
|
|
// ========================================================================
|
|
// Tools
|
|
// ========================================================================
|
|
|
|
api.registerTool(
|
|
{
|
|
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.",
|
|
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.",
|
|
}),
|
|
),
|
|
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"',
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { query, limit, userId, agentId, scope = "all" } = params as {
|
|
query: string;
|
|
limit?: number;
|
|
userId?: string;
|
|
agentId?: string;
|
|
scope?: "session" | "long-term" | "all";
|
|
};
|
|
|
|
try {
|
|
let results: MemoryItem[] = [];
|
|
const uid = _resolveUserId({ agentId, userId });
|
|
|
|
if (scope === "session") {
|
|
if (currentSessionId) {
|
|
results = await provider.search(
|
|
query,
|
|
buildSearchOptions(uid, limit, currentSessionId),
|
|
);
|
|
}
|
|
} else if (scope === "long-term") {
|
|
results = await provider.search(
|
|
query,
|
|
buildSearchOptions(uid, limit),
|
|
);
|
|
} else {
|
|
// "all" — search both scopes and combine
|
|
const longTermResults = await provider.search(
|
|
query,
|
|
buildSearchOptions(uid, limit),
|
|
);
|
|
let sessionResults: MemoryItem[] = [];
|
|
if (currentSessionId) {
|
|
sessionResults = await provider.search(
|
|
query,
|
|
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)),
|
|
];
|
|
}
|
|
|
|
if (!results || results.length === 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,
|
|
}));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Found ${results.length} memories:\n\n${text}`,
|
|
},
|
|
],
|
|
details: { count: results.length, memories: sanitized },
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory search failed: ${String(err)}`,
|
|
},
|
|
],
|
|
details: { error: String(err) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_search" },
|
|
);
|
|
|
|
api.registerTool(
|
|
{
|
|
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.String({ description: "Information to remember" }),
|
|
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: "Optional 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.",
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { text, userId, agentId, longTerm = true } = params as {
|
|
text: string;
|
|
userId?: string;
|
|
agentId?: string;
|
|
metadata?: Record<string, unknown>;
|
|
longTerm?: boolean;
|
|
};
|
|
|
|
try {
|
|
const uid = _resolveUserId({ agentId, userId });
|
|
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
|
const result = await provider.add(
|
|
[{ role: "user", content: text }],
|
|
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} 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) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_store" },
|
|
);
|
|
|
|
api.registerTool(
|
|
{
|
|
name: "memory_get",
|
|
label: "Memory Get",
|
|
description: "Retrieve a specific memory by its ID from Mem0.",
|
|
parameters: Type.Object({
|
|
memoryId: Type.String({ description: "The memory ID to retrieve" }),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { memoryId } = params as { memoryId: string };
|
|
|
|
try {
|
|
const memory = await provider.get(memoryId);
|
|
|
|
return {
|
|
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) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_get" },
|
|
);
|
|
|
|
api.registerTool(
|
|
{
|
|
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.",
|
|
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.",
|
|
}),
|
|
),
|
|
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"',
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
|
|
|
|
try {
|
|
let memories: MemoryItem[] = [];
|
|
const uid = _resolveUserId({ agentId, userId });
|
|
|
|
if (scope === "session") {
|
|
if (currentSessionId) {
|
|
memories = await provider.getAll({
|
|
user_id: uid,
|
|
run_id: currentSessionId,
|
|
source: "OPENCLAW",
|
|
});
|
|
}
|
|
} else if (scope === "long-term") {
|
|
memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
|
|
} else {
|
|
// "all" — combine both scopes
|
|
const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
|
|
let session: MemoryItem[] = [];
|
|
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)),
|
|
];
|
|
}
|
|
|
|
if (!memories || memories.length === 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,
|
|
}));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `${memories.length} memories:\n\n${text}`,
|
|
},
|
|
],
|
|
details: { count: memories.length, memories: sanitized },
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory list failed: ${String(err)}`,
|
|
},
|
|
],
|
|
details: { error: String(err) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_list" },
|
|
);
|
|
|
|
api.registerTool(
|
|
{
|
|
name: "memory_forget",
|
|
label: "Memory Forget",
|
|
description:
|
|
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.",
|
|
parameters: Type.Object({
|
|
query: Type.Optional(
|
|
Type.String({
|
|
description: "Search query to find memory to delete",
|
|
}),
|
|
),
|
|
memoryId: Type.Optional(
|
|
Type.String({ description: "Specific memory ID to delete" }),
|
|
),
|
|
agentId: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").",
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { query, memoryId, agentId } = params as {
|
|
query?: string;
|
|
memoryId?: string;
|
|
agentId?: string;
|
|
};
|
|
|
|
try {
|
|
if (memoryId) {
|
|
await provider.delete(memoryId);
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Memory ${memoryId} forgotten.` },
|
|
],
|
|
details: { action: "deleted", id: memoryId },
|
|
};
|
|
}
|
|
|
|
if (query) {
|
|
const uid = _resolveUserId({ agentId });
|
|
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 },
|
|
};
|
|
}
|
|
|
|
// 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: `Forgotten: "${results[0].memory}"`,
|
|
},
|
|
],
|
|
details: { action: "deleted", id: results[0].id },
|
|
};
|
|
}
|
|
|
|
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,
|
|
}));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`,
|
|
},
|
|
],
|
|
details: { action: "candidates", candidates },
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{ type: "text", text: "Provide a query or memoryId." },
|
|
],
|
|
details: { error: "missing_param" },
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory forget failed: ${String(err)}`,
|
|
},
|
|
],
|
|
details: { error: String(err) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_forget" },
|
|
);
|
|
|
|
// ========================================================================
|
|
// CLI Commands
|
|
// ========================================================================
|
|
|
|
api.registerCli(
|
|
({ program }) => {
|
|
const mem0 = program
|
|
.command("mem0")
|
|
.description("Mem0 memory plugin commands");
|
|
|
|
mem0
|
|
.command("search")
|
|
.description("Search memories in Mem0")
|
|
.argument("<query>", "Search query")
|
|
.option("--limit <n>", "Max results", String(cfg.topK))
|
|
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
|
|
.option("--agent <agentId>", "Search a specific agent's memory namespace")
|
|
.action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => {
|
|
try {
|
|
const limit = parseInt(opts.limit, 10);
|
|
const scope = opts.scope as "session" | "long-term" | "all";
|
|
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
|
|
|
|
let allResults: MemoryItem[] = [];
|
|
|
|
if (scope === "session" || scope === "all") {
|
|
if (currentSessionId) {
|
|
const sessionResults = await provider.search(
|
|
query,
|
|
buildSearchOptions(uid, limit, currentSessionId),
|
|
);
|
|
if (sessionResults?.length) {
|
|
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
|
|
}
|
|
} else if (scope === "session") {
|
|
console.log("No active session ID available for session-scoped search.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (scope === "long-term" || scope === "all") {
|
|
const longTermResults = await provider.search(
|
|
query,
|
|
buildSearchOptions(uid, limit),
|
|
);
|
|
if (longTermResults?.length) {
|
|
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
|
|
}
|
|
}
|
|
|
|
// Deduplicate by ID when searching "all"
|
|
if (scope === "all") {
|
|
const seen = new Set<string>();
|
|
allResults = allResults.filter((r) => {
|
|
if (seen.has(r.id)) return false;
|
|
seen.add(r.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
if (!allResults.length) {
|
|
console.log("No memories found.");
|
|
return;
|
|
}
|
|
|
|
const output = allResults.map((r) => ({
|
|
id: r.id,
|
|
memory: r.memory,
|
|
score: r.score,
|
|
scope: (r as any)._scope,
|
|
categories: r.categories,
|
|
created_at: r.created_at,
|
|
}));
|
|
console.log(JSON.stringify(output, null, 2));
|
|
} catch (err) {
|
|
console.error(`Search failed: ${String(err)}`);
|
|
}
|
|
});
|
|
|
|
mem0
|
|
.command("stats")
|
|
.description("Show memory statistics from Mem0")
|
|
.option("--agent <agentId>", "Show stats for a specific agent")
|
|
.action(async (opts: { agent?: string }) => {
|
|
try {
|
|
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
|
|
const memories = await provider.getAll({
|
|
user_id: uid,
|
|
source: "OPENCLAW",
|
|
});
|
|
console.log(`Mode: ${cfg.mode}`);
|
|
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
|
|
console.log(
|
|
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
|
);
|
|
console.log(`Graph enabled: ${cfg.enableGraph}`);
|
|
console.log(
|
|
`Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`,
|
|
);
|
|
} catch (err) {
|
|
console.error(`Stats failed: ${String(err)}`);
|
|
}
|
|
});
|
|
},
|
|
{ commands: ["mem0"] },
|
|
);
|
|
|
|
// ========================================================================
|
|
// Lifecycle Hooks
|
|
// ========================================================================
|
|
|
|
// Auto-recall: inject relevant memories before agent starts
|
|
if (cfg.autoRecall) {
|
|
api.on("before_agent_start", async (event, ctx) => {
|
|
if (!event.prompt || event.prompt.length < 5) return;
|
|
|
|
// Track session ID
|
|
const sessionId = (ctx as any)?.sessionKey ?? undefined;
|
|
if (sessionId) currentSessionId = sessionId;
|
|
|
|
try {
|
|
// Search long-term memories (user-scoped, isolated per agent)
|
|
const longTermResults = await provider.search(
|
|
event.prompt,
|
|
buildSearchOptions(undefined, undefined, undefined, sessionId),
|
|
);
|
|
|
|
// Search session memories (session-scoped) if we have a session ID
|
|
let sessionResults: MemoryItem[] = [];
|
|
if (currentSessionId) {
|
|
sessionResults = await provider.search(
|
|
event.prompt,
|
|
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 totalCount = longTermResults.length + uniqueSessionResults.length;
|
|
api.logger.info(
|
|
`openclaw-mem0: injecting ${totalCount} memories into context (${longTermResults.length} long-term, ${uniqueSessionResults.length} session)`,
|
|
);
|
|
|
|
return {
|
|
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
|
};
|
|
} catch (err) {
|
|
api.logger.warn(`openclaw-mem0: recall failed: ${String(err)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Auto-capture: store conversation context after agent ends
|
|
if (cfg.autoCapture) {
|
|
api.on("agent_end", async (event, ctx) => {
|
|
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Track session ID
|
|
const sessionId = (ctx as any)?.sessionKey ?? undefined;
|
|
if (sessionId) currentSessionId = sessionId;
|
|
|
|
try {
|
|
// Extract messages, limiting to last 10
|
|
const recentMessages = event.messages.slice(-10);
|
|
const formattedMessages: Array<{
|
|
role: string;
|
|
content: string;
|
|
}> = [];
|
|
|
|
for (const msg of recentMessages) {
|
|
if (!msg || typeof msg !== "object") continue;
|
|
const msgObj = msg as Record<string, unknown>;
|
|
|
|
const role = msgObj.role;
|
|
if (role !== "user" && role !== "assistant") continue;
|
|
|
|
let textContent = "";
|
|
const content = msgObj.content;
|
|
|
|
if (typeof content === "string") {
|
|
textContent = content;
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (
|
|
block &&
|
|
typeof block === "object" &&
|
|
"text" in block &&
|
|
typeof (block as Record<string, unknown>).text === "string"
|
|
) {
|
|
textContent +=
|
|
(textContent ? "\n" : "") +
|
|
((block as Record<string, unknown>).text as string);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!textContent) continue;
|
|
// Strip injected memory context, keep the actual user text
|
|
if (textContent.includes("<relevant-memories>")) {
|
|
textContent = textContent.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g, "").trim();
|
|
if (!textContent) continue;
|
|
}
|
|
|
|
formattedMessages.push({
|
|
role: role as string,
|
|
content: textContent,
|
|
});
|
|
}
|
|
|
|
if (formattedMessages.length === 0) return;
|
|
|
|
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
|
|
const result = await provider.add(
|
|
formattedMessages,
|
|
addOpts,
|
|
);
|
|
|
|
const capturedCount = result.results?.length ?? 0;
|
|
if (capturedCount > 0) {
|
|
api.logger.info(
|
|
`openclaw-mem0: auto-captured ${capturedCount} memories`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
api.logger.warn(`openclaw-mem0: capture failed: ${String(err)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ========================================================================
|
|
// Service
|
|
// ========================================================================
|
|
|
|
api.registerService({
|
|
id: "openclaw-mem0",
|
|
start: () => {
|
|
api.logger.info(
|
|
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
|
);
|
|
},
|
|
stop: () => {
|
|
api.logger.info("openclaw-mem0: stopped");
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
export default memoryPlugin;
|