repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * OpenClaw Memory (Mem0) Plugin |
| #3 | * |
| #4 | * Long-term memory via Mem0 — supports both the Mem0 platform |
| #5 | * and the open-source self-hosted SDK. Uses the official `mem0ai` package. |
| #6 | * |
| #7 | * Features: |
| #8 | * - 5 tools: memory_search, memory_list, memory_store, memory_get, memory_forget |
| #9 | * (with session/long-term scope support via scope and longTerm parameters) |
| #10 | * - Short-term (session-scoped) and long-term (user-scoped) memory |
| #11 | * - Auto-recall: injects relevant memories (both scopes) before each agent turn |
| #12 | * - Auto-capture: stores key facts scoped to the current session after each agent turn |
| #13 | * - Per-agent isolation: multi-agent setups write/read from separate userId namespaces |
| #14 | * automatically via sessionKey routing (zero breaking changes for single-agent setups) |
| #15 | * - CLI: openclaw mem0 search, openclaw mem0 stats |
| #16 | * - Dual mode: platform or open-source (self-hosted) |
| #17 | */ |
| #18 | |
| #19 | import { Type } from "@sinclair/typebox"; |
| #20 | import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; |
| #21 | |
| #22 | // ============================================================================ |
| #23 | // Types |
| #24 | // ============================================================================ |
| #25 | |
| #26 | type Mem0Mode = "platform" | "open-source"; |
| #27 | |
| #28 | type Mem0Config = { |
| #29 | mode: Mem0Mode; |
| #30 | // Platform-specific |
| #31 | apiKey?: string; |
| #32 | orgId?: string; |
| #33 | projectId?: string; |
| #34 | customInstructions: string; |
| #35 | customCategories: Record<string, string>; |
| #36 | enableGraph: boolean; |
| #37 | // OSS-specific |
| #38 | customPrompt?: string; |
| #39 | oss?: { |
| #40 | embedder?: { provider: string; config: Record<string, unknown> }; |
| #41 | vectorStore?: { provider: string; config: Record<string, unknown> }; |
| #42 | llm?: { provider: string; config: Record<string, unknown> }; |
| #43 | historyDbPath?: string; |
| #44 | }; |
| #45 | // Shared |
| #46 | userId: string; |
| #47 | autoCapture: boolean; |
| #48 | autoRecall: boolean; |
| #49 | searchThreshold: number; |
| #50 | topK: number; |
| #51 | }; |
| #52 | |
| #53 | // Unified types for the provider interface |
| #54 | interface AddOptions { |
| #55 | user_id: string; |
| #56 | run_id?: string; |
| #57 | custom_instructions?: string; |
| #58 | custom_categories?: Array<Record<string, string>>; |
| #59 | enable_graph?: boolean; |
| #60 | output_format?: string; |
| #61 | source?: string; |
| #62 | } |
| #63 | |
| #64 | interface SearchOptions { |
| #65 | user_id: string; |
| #66 | run_id?: string; |
| #67 | top_k?: number; |
| #68 | threshold?: number; |
| #69 | limit?: number; |
| #70 | keyword_search?: boolean; |
| #71 | reranking?: boolean; |
| #72 | source?: string; |
| #73 | } |
| #74 | |
| #75 | interface ListOptions { |
| #76 | user_id: string; |
| #77 | run_id?: string; |
| #78 | page_size?: number; |
| #79 | source?: string; |
| #80 | } |
| #81 | |
| #82 | interface MemoryItem { |
| #83 | id: string; |
| #84 | memory: string; |
| #85 | user_id?: string; |
| #86 | score?: number; |
| #87 | categories?: string[]; |
| #88 | metadata?: Record<string, unknown>; |
| #89 | created_at?: string; |
| #90 | updated_at?: string; |
| #91 | } |
| #92 | |
| #93 | interface AddResultItem { |
| #94 | id: string; |
| #95 | memory: string; |
| #96 | event: "ADD" | "UPDATE" | "DELETE" | "NOOP"; |
| #97 | } |
| #98 | |
| #99 | interface AddResult { |
| #100 | results: AddResultItem[]; |
| #101 | } |
| #102 | |
| #103 | // ============================================================================ |
| #104 | // Unified Provider Interface |
| #105 | // ============================================================================ |
| #106 | |
| #107 | interface Mem0Provider { |
| #108 | add( |
| #109 | messages: Array<{ role: string; content: string }>, |
| #110 | options: AddOptions, |
| #111 | ): Promise<AddResult>; |
| #112 | search(query: string, options: SearchOptions): Promise<MemoryItem[]>; |
| #113 | get(memoryId: string): Promise<MemoryItem>; |
| #114 | getAll(options: ListOptions): Promise<MemoryItem[]>; |
| #115 | delete(memoryId: string): Promise<void>; |
| #116 | } |
| #117 | |
| #118 | // ============================================================================ |
| #119 | // Platform Provider (Mem0 Cloud) |
| #120 | // ============================================================================ |
| #121 | |
| #122 | class PlatformProvider implements Mem0Provider { |
| #123 | private client: any; // MemoryClient from mem0ai |
| #124 | private initPromise: Promise<void> | null = null; |
| #125 | |
| #126 | constructor( |
| #127 | private readonly apiKey: string, |
| #128 | private readonly orgId?: string, |
| #129 | private readonly projectId?: string, |
| #130 | ) { } |
| #131 | |
| #132 | private async ensureClient(): Promise<void> { |
| #133 | if (this.client) return; |
| #134 | if (this.initPromise) return this.initPromise; |
| #135 | this.initPromise = this._init(); |
| #136 | return this.initPromise; |
| #137 | } |
| #138 | |
| #139 | private async _init(): Promise<void> { |
| #140 | const { default: MemoryClient } = await import("mem0ai"); |
| #141 | const opts: Record<string, string> = { apiKey: this.apiKey }; |
| #142 | if (this.orgId) opts.org_id = this.orgId; |
| #143 | if (this.projectId) opts.project_id = this.projectId; |
| #144 | this.client = new MemoryClient(opts); |
| #145 | } |
| #146 | |
| #147 | async add( |
| #148 | messages: Array<{ role: string; content: string }>, |
| #149 | options: AddOptions, |
| #150 | ): Promise<AddResult> { |
| #151 | await this.ensureClient(); |
| #152 | const opts: Record<string, unknown> = { user_id: options.user_id }; |
| #153 | if (options.run_id) opts.run_id = options.run_id; |
| #154 | if (options.custom_instructions) |
| #155 | opts.custom_instructions = options.custom_instructions; |
| #156 | if (options.custom_categories) |
| #157 | opts.custom_categories = options.custom_categories; |
| #158 | if (options.enable_graph) opts.enable_graph = options.enable_graph; |
| #159 | if (options.output_format) opts.output_format = options.output_format; |
| #160 | if (options.source) opts.source = options.source; |
| #161 | |
| #162 | const result = await this.client.add(messages, opts); |
| #163 | return normalizeAddResult(result); |
| #164 | } |
| #165 | |
| #166 | async search(query: string, options: SearchOptions): Promise<MemoryItem[]> { |
| #167 | await this.ensureClient(); |
| #168 | const filters: Record<string, unknown> = { user_id: options.user_id }; |
| #169 | if (options.run_id) filters.run_id = options.run_id; |
| #170 | |
| #171 | const opts: Record<string, unknown> = { |
| #172 | api_version: "v2", |
| #173 | filters, |
| #174 | }; |
| #175 | if (options.top_k != null) opts.top_k = options.top_k; |
| #176 | if (options.threshold != null) opts.threshold = options.threshold; |
| #177 | if (options.keyword_search != null) opts.keyword_search = options.keyword_search; |
| #178 | if (options.reranking != null) opts.rerank = options.reranking; |
| #179 | |
| #180 | const results = await this.client.search(query, opts); |
| #181 | return normalizeSearchResults(results); |
| #182 | } |
| #183 | |
| #184 | async get(memoryId: string): Promise<MemoryItem> { |
| #185 | await this.ensureClient(); |
| #186 | const result = await this.client.get(memoryId); |
| #187 | return normalizeMemoryItem(result); |
| #188 | } |
| #189 | |
| #190 | async getAll(options: ListOptions): Promise<MemoryItem[]> { |
| #191 | await this.ensureClient(); |
| #192 | const opts: Record<string, unknown> = { user_id: options.user_id }; |
| #193 | if (options.run_id) opts.run_id = options.run_id; |
| #194 | if (options.page_size != null) opts.page_size = options.page_size; |
| #195 | if (options.source) opts.source = options.source; |
| #196 | |
| #197 | const results = await this.client.getAll(opts); |
| #198 | if (Array.isArray(results)) return results.map(normalizeMemoryItem); |
| #199 | // Some versions return { results: [...] } |
| #200 | if (results?.results && Array.isArray(results.results)) |
| #201 | return results.results.map(normalizeMemoryItem); |
| #202 | return []; |
| #203 | } |
| #204 | |
| #205 | async delete(memoryId: string): Promise<void> { |
| #206 | await this.ensureClient(); |
| #207 | await this.client.delete(memoryId); |
| #208 | } |
| #209 | } |
| #210 | |
| #211 | // ============================================================================ |
| #212 | // Open-Source Provider (Self-hosted) |
| #213 | // ============================================================================ |
| #214 | |
| #215 | class OSSProvider implements Mem0Provider { |
| #216 | private memory: any; // Memory from mem0ai/oss |
| #217 | private initPromise: Promise<void> | null = null; |
| #218 | |
| #219 | constructor( |
| #220 | private readonly ossConfig?: Mem0Config["oss"], |
| #221 | private readonly customPrompt?: string, |
| #222 | private readonly resolvePath?: (p: string) => string, |
| #223 | ) { } |
| #224 | |
| #225 | private async ensureMemory(): Promise<void> { |
| #226 | if (this.memory) return; |
| #227 | if (this.initPromise) return this.initPromise; |
| #228 | this.initPromise = this._init(); |
| #229 | return this.initPromise; |
| #230 | } |
| #231 | |
| #232 | private async _init(): Promise<void> { |
| #233 | const { Memory } = await import("mem0ai/oss"); |
| #234 | |
| #235 | const config: Record<string, unknown> = { version: "v1.1" }; |
| #236 | |
| #237 | if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder; |
| #238 | if (this.ossConfig?.vectorStore) |
| #239 | config.vectorStore = this.ossConfig.vectorStore; |
| #240 | if (this.ossConfig?.llm) config.llm = this.ossConfig.llm; |
| #241 | |
| #242 | if (this.ossConfig?.historyDbPath) { |
| #243 | const dbPath = this.resolvePath |
| #244 | ? this.resolvePath(this.ossConfig.historyDbPath) |
| #245 | : this.ossConfig.historyDbPath; |
| #246 | config.historyDbPath = dbPath; |
| #247 | } |
| #248 | |
| #249 | if (this.customPrompt) config.customPrompt = this.customPrompt; |
| #250 | |
| #251 | this.memory = new Memory(config); |
| #252 | } |
| #253 | |
| #254 | async add( |
| #255 | messages: Array<{ role: string; content: string }>, |
| #256 | options: AddOptions, |
| #257 | ): Promise<AddResult> { |
| #258 | await this.ensureMemory(); |
| #259 | // OSS SDK uses camelCase: userId/runId, not user_id/run_id |
| #260 | const addOpts: Record<string, unknown> = { userId: options.user_id }; |
| #261 | if (options.run_id) addOpts.runId = options.run_id; |
| #262 | if (options.source) addOpts.source = options.source; |
| #263 | const result = await this.memory.add(messages, addOpts); |
| #264 | return normalizeAddResult(result); |
| #265 | } |
| #266 | |
| #267 | async search(query: string, options: SearchOptions): Promise<MemoryItem[]> { |
| #268 | await this.ensureMemory(); |
| #269 | // OSS SDK uses camelCase: userId/runId, not user_id/run_id |
| #270 | const opts: Record<string, unknown> = { userId: options.user_id }; |
| #271 | if (options.run_id) opts.runId = options.run_id; |
| #272 | if (options.limit != null) opts.limit = options.limit; |
| #273 | else if (options.top_k != null) opts.limit = options.top_k; |
| #274 | if (options.keyword_search != null) opts.keyword_search = options.keyword_search; |
| #275 | if (options.reranking != null) opts.reranking = options.reranking; |
| #276 | if (options.source) opts.source = options.source; |
| #277 | if (options.threshold != null) opts.threshold = options.threshold; |
| #278 | |
| #279 | const results = await this.memory.search(query, opts); |
| #280 | const normalized = normalizeSearchResults(results); |
| #281 | |
| #282 | // Filter results by threshold if specified (client-side filtering as fallback) |
| #283 | if (options.threshold != null) { |
| #284 | return normalized.filter(item => (item.score ?? 0) >= options.threshold!); |
| #285 | } |
| #286 | |
| #287 | return normalized; |
| #288 | } |
| #289 | |
| #290 | async get(memoryId: string): Promise<MemoryItem> { |
| #291 | await this.ensureMemory(); |
| #292 | const result = await this.memory.get(memoryId); |
| #293 | return normalizeMemoryItem(result); |
| #294 | } |
| #295 | |
| #296 | async getAll(options: ListOptions): Promise<MemoryItem[]> { |
| #297 | await this.ensureMemory(); |
| #298 | // OSS SDK uses camelCase: userId/runId, not user_id/run_id |
| #299 | const getAllOpts: Record<string, unknown> = { userId: options.user_id }; |
| #300 | if (options.run_id) getAllOpts.runId = options.run_id; |
| #301 | if (options.source) getAllOpts.source = options.source; |
| #302 | const results = await this.memory.getAll(getAllOpts); |
| #303 | if (Array.isArray(results)) return results.map(normalizeMemoryItem); |
| #304 | if (results?.results && Array.isArray(results.results)) |
| #305 | return results.results.map(normalizeMemoryItem); |
| #306 | return []; |
| #307 | } |
| #308 | |
| #309 | async delete(memoryId: string): Promise<void> { |
| #310 | await this.ensureMemory(); |
| #311 | await this.memory.delete(memoryId); |
| #312 | } |
| #313 | } |
| #314 | |
| #315 | // ============================================================================ |
| #316 | // Result Normalizers |
| #317 | // ============================================================================ |
| #318 | |
| #319 | function normalizeMemoryItem(raw: any): MemoryItem { |
| #320 | return { |
| #321 | id: raw.id ?? raw.memory_id ?? "", |
| #322 | memory: raw.memory ?? raw.text ?? raw.content ?? "", |
| #323 | // Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names |
| #324 | user_id: raw.user_id ?? raw.userId, |
| #325 | score: raw.score, |
| #326 | categories: raw.categories, |
| #327 | metadata: raw.metadata, |
| #328 | created_at: raw.created_at ?? raw.createdAt, |
| #329 | updated_at: raw.updated_at ?? raw.updatedAt, |
| #330 | }; |
| #331 | } |
| #332 | |
| #333 | function normalizeSearchResults(raw: any): MemoryItem[] { |
| #334 | // Platform API returns flat array, OSS returns { results: [...] } |
| #335 | if (Array.isArray(raw)) return raw.map(normalizeMemoryItem); |
| #336 | if (raw?.results && Array.isArray(raw.results)) |
| #337 | return raw.results.map(normalizeMemoryItem); |
| #338 | return []; |
| #339 | } |
| #340 | |
| #341 | function normalizeAddResult(raw: any): AddResult { |
| #342 | // Handle { results: [...] } shape (both platform and OSS) |
| #343 | if (raw?.results && Array.isArray(raw.results)) { |
| #344 | return { |
| #345 | results: raw.results.map((r: any) => ({ |
| #346 | id: r.id ?? r.memory_id ?? "", |
| #347 | memory: r.memory ?? r.text ?? "", |
| #348 | // Platform API may return PENDING status (async processing) |
| #349 | // OSS stores event in metadata.event |
| #350 | event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), |
| #351 | })), |
| #352 | }; |
| #353 | } |
| #354 | // Platform API without output_format returns flat array |
| #355 | if (Array.isArray(raw)) { |
| #356 | return { |
| #357 | results: raw.map((r: any) => ({ |
| #358 | id: r.id ?? r.memory_id ?? "", |
| #359 | memory: r.memory ?? r.text ?? "", |
| #360 | event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), |
| #361 | })), |
| #362 | }; |
| #363 | } |
| #364 | return { results: [] }; |
| #365 | } |
| #366 | |
| #367 | // ============================================================================ |
| #368 | // Config Parser |
| #369 | // ============================================================================ |
| #370 | |
| #371 | function resolveEnvVars(value: string): string { |
| #372 | return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { |
| #373 | const envValue = process.env[envVar]; |
| #374 | if (!envValue) { |
| #375 | throw new Error(`Environment variable ${envVar} is not set`); |
| #376 | } |
| #377 | return envValue; |
| #378 | }); |
| #379 | } |
| #380 | |
| #381 | function resolveEnvVarsDeep(obj: Record<string, unknown>): Record<string, unknown> { |
| #382 | const result: Record<string, unknown> = {}; |
| #383 | for (const [key, value] of Object.entries(obj)) { |
| #384 | if (typeof value === "string") { |
| #385 | result[key] = resolveEnvVars(value); |
| #386 | } else if (value && typeof value === "object" && !Array.isArray(value)) { |
| #387 | result[key] = resolveEnvVarsDeep(value as Record<string, unknown>); |
| #388 | } else { |
| #389 | result[key] = value; |
| #390 | } |
| #391 | } |
| #392 | return result; |
| #393 | } |
| #394 | |
| #395 | // ============================================================================ |
| #396 | // Default Custom Instructions & Categories |
| #397 | // ============================================================================ |
| #398 | |
| #399 | 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. |
| #400 | |
| #401 | Information to Extract: |
| #402 | |
| #403 | 1. Identity & Demographics: |
| #404 | - Name, age, location, timezone, language preferences |
| #405 | - Occupation, employer, job role, industry |
| #406 | - Education background |
| #407 | |
| #408 | 2. Preferences & Opinions: |
| #409 | - Communication style preferences (formal/casual, verbose/concise) |
| #410 | - Tool and technology preferences (languages, frameworks, editors, OS) |
| #411 | - Content preferences (topics of interest, learning style) |
| #412 | - Strong opinions or values they've expressed |
| #413 | - Likes and dislikes they've explicitly stated |
| #414 | |
| #415 | 3. Goals & Projects: |
| #416 | - Current projects they're working on (name, description, status) |
| #417 | - Short-term and long-term goals |
| #418 | - Deadlines and milestones mentioned |
| #419 | - Problems they're actively trying to solve |
| #420 | |
| #421 | 4. Technical Context: |
| #422 | - Tech stack and tools they use |
| #423 | - Skill level in different areas (beginner/intermediate/expert) |
| #424 | - Development environment and setup details |
| #425 | - Recurring technical challenges |
| #426 | |
| #427 | 5. Relationships & People: |
| #428 | - Names and roles of people they mention (colleagues, family, friends) |
| #429 | - Team structure and dynamics |
| #430 | - Key contacts and their relevance |
| #431 | |
| #432 | 6. Decisions & Lessons: |
| #433 | - Important decisions made and their reasoning |
| #434 | - Lessons learned from past experiences |
| #435 | - Strategies that worked or failed |
| #436 | - Changed opinions or updated beliefs |
| #437 | |
| #438 | 7. Routines & Habits: |
| #439 | - Daily routines and schedules mentioned |
| #440 | - Work patterns (when they're productive, how they organize work) |
| #441 | - Health and wellness habits if voluntarily shared |
| #442 | |
| #443 | 8. Life Events: |
| #444 | - Significant events (new job, moving, milestones) |
| #445 | - Upcoming events or plans |
| #446 | - Changes in circumstances |
| #447 | |
| #448 | Guidelines: |
| #449 | - Store memories as clear, self-contained statements (each memory should make sense on its own) |
| #450 | - Use third person: "User prefers..." not "I prefer..." |
| #451 | - Include temporal context when relevant: "As of [date], user is working on..." |
| #452 | - When information updates, UPDATE the existing memory rather than creating duplicates |
| #453 | - Merge related facts into single coherent memories when possible |
| #454 | - Preserve specificity: "User uses Next.js 14 with App Router" is better than "User uses React" |
| #455 | - Capture the WHY behind preferences when stated: "User prefers Vim because of keyboard-driven workflow" |
| #456 | |
| #457 | Exclude: |
| #458 | - Passwords, API keys, tokens, or any authentication credentials |
| #459 | - Exact financial amounts (account balances, salaries) unless the user explicitly asks to remember them |
| #460 | - Temporary or ephemeral information (one-time questions, debugging sessions with no lasting insight) |
| #461 | - Generic small talk with no informational content |
| #462 | - The assistant's own responses unless they contain a commitment or promise to the user |
| #463 | - Raw code snippets (capture the intent/decision, not the code itself) |
| #464 | - Information the user explicitly asks not to remember`; |
| #465 | |
| #466 | const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = { |
| #467 | identity: |
| #468 | "Personal identity information: name, age, location, timezone, occupation, employer, education, demographics", |
| #469 | preferences: |
| #470 | "Explicitly stated likes, dislikes, preferences, opinions, and values across any domain", |
| #471 | goals: |
| #472 | "Current and future goals, aspirations, objectives, targets the user is working toward", |
| #473 | projects: |
| #474 | "Specific projects, initiatives, or endeavors the user is working on, including status and details", |
| #475 | technical: |
| #476 | "Technical skills, tools, tech stack, development environment, programming languages, frameworks", |
| #477 | decisions: |
| #478 | "Important decisions made, reasoning behind choices, strategy changes, and their outcomes", |
| #479 | relationships: |
| #480 | "People mentioned by the user: colleagues, family, friends, their roles and relevance", |
| #481 | routines: |
| #482 | "Daily habits, work patterns, schedules, productivity routines, health and wellness habits", |
| #483 | life_events: |
| #484 | "Significant life events, milestones, transitions, upcoming plans and changes", |
| #485 | lessons: |
| #486 | "Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs", |
| #487 | work: |
| #488 | "Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges", |
| #489 | health: |
| #490 | "Health-related information voluntarily shared: conditions, medications, fitness, wellness goals", |
| #491 | }; |
| #492 | |
| #493 | // ============================================================================ |
| #494 | // Config Schema |
| #495 | // ============================================================================ |
| #496 | |
| #497 | const ALLOWED_KEYS = [ |
| #498 | "mode", |
| #499 | "apiKey", |
| #500 | "userId", |
| #501 | "orgId", |
| #502 | "projectId", |
| #503 | "autoCapture", |
| #504 | "autoRecall", |
| #505 | "customInstructions", |
| #506 | "customCategories", |
| #507 | "customPrompt", |
| #508 | "enableGraph", |
| #509 | "searchThreshold", |
| #510 | "topK", |
| #511 | "oss", |
| #512 | ]; |
| #513 | |
| #514 | function assertAllowedKeys( |
| #515 | value: Record<string, unknown>, |
| #516 | allowed: string[], |
| #517 | label: string, |
| #518 | ) { |
| #519 | const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); |
| #520 | if (unknown.length === 0) return; |
| #521 | throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); |
| #522 | } |
| #523 | |
| #524 | const mem0ConfigSchema = { |
| #525 | parse(value: unknown): Mem0Config { |
| #526 | if (!value || typeof value !== "object" || Array.isArray(value)) { |
| #527 | throw new Error("openclaw-mem0 config required"); |
| #528 | } |
| #529 | const cfg = value as Record<string, unknown>; |
| #530 | assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config"); |
| #531 | |
| #532 | // Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform |
| #533 | const mode: Mem0Mode = |
| #534 | cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform"; |
| #535 | |
| #536 | // Platform mode requires apiKey |
| #537 | if (mode === "platform") { |
| #538 | if (typeof cfg.apiKey !== "string" || !cfg.apiKey) { |
| #539 | throw new Error( |
| #540 | "apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)", |
| #541 | ); |
| #542 | } |
| #543 | } |
| #544 | |
| #545 | // Resolve env vars in oss config |
| #546 | let ossConfig: Mem0Config["oss"]; |
| #547 | if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) { |
| #548 | ossConfig = resolveEnvVarsDeep( |
| #549 | cfg.oss as Record<string, unknown>, |
| #550 | ) as unknown as Mem0Config["oss"]; |
| #551 | } |
| #552 | |
| #553 | return { |
| #554 | mode, |
| #555 | apiKey: |
| #556 | typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined, |
| #557 | userId: |
| #558 | typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default", |
| #559 | orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined, |
| #560 | projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined, |
| #561 | autoCapture: cfg.autoCapture !== false, |
| #562 | autoRecall: cfg.autoRecall !== false, |
| #563 | customInstructions: |
| #564 | typeof cfg.customInstructions === "string" |
| #565 | ? cfg.customInstructions |
| #566 | : DEFAULT_CUSTOM_INSTRUCTIONS, |
| #567 | customCategories: |
| #568 | cfg.customCategories && |
| #569 | typeof cfg.customCategories === "object" && |
| #570 | !Array.isArray(cfg.customCategories) |
| #571 | ? (cfg.customCategories as Record<string, string>) |
| #572 | : DEFAULT_CUSTOM_CATEGORIES, |
| #573 | customPrompt: |
| #574 | typeof cfg.customPrompt === "string" |
| #575 | ? cfg.customPrompt |
| #576 | : DEFAULT_CUSTOM_INSTRUCTIONS, |
| #577 | enableGraph: cfg.enableGraph === true, |
| #578 | searchThreshold: |
| #579 | typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5, |
| #580 | topK: typeof cfg.topK === "number" ? cfg.topK : 5, |
| #581 | oss: ossConfig, |
| #582 | }; |
| #583 | }, |
| #584 | }; |
| #585 | |
| #586 | // ============================================================================ |
| #587 | // Provider Factory |
| #588 | // ============================================================================ |
| #589 | |
| #590 | function createProvider( |
| #591 | cfg: Mem0Config, |
| #592 | api: OpenClawPluginApi, |
| #593 | ): Mem0Provider { |
| #594 | if (cfg.mode === "open-source") { |
| #595 | return new OSSProvider(cfg.oss, cfg.customPrompt, (p) => |
| #596 | api.resolvePath(p), |
| #597 | ); |
| #598 | } |
| #599 | |
| #600 | return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId); |
| #601 | } |
| #602 | |
| #603 | // ============================================================================ |
| #604 | // Helpers |
| #605 | // ============================================================================ |
| #606 | |
| #607 | /** Convert Record<string, string> categories to the array format mem0ai expects */ |
| #608 | function categoriesToArray( |
| #609 | cats: Record<string, string>, |
| #610 | ): Array<Record<string, string>> { |
| #611 | return Object.entries(cats).map(([key, value]) => ({ [key]: value })); |
| #612 | } |
| #613 | |
| #614 | // ============================================================================ |
| #615 | // Per-agent isolation helpers (exported for testability) |
| #616 | // ============================================================================ |
| #617 | |
| #618 | /** |
| #619 | * Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`. |
| #620 | * Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys. |
| #621 | */ |
| #622 | export function extractAgentId(sessionKey: string | undefined): string | undefined { |
| #623 | if (!sessionKey) return undefined; |
| #624 | const match = sessionKey.match(/^agent:([^:]+):/); |
| #625 | const agentId = match?.[1]; |
| #626 | // "main" is the primary session — fall back to configured userId |
| #627 | if (!agentId || agentId === "main") return undefined; |
| #628 | return agentId; |
| #629 | } |
| #630 | |
| #631 | /** |
| #632 | * Derive the effective user_id from a session key, namespacing per-agent. |
| #633 | * Falls back to baseUserId when the session is not agent-scoped. |
| #634 | */ |
| #635 | export function effectiveUserId(baseUserId: string, sessionKey?: string): string { |
| #636 | const agentId = extractAgentId(sessionKey); |
| #637 | return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId; |
| #638 | } |
| #639 | |
| #640 | /** Build a user_id for an explicit agentId (e.g. from tool params). */ |
| #641 | export function agentUserId(baseUserId: string, agentId: string): string { |
| #642 | return `${baseUserId}:agent:${agentId}`; |
| #643 | } |
| #644 | |
| #645 | /** |
| #646 | * Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured. |
| #647 | */ |
| #648 | export function resolveUserId( |
| #649 | baseUserId: string, |
| #650 | opts: { agentId?: string; userId?: string }, |
| #651 | currentSessionId?: string, |
| #652 | ): string { |
| #653 | if (opts.agentId) return agentUserId(baseUserId, opts.agentId); |
| #654 | if (opts.userId) return opts.userId; |
| #655 | return effectiveUserId(baseUserId, currentSessionId); |
| #656 | } |
| #657 | |
| #658 | // ============================================================================ |
| #659 | // Plugin Definition |
| #660 | // ============================================================================ |
| #661 | |
| #662 | const memoryPlugin = { |
| #663 | id: "openclaw-mem0", |
| #664 | name: "Memory (Mem0)", |
| #665 | description: |
| #666 | "Mem0 memory backend — Mem0 platform or self-hosted open-source", |
| #667 | kind: "memory" as const, |
| #668 | configSchema: mem0ConfigSchema, |
| #669 | |
| #670 | register(api: OpenClawPluginApi) { |
| #671 | const cfg = mem0ConfigSchema.parse(api.pluginConfig); |
| #672 | const provider = createProvider(cfg, api); |
| #673 | |
| #674 | // Track current session ID for tool-level session scoping |
| #675 | let currentSessionId: string | undefined; |
| #676 | |
| #677 | // ======================================================================== |
| #678 | // Per-agent isolation helpers (thin wrappers around exported functions) |
| #679 | // ======================================================================== |
| #680 | const _effectiveUserId = (sessionKey?: string) => |
| #681 | effectiveUserId(cfg.userId, sessionKey); |
| #682 | const _agentUserId = (id: string) => agentUserId(cfg.userId, id); |
| #683 | const _resolveUserId = (opts: { agentId?: string; userId?: string }) => |
| #684 | resolveUserId(cfg.userId, opts, currentSessionId); |
| #685 | |
| #686 | api.logger.info( |
| #687 | `openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`, |
| #688 | ); |
| #689 | |
| #690 | // Helper: build add options |
| #691 | function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions { |
| #692 | const opts: AddOptions = { |
| #693 | user_id: userIdOverride || _effectiveUserId(sessionKey), |
| #694 | source: "OPENCLAW", |
| #695 | }; |
| #696 | if (runId) opts.run_id = runId; |
| #697 | if (cfg.mode === "platform") { |
| #698 | opts.custom_instructions = cfg.customInstructions; |
| #699 | opts.custom_categories = categoriesToArray(cfg.customCategories); |
| #700 | opts.enable_graph = cfg.enableGraph; |
| #701 | opts.output_format = "v1.1"; |
| #702 | } |
| #703 | return opts; |
| #704 | } |
| #705 | |
| #706 | // Helper: build search options |
| #707 | function buildSearchOptions( |
| #708 | userIdOverride?: string, |
| #709 | limit?: number, |
| #710 | runId?: string, |
| #711 | sessionKey?: string, |
| #712 | ): SearchOptions { |
| #713 | const opts: SearchOptions = { |
| #714 | user_id: userIdOverride || _effectiveUserId(sessionKey), |
| #715 | top_k: limit ?? cfg.topK, |
| #716 | limit: limit ?? cfg.topK, |
| #717 | threshold: cfg.searchThreshold, |
| #718 | keyword_search: true, |
| #719 | reranking: true, |
| #720 | source: "OPENCLAW", |
| #721 | }; |
| #722 | if (runId) opts.run_id = runId; |
| #723 | return opts; |
| #724 | } |
| #725 | |
| #726 | // ======================================================================== |
| #727 | // Tools |
| #728 | // ======================================================================== |
| #729 | |
| #730 | api.registerTool( |
| #731 | { |
| #732 | name: "memory_search", |
| #733 | label: "Memory Search", |
| #734 | description: |
| #735 | "Search through long-term memories stored in Mem0. Use when you need context about user preferences, past decisions, or previously discussed topics.", |
| #736 | parameters: Type.Object({ |
| #737 | query: Type.String({ description: "Search query" }), |
| #738 | limit: Type.Optional( |
| #739 | Type.Number({ |
| #740 | description: `Max results (default: ${cfg.topK})`, |
| #741 | }), |
| #742 | ), |
| #743 | userId: Type.Optional( |
| #744 | Type.String({ |
| #745 | description: |
| #746 | "User ID to scope search (default: configured userId)", |
| #747 | }), |
| #748 | ), |
| #749 | agentId: Type.Optional( |
| #750 | Type.String({ |
| #751 | description: |
| #752 | "Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.", |
| #753 | }), |
| #754 | ), |
| #755 | scope: Type.Optional( |
| #756 | Type.Union([ |
| #757 | Type.Literal("session"), |
| #758 | Type.Literal("long-term"), |
| #759 | Type.Literal("all"), |
| #760 | ], { |
| #761 | description: |
| #762 | 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', |
| #763 | }), |
| #764 | ), |
| #765 | }), |
| #766 | async execute(_toolCallId, params) { |
| #767 | const { query, limit, userId, agentId, scope = "all" } = params as { |
| #768 | query: string; |
| #769 | limit?: number; |
| #770 | userId?: string; |
| #771 | agentId?: string; |
| #772 | scope?: "session" | "long-term" | "all"; |
| #773 | }; |
| #774 | |
| #775 | try { |
| #776 | let results: MemoryItem[] = []; |
| #777 | const uid = _resolveUserId({ agentId, userId }); |
| #778 | |
| #779 | if (scope === "session") { |
| #780 | if (currentSessionId) { |
| #781 | results = await provider.search( |
| #782 | query, |
| #783 | buildSearchOptions(uid, limit, currentSessionId), |
| #784 | ); |
| #785 | } |
| #786 | } else if (scope === "long-term") { |
| #787 | results = await provider.search( |
| #788 | query, |
| #789 | buildSearchOptions(uid, limit), |
| #790 | ); |
| #791 | } else { |
| #792 | // "all" — search both scopes and combine |
| #793 | const longTermResults = await provider.search( |
| #794 | query, |
| #795 | buildSearchOptions(uid, limit), |
| #796 | ); |
| #797 | let sessionResults: MemoryItem[] = []; |
| #798 | if (currentSessionId) { |
| #799 | sessionResults = await provider.search( |
| #800 | query, |
| #801 | buildSearchOptions(uid, limit, currentSessionId), |
| #802 | ); |
| #803 | } |
| #804 | // Deduplicate by ID, preferring long-term |
| #805 | const seen = new Set(longTermResults.map((r) => r.id)); |
| #806 | results = [ |
| #807 | ...longTermResults, |
| #808 | ...sessionResults.filter((r) => !seen.has(r.id)), |
| #809 | ]; |
| #810 | } |
| #811 | |
| #812 | if (!results || results.length === 0) { |
| #813 | return { |
| #814 | content: [ |
| #815 | { type: "text", text: "No relevant memories found." }, |
| #816 | ], |
| #817 | details: { count: 0 }, |
| #818 | }; |
| #819 | } |
| #820 | |
| #821 | const text = results |
| #822 | .map( |
| #823 | (r, i) => |
| #824 | `${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`, |
| #825 | ) |
| #826 | .join("\n"); |
| #827 | |
| #828 | const sanitized = results.map((r) => ({ |
| #829 | id: r.id, |
| #830 | memory: r.memory, |
| #831 | score: r.score, |
| #832 | categories: r.categories, |
| #833 | created_at: r.created_at, |
| #834 | })); |
| #835 | |
| #836 | return { |
| #837 | content: [ |
| #838 | { |
| #839 | type: "text", |
| #840 | text: `Found ${results.length} memories:\n\n${text}`, |
| #841 | }, |
| #842 | ], |
| #843 | details: { count: results.length, memories: sanitized }, |
| #844 | }; |
| #845 | } catch (err) { |
| #846 | return { |
| #847 | content: [ |
| #848 | { |
| #849 | type: "text", |
| #850 | text: `Memory search failed: ${String(err)}`, |
| #851 | }, |
| #852 | ], |
| #853 | details: { error: String(err) }, |
| #854 | }; |
| #855 | } |
| #856 | }, |
| #857 | }, |
| #858 | { name: "memory_search" }, |
| #859 | ); |
| #860 | |
| #861 | api.registerTool( |
| #862 | { |
| #863 | name: "memory_store", |
| #864 | label: "Memory Store", |
| #865 | description: |
| #866 | "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.", |
| #867 | parameters: Type.Object({ |
| #868 | text: Type.String({ description: "Information to remember" }), |
| #869 | userId: Type.Optional( |
| #870 | Type.String({ |
| #871 | description: "User ID to scope this memory", |
| #872 | }), |
| #873 | ), |
| #874 | agentId: Type.Optional( |
| #875 | Type.String({ |
| #876 | description: |
| #877 | "Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.", |
| #878 | }), |
| #879 | ), |
| #880 | metadata: Type.Optional( |
| #881 | Type.Record(Type.String(), Type.Unknown(), { |
| #882 | description: "Optional metadata to attach to this memory", |
| #883 | }), |
| #884 | ), |
| #885 | longTerm: Type.Optional( |
| #886 | Type.Boolean({ |
| #887 | description: |
| #888 | "Store as long-term (user-scoped) memory. Default: true. Set to false for session-scoped memory.", |
| #889 | }), |
| #890 | ), |
| #891 | }), |
| #892 | async execute(_toolCallId, params) { |
| #893 | const { text, userId, agentId, longTerm = true } = params as { |
| #894 | text: string; |
| #895 | userId?: string; |
| #896 | agentId?: string; |
| #897 | metadata?: Record<string, unknown>; |
| #898 | longTerm?: boolean; |
| #899 | }; |
| #900 | |
| #901 | try { |
| #902 | const uid = _resolveUserId({ agentId, userId }); |
| #903 | const runId = !longTerm && currentSessionId ? currentSessionId : undefined; |
| #904 | const result = await provider.add( |
| #905 | [{ role: "user", content: text }], |
| #906 | buildAddOptions(uid, runId, currentSessionId), |
| #907 | ); |
| #908 | |
| #909 | const added = |
| #910 | result.results?.filter((r) => r.event === "ADD") ?? []; |
| #911 | const updated = |
| #912 | result.results?.filter((r) => r.event === "UPDATE") ?? []; |
| #913 | |
| #914 | const summary = []; |
| #915 | if (added.length > 0) |
| #916 | summary.push( |
| #917 | `${added.length} new memor${added.length === 1 ? "y" : "ies"} added`, |
| #918 | ); |
| #919 | if (updated.length > 0) |
| #920 | summary.push( |
| #921 | `${updated.length} memor${updated.length === 1 ? "y" : "ies"} updated`, |
| #922 | ); |
| #923 | if (summary.length === 0) |
| #924 | summary.push("No new memories extracted"); |
| #925 | |
| #926 | return { |
| #927 | content: [ |
| #928 | { |
| #929 | type: "text", |
| #930 | text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}`, |
| #931 | }, |
| #932 | ], |
| #933 | details: { |
| #934 | action: "stored", |
| #935 | results: result.results, |
| #936 | }, |
| #937 | }; |
| #938 | } catch (err) { |
| #939 | return { |
| #940 | content: [ |
| #941 | { |
| #942 | type: "text", |
| #943 | text: `Memory store failed: ${String(err)}`, |
| #944 | }, |
| #945 | ], |
| #946 | details: { error: String(err) }, |
| #947 | }; |
| #948 | } |
| #949 | }, |
| #950 | }, |
| #951 | { name: "memory_store" }, |
| #952 | ); |
| #953 | |
| #954 | api.registerTool( |
| #955 | { |
| #956 | name: "memory_get", |
| #957 | label: "Memory Get", |
| #958 | description: "Retrieve a specific memory by its ID from Mem0.", |
| #959 | parameters: Type.Object({ |
| #960 | memoryId: Type.String({ description: "The memory ID to retrieve" }), |
| #961 | }), |
| #962 | async execute(_toolCallId, params) { |
| #963 | const { memoryId } = params as { memoryId: string }; |
| #964 | |
| #965 | try { |
| #966 | const memory = await provider.get(memoryId); |
| #967 | |
| #968 | return { |
| #969 | content: [ |
| #970 | { |
| #971 | type: "text", |
| #972 | text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`, |
| #973 | }, |
| #974 | ], |
| #975 | details: { memory }, |
| #976 | }; |
| #977 | } catch (err) { |
| #978 | return { |
| #979 | content: [ |
| #980 | { |
| #981 | type: "text", |
| #982 | text: `Memory get failed: ${String(err)}`, |
| #983 | }, |
| #984 | ], |
| #985 | details: { error: String(err) }, |
| #986 | }; |
| #987 | } |
| #988 | }, |
| #989 | }, |
| #990 | { name: "memory_get" }, |
| #991 | ); |
| #992 | |
| #993 | api.registerTool( |
| #994 | { |
| #995 | name: "memory_list", |
| #996 | label: "Memory List", |
| #997 | description: |
| #998 | "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.", |
| #999 | parameters: Type.Object({ |
| #1000 | userId: Type.Optional( |
| #1001 | Type.String({ |
| #1002 | description: |
| #1003 | "User ID to list memories for (default: configured userId)", |
| #1004 | }), |
| #1005 | ), |
| #1006 | agentId: Type.Optional( |
| #1007 | Type.String({ |
| #1008 | description: |
| #1009 | "Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.", |
| #1010 | }), |
| #1011 | ), |
| #1012 | scope: Type.Optional( |
| #1013 | Type.Union([ |
| #1014 | Type.Literal("session"), |
| #1015 | Type.Literal("long-term"), |
| #1016 | Type.Literal("all"), |
| #1017 | ], { |
| #1018 | description: |
| #1019 | 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', |
| #1020 | }), |
| #1021 | ), |
| #1022 | }), |
| #1023 | async execute(_toolCallId, params) { |
| #1024 | const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" }; |
| #1025 | |
| #1026 | try { |
| #1027 | let memories: MemoryItem[] = []; |
| #1028 | const uid = _resolveUserId({ agentId, userId }); |
| #1029 | |
| #1030 | if (scope === "session") { |
| #1031 | if (currentSessionId) { |
| #1032 | memories = await provider.getAll({ |
| #1033 | user_id: uid, |
| #1034 | run_id: currentSessionId, |
| #1035 | source: "OPENCLAW", |
| #1036 | }); |
| #1037 | } |
| #1038 | } else if (scope === "long-term") { |
| #1039 | memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); |
| #1040 | } else { |
| #1041 | // "all" — combine both scopes |
| #1042 | const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); |
| #1043 | let session: MemoryItem[] = []; |
| #1044 | if (currentSessionId) { |
| #1045 | session = await provider.getAll({ |
| #1046 | user_id: uid, |
| #1047 | run_id: currentSessionId, |
| #1048 | source: "OPENCLAW", |
| #1049 | }); |
| #1050 | } |
| #1051 | const seen = new Set(longTerm.map((r) => r.id)); |
| #1052 | memories = [ |
| #1053 | ...longTerm, |
| #1054 | ...session.filter((r) => !seen.has(r.id)), |
| #1055 | ]; |
| #1056 | } |
| #1057 | |
| #1058 | if (!memories || memories.length === 0) { |
| #1059 | return { |
| #1060 | content: [ |
| #1061 | { type: "text", text: "No memories stored yet." }, |
| #1062 | ], |
| #1063 | details: { count: 0 }, |
| #1064 | }; |
| #1065 | } |
| #1066 | |
| #1067 | const text = memories |
| #1068 | .map( |
| #1069 | (r, i) => |
| #1070 | `${i + 1}. ${r.memory} (id: ${r.id})`, |
| #1071 | ) |
| #1072 | .join("\n"); |
| #1073 | |
| #1074 | const sanitized = memories.map((r) => ({ |
| #1075 | id: r.id, |
| #1076 | memory: r.memory, |
| #1077 | categories: r.categories, |
| #1078 | created_at: r.created_at, |
| #1079 | })); |
| #1080 | |
| #1081 | return { |
| #1082 | content: [ |
| #1083 | { |
| #1084 | type: "text", |
| #1085 | text: `${memories.length} memories:\n\n${text}`, |
| #1086 | }, |
| #1087 | ], |
| #1088 | details: { count: memories.length, memories: sanitized }, |
| #1089 | }; |
| #1090 | } catch (err) { |
| #1091 | return { |
| #1092 | content: [ |
| #1093 | { |
| #1094 | type: "text", |
| #1095 | text: `Memory list failed: ${String(err)}`, |
| #1096 | }, |
| #1097 | ], |
| #1098 | details: { error: String(err) }, |
| #1099 | }; |
| #1100 | } |
| #1101 | }, |
| #1102 | }, |
| #1103 | { name: "memory_list" }, |
| #1104 | ); |
| #1105 | |
| #1106 | api.registerTool( |
| #1107 | { |
| #1108 | name: "memory_forget", |
| #1109 | label: "Memory Forget", |
| #1110 | description: |
| #1111 | "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.", |
| #1112 | parameters: Type.Object({ |
| #1113 | query: Type.Optional( |
| #1114 | Type.String({ |
| #1115 | description: "Search query to find memory to delete", |
| #1116 | }), |
| #1117 | ), |
| #1118 | memoryId: Type.Optional( |
| #1119 | Type.String({ description: "Specific memory ID to delete" }), |
| #1120 | ), |
| #1121 | agentId: Type.Optional( |
| #1122 | Type.String({ |
| #1123 | description: |
| #1124 | "Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").", |
| #1125 | }), |
| #1126 | ), |
| #1127 | }), |
| #1128 | async execute(_toolCallId, params) { |
| #1129 | const { query, memoryId, agentId } = params as { |
| #1130 | query?: string; |
| #1131 | memoryId?: string; |
| #1132 | agentId?: string; |
| #1133 | }; |
| #1134 | |
| #1135 | try { |
| #1136 | if (memoryId) { |
| #1137 | await provider.delete(memoryId); |
| #1138 | return { |
| #1139 | content: [ |
| #1140 | { type: "text", text: `Memory ${memoryId} forgotten.` }, |
| #1141 | ], |
| #1142 | details: { action: "deleted", id: memoryId }, |
| #1143 | }; |
| #1144 | } |
| #1145 | |
| #1146 | if (query) { |
| #1147 | const uid = _resolveUserId({ agentId }); |
| #1148 | const results = await provider.search( |
| #1149 | query, |
| #1150 | buildSearchOptions(uid, 5), |
| #1151 | ); |
| #1152 | |
| #1153 | if (!results || results.length === 0) { |
| #1154 | return { |
| #1155 | content: [ |
| #1156 | { type: "text", text: "No matching memories found." }, |
| #1157 | ], |
| #1158 | details: { found: 0 }, |
| #1159 | }; |
| #1160 | } |
| #1161 | |
| #1162 | // If single high-confidence match, delete directly |
| #1163 | if ( |
| #1164 | results.length === 1 || |
| #1165 | (results[0].score ?? 0) > 0.9 |
| #1166 | ) { |
| #1167 | await provider.delete(results[0].id); |
| #1168 | return { |
| #1169 | content: [ |
| #1170 | { |
| #1171 | type: "text", |
| #1172 | text: `Forgotten: "${results[0].memory}"`, |
| #1173 | }, |
| #1174 | ], |
| #1175 | details: { action: "deleted", id: results[0].id }, |
| #1176 | }; |
| #1177 | } |
| #1178 | |
| #1179 | const list = results |
| #1180 | .map( |
| #1181 | (r) => |
| #1182 | `- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (score: ${((r.score ?? 0) * 100).toFixed(0)}%)`, |
| #1183 | ) |
| #1184 | .join("\n"); |
| #1185 | |
| #1186 | const candidates = results.map((r) => ({ |
| #1187 | id: r.id, |
| #1188 | memory: r.memory, |
| #1189 | score: r.score, |
| #1190 | })); |
| #1191 | |
| #1192 | return { |
| #1193 | content: [ |
| #1194 | { |
| #1195 | type: "text", |
| #1196 | text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`, |
| #1197 | }, |
| #1198 | ], |
| #1199 | details: { action: "candidates", candidates }, |
| #1200 | }; |
| #1201 | } |
| #1202 | |
| #1203 | return { |
| #1204 | content: [ |
| #1205 | { type: "text", text: "Provide a query or memoryId." }, |
| #1206 | ], |
| #1207 | details: { error: "missing_param" }, |
| #1208 | }; |
| #1209 | } catch (err) { |
| #1210 | return { |
| #1211 | content: [ |
| #1212 | { |
| #1213 | type: "text", |
| #1214 | text: `Memory forget failed: ${String(err)}`, |
| #1215 | }, |
| #1216 | ], |
| #1217 | details: { error: String(err) }, |
| #1218 | }; |
| #1219 | } |
| #1220 | }, |
| #1221 | }, |
| #1222 | { name: "memory_forget" }, |
| #1223 | ); |
| #1224 | |
| #1225 | // ======================================================================== |
| #1226 | // CLI Commands |
| #1227 | // ======================================================================== |
| #1228 | |
| #1229 | api.registerCli( |
| #1230 | ({ program }) => { |
| #1231 | const mem0 = program |
| #1232 | .command("mem0") |
| #1233 | .description("Mem0 memory plugin commands"); |
| #1234 | |
| #1235 | mem0 |
| #1236 | .command("search") |
| #1237 | .description("Search memories in Mem0") |
| #1238 | .argument("<query>", "Search query") |
| #1239 | .option("--limit <n>", "Max results", String(cfg.topK)) |
| #1240 | .option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all") |
| #1241 | .option("--agent <agentId>", "Search a specific agent's memory namespace") |
| #1242 | .action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => { |
| #1243 | try { |
| #1244 | const limit = parseInt(opts.limit, 10); |
| #1245 | const scope = opts.scope as "session" | "long-term" | "all"; |
| #1246 | const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId); |
| #1247 | |
| #1248 | let allResults: MemoryItem[] = []; |
| #1249 | |
| #1250 | if (scope === "session" || scope === "all") { |
| #1251 | if (currentSessionId) { |
| #1252 | const sessionResults = await provider.search( |
| #1253 | query, |
| #1254 | buildSearchOptions(uid, limit, currentSessionId), |
| #1255 | ); |
| #1256 | if (sessionResults?.length) { |
| #1257 | allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const }))); |
| #1258 | } |
| #1259 | } else if (scope === "session") { |
| #1260 | console.log("No active session ID available for session-scoped search."); |
| #1261 | return; |
| #1262 | } |
| #1263 | } |
| #1264 | |
| #1265 | if (scope === "long-term" || scope === "all") { |
| #1266 | const longTermResults = await provider.search( |
| #1267 | query, |
| #1268 | buildSearchOptions(uid, limit), |
| #1269 | ); |
| #1270 | if (longTermResults?.length) { |
| #1271 | allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const }))); |
| #1272 | } |
| #1273 | } |
| #1274 | |
| #1275 | // Deduplicate by ID when searching "all" |
| #1276 | if (scope === "all") { |
| #1277 | const seen = new Set<string>(); |
| #1278 | allResults = allResults.filter((r) => { |
| #1279 | if (seen.has(r.id)) return false; |
| #1280 | seen.add(r.id); |
| #1281 | return true; |
| #1282 | }); |
| #1283 | } |
| #1284 | |
| #1285 | if (!allResults.length) { |
| #1286 | console.log("No memories found."); |
| #1287 | return; |
| #1288 | } |
| #1289 | |
| #1290 | const output = allResults.map((r) => ({ |
| #1291 | id: r.id, |
| #1292 | memory: r.memory, |
| #1293 | score: r.score, |
| #1294 | scope: (r as any)._scope, |
| #1295 | categories: r.categories, |
| #1296 | created_at: r.created_at, |
| #1297 | })); |
| #1298 | console.log(JSON.stringify(output, null, 2)); |
| #1299 | } catch (err) { |
| #1300 | console.error(`Search failed: ${String(err)}`); |
| #1301 | } |
| #1302 | }); |
| #1303 | |
| #1304 | mem0 |
| #1305 | .command("stats") |
| #1306 | .description("Show memory statistics from Mem0") |
| #1307 | .option("--agent <agentId>", "Show stats for a specific agent") |
| #1308 | .action(async (opts: { agent?: string }) => { |
| #1309 | try { |
| #1310 | const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId; |
| #1311 | const memories = await provider.getAll({ |
| #1312 | user_id: uid, |
| #1313 | source: "OPENCLAW", |
| #1314 | }); |
| #1315 | console.log(`Mode: ${cfg.mode}`); |
| #1316 | console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`); |
| #1317 | console.log( |
| #1318 | `Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`, |
| #1319 | ); |
| #1320 | console.log(`Graph enabled: ${cfg.enableGraph}`); |
| #1321 | console.log( |
| #1322 | `Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`, |
| #1323 | ); |
| #1324 | } catch (err) { |
| #1325 | console.error(`Stats failed: ${String(err)}`); |
| #1326 | } |
| #1327 | }); |
| #1328 | }, |
| #1329 | { commands: ["mem0"] }, |
| #1330 | ); |
| #1331 | |
| #1332 | // ======================================================================== |
| #1333 | // Lifecycle Hooks |
| #1334 | // ======================================================================== |
| #1335 | |
| #1336 | // Auto-recall: inject relevant memories before agent starts |
| #1337 | if (cfg.autoRecall) { |
| #1338 | api.on("before_agent_start", async (event, ctx) => { |
| #1339 | if (!event.prompt || event.prompt.length < 5) return; |
| #1340 | |
| #1341 | // Track session ID |
| #1342 | const sessionId = (ctx as any)?.sessionKey ?? undefined; |
| #1343 | if (sessionId) currentSessionId = sessionId; |
| #1344 | |
| #1345 | try { |
| #1346 | // Search long-term memories (user-scoped, isolated per agent) |
| #1347 | const longTermResults = await provider.search( |
| #1348 | event.prompt, |
| #1349 | buildSearchOptions(undefined, undefined, undefined, sessionId), |
| #1350 | ); |
| #1351 | |
| #1352 | // Search session memories (session-scoped) if we have a session ID |
| #1353 | let sessionResults: MemoryItem[] = []; |
| #1354 | if (currentSessionId) { |
| #1355 | sessionResults = await provider.search( |
| #1356 | event.prompt, |
| #1357 | buildSearchOptions(undefined, undefined, currentSessionId, sessionId), |
| #1358 | ); |
| #1359 | } |
| #1360 | |
| #1361 | // Deduplicate session results against long-term |
| #1362 | const longTermIds = new Set(longTermResults.map((r) => r.id)); |
| #1363 | const uniqueSessionResults = sessionResults.filter( |
| #1364 | (r) => !longTermIds.has(r.id), |
| #1365 | ); |
| #1366 | |
| #1367 | if (longTermResults.length === 0 && uniqueSessionResults.length === 0) return; |
| #1368 | |
| #1369 | // Build context with clear labels |
| #1370 | let memoryContext = ""; |
| #1371 | if (longTermResults.length > 0) { |
| #1372 | memoryContext += longTermResults |
| #1373 | .map( |
| #1374 | (r) => |
| #1375 | `- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`, |
| #1376 | ) |
| #1377 | .join("\n"); |
| #1378 | } |
| #1379 | if (uniqueSessionResults.length > 0) { |
| #1380 | if (memoryContext) memoryContext += "\n"; |
| #1381 | memoryContext += "\nSession memories:\n"; |
| #1382 | memoryContext += uniqueSessionResults |
| #1383 | .map((r) => `- ${r.memory}`) |
| #1384 | .join("\n"); |
| #1385 | } |
| #1386 | |
| #1387 | const totalCount = longTermResults.length + uniqueSessionResults.length; |
| #1388 | api.logger.info( |
| #1389 | `openclaw-mem0: injecting ${totalCount} memories into context (${longTermResults.length} long-term, ${uniqueSessionResults.length} session)`, |
| #1390 | ); |
| #1391 | |
| #1392 | return { |
| #1393 | prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`, |
| #1394 | }; |
| #1395 | } catch (err) { |
| #1396 | api.logger.warn(`openclaw-mem0: recall failed: ${String(err)}`); |
| #1397 | } |
| #1398 | }); |
| #1399 | } |
| #1400 | |
| #1401 | // Auto-capture: store conversation context after agent ends |
| #1402 | if (cfg.autoCapture) { |
| #1403 | api.on("agent_end", async (event, ctx) => { |
| #1404 | if (!event.success || !event.messages || event.messages.length === 0) { |
| #1405 | return; |
| #1406 | } |
| #1407 | |
| #1408 | // Track session ID |
| #1409 | const sessionId = (ctx as any)?.sessionKey ?? undefined; |
| #1410 | if (sessionId) currentSessionId = sessionId; |
| #1411 | |
| #1412 | try { |
| #1413 | // Extract messages, limiting to last 10 |
| #1414 | const recentMessages = event.messages.slice(-10); |
| #1415 | const formattedMessages: Array<{ |
| #1416 | role: string; |
| #1417 | content: string; |
| #1418 | }> = []; |
| #1419 | |
| #1420 | for (const msg of recentMessages) { |
| #1421 | if (!msg || typeof msg !== "object") continue; |
| #1422 | const msgObj = msg as Record<string, unknown>; |
| #1423 | |
| #1424 | const role = msgObj.role; |
| #1425 | if (role !== "user" && role !== "assistant") continue; |
| #1426 | |
| #1427 | let textContent = ""; |
| #1428 | const content = msgObj.content; |
| #1429 | |
| #1430 | if (typeof content === "string") { |
| #1431 | textContent = content; |
| #1432 | } else if (Array.isArray(content)) { |
| #1433 | for (const block of content) { |
| #1434 | if ( |
| #1435 | block && |
| #1436 | typeof block === "object" && |
| #1437 | "text" in block && |
| #1438 | typeof (block as Record<string, unknown>).text === "string" |
| #1439 | ) { |
| #1440 | textContent += |
| #1441 | (textContent ? "\n" : "") + |
| #1442 | ((block as Record<string, unknown>).text as string); |
| #1443 | } |
| #1444 | } |
| #1445 | } |
| #1446 | |
| #1447 | if (!textContent) continue; |
| #1448 | // Strip injected memory context, keep the actual user text |
| #1449 | if (textContent.includes("<relevant-memories>")) { |
| #1450 | textContent = textContent.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g, "").trim(); |
| #1451 | if (!textContent) continue; |
| #1452 | } |
| #1453 | |
| #1454 | formattedMessages.push({ |
| #1455 | role: role as string, |
| #1456 | content: textContent, |
| #1457 | }); |
| #1458 | } |
| #1459 | |
| #1460 | if (formattedMessages.length === 0) return; |
| #1461 | |
| #1462 | const addOpts = buildAddOptions(undefined, currentSessionId, sessionId); |
| #1463 | const result = await provider.add( |
| #1464 | formattedMessages, |
| #1465 | addOpts, |
| #1466 | ); |
| #1467 | |
| #1468 | const capturedCount = result.results?.length ?? 0; |
| #1469 | if (capturedCount > 0) { |
| #1470 | api.logger.info( |
| #1471 | `openclaw-mem0: auto-captured ${capturedCount} memories`, |
| #1472 | ); |
| #1473 | } |
| #1474 | } catch (err) { |
| #1475 | api.logger.warn(`openclaw-mem0: capture failed: ${String(err)}`); |
| #1476 | } |
| #1477 | }); |
| #1478 | } |
| #1479 | |
| #1480 | // ======================================================================== |
| #1481 | // Service |
| #1482 | // ======================================================================== |
| #1483 | |
| #1484 | api.registerService({ |
| #1485 | id: "openclaw-mem0", |
| #1486 | start: () => { |
| #1487 | api.logger.info( |
| #1488 | `openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`, |
| #1489 | ); |
| #1490 | }, |
| #1491 | stop: () => { |
| #1492 | api.logger.info("openclaw-mem0: stopped"); |
| #1493 | }, |
| #1494 | }); |
| #1495 | }, |
| #1496 | }; |
| #1497 | |
| #1498 | export default memoryPlugin; |
| #1499 |