/** * OpenClaw ModelStudio Memory Plugin * * Alibaba Cloud Bailian long-term memory service. Provides: * - memory_search: semantic search * - memory_store: manual store (uses custom_content) * - memory_list: list all memories * - memory_forget: delete specified memories * - autoRecall: auto-recall relevant memories * - autoCapture: auto-capture conversations * - CLI: openclaw modelstudio-memory search/stats */ import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; // ============================================================================ // Types // ============================================================================ type BailianMemoryConfig = { apiKey: string; userId: string; baseUrl: string; autoCapture: boolean; autoRecall: boolean; topK: number; minScore: number; captureMaxMessages: number; recallMinPromptLength: number; recallCacheTtlMs: number; // 新增字段 profileSchema?: string; memoryLibraryId?: string; projectId?: string; }; interface MemoryNode { memory_node_id: string; content: string; created_at?: number; updated_at?: number; score?: number; } interface SearchResponse { request_id: string; memory_nodes: MemoryNode[]; } interface AddResponse { request_id: string; memory_nodes: Array<{ memory_node_id: string; content: string; event: string; }>; } interface ListResponse { request_id: string; memory_nodes: MemoryNode[]; total: number; page_num: number; page_size: number; } // ============================================================================ // Config Schema // ============================================================================ const DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/api/v2/apps/memory"; const ALLOWED_KEYS = [ "apiKey", "userId", "baseUrl", "autoCapture", "autoRecall", "topK", "minScore", "captureMaxMessages", "recallMinPromptLength", "recallCacheTtlMs", "profileSchema", "memoryLibraryId", "projectId", ]; function assertAllowedKeys( value: Record, allowed: string[], label: string ): void { const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); if (unknown.length === 0) return; throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); } const modelstudioMemoryConfigSchema = { parse(value: unknown): BailianMemoryConfig { const raw = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; const cfg = "config" in raw && raw.config && typeof raw.config === "object" && !Array.isArray(raw.config) ? (raw.config as Record) : raw; assertAllowedKeys(cfg, ALLOWED_KEYS, "modelstudio-memory-for-openclaw config"); // apiKey: allow empty at parse time; tools return config prompt when missing const apiKey = typeof cfg.apiKey === "string" ? cfg.apiKey : ""; const userId = typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "openclaw_memory"; return { apiKey, userId, baseUrl: typeof cfg.baseUrl === "string" && cfg.baseUrl ? cfg.baseUrl : DEFAULT_BASE_URL, autoCapture: cfg.autoCapture !== false, autoRecall: cfg.autoRecall !== false, topK: typeof cfg.topK === "number" ? cfg.topK : 10, minScore: typeof cfg.minScore === "number" ? cfg.minScore : 0, captureMaxMessages: typeof cfg.captureMaxMessages === "number" && cfg.captureMaxMessages >= 1 ? Math.min(cfg.captureMaxMessages, 50) : 10, recallMinPromptLength: typeof cfg.recallMinPromptLength === "number" ? cfg.recallMinPromptLength : 10, // recallCacheTtlMs: reserved for future recall cache; currently unused recallCacheTtlMs: typeof cfg.recallCacheTtlMs === "number" ? cfg.recallCacheTtlMs : 300000, // 新增字段 profileSchema: typeof cfg.profileSchema === "string" ? cfg.profileSchema : undefined, memoryLibraryId: typeof cfg.memoryLibraryId === "string" ? cfg.memoryLibraryId : undefined, projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined, }; }, }; // ============================================================================ // API Client // ============================================================================ class BailianMemoryClient { constructor( private baseUrl: string, private apiKey: string, private userId: string, private logger: any, private profileSchema?: string, private memoryLibraryId?: string, private projectId?: string ) {} /** * Add memories (auto-extracted from conversation) */ async addMemory( messages: Array<{ role: string; content: string }> ): Promise { const response = await fetch(`${this.baseUrl}/add`, { method: "POST", headers: this.getHeaders(), body: JSON.stringify({ user_id: this.userId, messages, source: "openclaw", ...(this.profileSchema && { profile_schema: this.profileSchema }), ...(this.memoryLibraryId && { memory_library_id: this.memoryLibraryId }), ...(this.projectId && { project_id: this.projectId }), }), }); return this.handleResponse(response); } /** * Add memories asynchronously (auto-extracted from conversation). * Same params as addMemory, uses /add-async endpoint for non-blocking capture. */ async addAsyncMemory( messages: Array<{ role: string; content: string }> ): Promise { const response = await fetch(`${this.baseUrl}/add-async`, { method: "POST", headers: this.getHeaders(), body: JSON.stringify({ user_id: this.userId, messages, source: "openclaw", ...(this.profileSchema && { profile_schema: this.profileSchema }), ...(this.memoryLibraryId && { memory_library_id: this.memoryLibraryId }), ...(this.projectId && { project_id: this.projectId }), }), }); return this.handleResponse(response); } /** * Add custom content (direct storage, no extraction) */ async addCustomContent(content: string): Promise { const response = await fetch(`${this.baseUrl}/add`, { method: "POST", headers: this.getHeaders(), body: JSON.stringify({ user_id: this.userId, custom_content: content, source: "openclaw", ...(this.profileSchema && { profile_schema: this.profileSchema }), ...(this.memoryLibraryId && { memory_library_id: this.memoryLibraryId }), ...(this.projectId && { project_id: this.projectId }), }), }); return this.handleResponse(response); } /** * Search memories */ async searchMemory( messages: Array<{ role: string; content: string }>, topK: number, minScore: number ): Promise { const response = await fetch(`${this.baseUrl}/memory_nodes/search`, { method: "POST", headers: this.getHeaders(), body: JSON.stringify({ user_id: this.userId, messages, top_k: topK, min_score: minScore, source: "openclaw", ...(this.memoryLibraryId && { memory_library_id: this.memoryLibraryId }), ...(this.projectId && { project_id: this.projectId }), }), }); return this.handleResponse(response); } /** * List memories */ async listMemory(pageNum: number, pageSize: number): Promise { const params = new URLSearchParams({ user_id: this.userId, page_num: String(pageNum), page_size: String(pageSize), ...(this.memoryLibraryId && { memory_library_id: this.memoryLibraryId }), ...(this.projectId && { project_id: this.projectId }), }); const url = `${this.baseUrl}/memory_nodes?${params.toString()}`; const response = await fetch(url, { method: "GET", headers: this.getHeaders(), }); return this.handleResponse(response); } /** * Delete memory */ async deleteMemory(memoryNodeId: string): Promise { const params = new URLSearchParams(); if (this.memoryLibraryId) { params.append('memory_library_id', this.memoryLibraryId); } const url = `${this.baseUrl}/memory_nodes/${encodeURIComponent(memoryNodeId)}${params.toString() ? '?' + params.toString() : ''}`; const response = await fetch(url, { method: "DELETE", headers: this.getHeaders(), }); await this.handleResponse(response); } /** * Get user profile for configured profile schema * Returns null if profileSchema is not configured */ async getUserProfile(): Promise<{ schema_name: string; schema_description: string; attributes: Array<{ id: string; name: string; value: string | null }>; } | null> { if (!this.profileSchema) { return null; } const params = new URLSearchParams({ user_id: this.userId, ...(this.memoryLibraryId && { memory_library_id: this.memoryLibraryId }), }); const url = `${this.baseUrl}/profile_schemas/${encodeURIComponent(this.profileSchema)}/user_profile?${params.toString()}`; try { const response = await fetch(url, { method: "GET", headers: this.getHeaders(), }); const result = await this.handleResponse(response); return result.profile || null; } catch (err) { this.logger.warn(`modelstudio-memory: getUserProfile failed: ${err}`); return null; } } private getHeaders(): Record { return { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", "User-Agent": "openclaw", }; } private async handleResponse(response: Response): Promise { if (!response.ok) { let errorMessage = `HTTP ${response.status}`; try { const error = await response.json(); errorMessage = error.message || error.error || errorMessage; } catch {} throw new Error(`Bailian API Error: ${errorMessage}`); } const text = await response.text(); if (!text.trim()) return {}; try { return JSON.parse(text); } catch { throw new Error("Bailian API Error: invalid JSON response"); } } } // ============================================================================ // Helpers // ============================================================================ const PROMPT_ESCAPE_MAP: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; function escapeMemoryForPrompt(text: string): string { return text.replace(/[&<>"']/g, (char) => PROMPT_ESCAPE_MAP[char] ?? char); } const INBOUND_SENTINELS = [ "Conversation info (untrusted metadata):", "Sender (untrusted metadata):", "Thread starter (untrusted, for context):", "Replied message (untrusted, for context):", "Forwarded message context (untrusted metadata):", "Chat history since last reply (untrusted, for context):", ]; function stripInboundMetadataFromText(text: string): string { if (!text || !INBOUND_SENTINELS.some((s) => text.includes(s))) return text; const lines = text.split(/\r?\n/); const result: string[] = []; let i = 0; while (i < lines.length) { const trimmed = lines[i]?.trim() ?? ""; if (INBOUND_SENTINELS.includes(trimmed) && lines[i + 1]?.trim() === "```json") { i += 2; while (i < lines.length && lines[i]?.trim() !== "```") i++; if (lines[i]?.trim() === "```") i++; while (i < lines.length && lines[i]?.trim() === "") i++; continue; } if (/^\[.*?GMT.*?\]\s*$/.test(trimmed)) { i++; continue; } const tsMatch = lines[i]?.match(/^(\[.*?GMT.*?\])\s*(.*)$/); if (tsMatch) { result.push(tsMatch[2] || ""); } else { result.push(lines[i] ?? ""); } i++; } return result.join("\n").replace(/^\n+|\n+$/g, ""); } /** * Extract text from message content, stripping inbound metadata and injected context */ function extractTextContent(content: unknown): string { let rawText = ""; if (typeof content === "string") { rawText = content; } else if (Array.isArray(content)) { rawText = content .filter((block) => block && typeof block === "object" && "text" in block) .map((block) => (block as { text: string }).text) .join("\n"); } else { return ""; } return stripInjectedContext(stripInboundMetadataFromText(rawText)).trim(); } /** * Format memory context for autoRecall (injected into system prompt) */ function formatMemoriesContext(memories: MemoryNode[]): string { const lines = memories.map((m, i) => `${i + 1}. ${escapeMemoryForPrompt(m.content)}` ); return `\nBelow are memory entries retrieved from previous interactions.\n\nGuidelines:\n- Use memories as contextual reference to understand past patterns and information\n- Apply relevant insights when they help with the current task\n\n${lines.join("\n")}\n`; } /** * Strip injected context from message */ function stripInjectedContext(text: string): string { return text.replace(/[\s\S]*?<\/relevant-memories>\s*/g, "").trim(); } const CONFIG_REQUIRED_MESSAGE = "Memory plugin is not configured. Please add apiKey to ~/.openclaw/openclaw.json under plugins.entries.modelstudio-memory-for-openclaw.config.apiKey, then restart the gateway. "; // ============================================================================ // Plugin Definition // ============================================================================ const modelstudioMemoryPlugin = { id: "modelstudio-memory-for-openclaw", name: "Memory (Bailian)", description: "Alibaba Cloud Bailian long-term memory service", kind: "memory" as const, configSchema: modelstudioMemoryConfigSchema, register(api: OpenClawPluginApi) { const cfg = modelstudioMemoryConfigSchema.parse(api.pluginConfig); const client = new BailianMemoryClient( cfg.baseUrl, cfg.apiKey, cfg.userId, api.logger, cfg.profileSchema, cfg.memoryLibraryId, cfg.projectId ); if (!cfg.apiKey) { const msg = "[modelstudio-memory] apiKey not configured. Memory features disabled. Add apiKey to ~/.openclaw/openclaw.json under plugins.entries.modelstudio-memory-for-openclaw.config"; api.logger.warn(msg); console.warn(msg); } api.logger.info( `modelstudio-memory: registered (user: ${cfg.userId}, autoCapture: ${cfg.autoCapture}, autoRecall: ${cfg.autoRecall})` ); // ======================================================================== // Tools // ======================================================================== // ========== memory_search ========== api.registerTool( { name: "memory_search", description: "Search long-term memories. Use when the user asks about past conversations, preferences, decisions, or previously discussed topics. Returns top relevant memories with IDs and content.", parameters: Type.Object({ query: Type.String({ description: "Search query" }), limit: Type.Optional( Type.Number({ default: cfg.topK, description: "Max results to return" }) ), }), async execute(_id, params) { if (!cfg.apiKey) { return { content: [{ type: "text", text: CONFIG_REQUIRED_MESSAGE }], isError: true, }; } try { const limit = Math.min( Math.max(1, params.limit ?? cfg.topK), 100 ); const query = extractTextContent(params?.query ?? ""); if (!query.trim()) { return { content: [{ type: "text", text: "Search query is empty after stripping metadata" }], isError: true, }; } const messages = [{ role: "user" as const, content: query }]; const result = await client.searchMemory( messages, limit, cfg.minScore ); const memories = result.memory_nodes || []; if (memories.length === 0) { return { content: [{ type: "text", text: "No relevant memories found" }], }; } const text = memories .map((m, i) => `${i + 1}. [${m.memory_node_id}] ${m.content}`) .join("\n"); return { content: [ { type: "text", text: `Found ${memories.length} relevant memories:\n\n${text}`, }, ], details: { count: memories.length, memories: memories.map((m) => ({ id: m.memory_node_id, content: m.content, score: m.score, created_at: m.created_at, updated_at: m.updated_at, })), }, }; } catch (err) { return { content: [ { type: "text", text: `Memory search failed: ${err}` }, ], isError: true, }; } }, }, { name: "memory_search" } ); // ========== memory_store ========== api.registerTool( { name: "memory_store", description: "Save information in long-term memory. Use when the user explicitly asks to remember something (e.g., preferences, facts, decisions). Stores content as-is without extraction.", parameters: Type.Object({ content: Type.String({ description: "Content to store" }), }), async execute(_id, params) { if (!cfg.apiKey) { return { content: [{ type: "text", text: CONFIG_REQUIRED_MESSAGE }], isError: true, }; } try { const result = await client.addCustomContent(params.content); const addedCount = result.memory_nodes?.length || 0; return { content: [ { type: "text", text: addedCount > 0 ? `Stored ${addedCount} memories` : "Stored successfully", }, ], details: { action: "store", count: addedCount, memory_nodes: result.memory_nodes, }, }; } catch (err) { return { content: [ { type: "text", text: `Memory storage failed: ${err}` }, ], isError: true, }; } }, }, { name: "memory_store" } ); // ========== memory_list ========== api.registerTool( { name: "memory_list", description: "List all stored memories with pagination. Use when the user asks what has been remembered, to browse memories, or to find a memory ID before deleting.", parameters: Type.Object({ page: Type.Optional( Type.Number({ default: 1, description: "Page number" }) ), pageSize: Type.Optional( Type.Number({ default: 10, description: "Page size" }) ), }), async execute(_id, params) { if (!cfg.apiKey) { return { content: [{ type: "text", text: CONFIG_REQUIRED_MESSAGE }], isError: true, }; } try { const page = Math.max(1, params.page ?? 1); const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 10)); const result = await client.listMemory(page, pageSize); const memories = result.memory_nodes || []; if (memories.length === 0) { return { content: [{ type: "text", text: "No memories yet" }], }; } const text = memories .map((m, i) => { const updatedAt = m.updated_at ? new Date(m.updated_at * 1000).toISOString() : 'N/A'; return `${i + 1}. [${m.memory_node_id}] ${m.content} (updated at: ${updatedAt})`; }) .join("\n"); return { content: [ { type: "text", text: `Total ${result.total} memories, showing page ${result.page_num}:\n\n${text}`, }, ], details: { total: result.total, page: result.page_num, pageSize: result.page_size, memories: memories.map((m) => ({ id: m.memory_node_id, content: m.content, created_at: m.created_at, updated_at: m.updated_at, })), }, }; } catch (err) { return { content: [ { type: "text", text: `Failed to list memories: ${err}` }, ], isError: true, }; } }, }, { name: "memory_list" } ); // ========== memory_forget ========== api.registerTool( { name: "memory_forget", description: "Delete a memory. Use when the user asks to forget, remove, or delete something. Specify by: memoryId (exact ID), query (search and delete best match), or index (delete Nth memory from list).", parameters: Type.Object({ memoryId: Type.Optional(Type.String({ description: "Memory ID to delete (full 32 chars)" })), query: Type.Optional(Type.String({ description: "Search query to find memory to delete" })), index: Type.Optional(Type.Number({ description: "Delete Nth memory (1-based)" })), }), async execute(_id, params) { if (!cfg.apiKey) { return { content: [{ type: "text", text: CONFIG_REQUIRED_MESSAGE }], isError: true, }; } try { let targetId = params.memoryId; // Method 1: Direct memoryId if (targetId) { await client.deleteMemory(targetId); return { content: [ { type: "text", text: `Deleted memory: ${targetId}` }, ], details: { action: "forget", memoryId: targetId, }, }; } // Method 2: Search by query if (params.query) { const query = extractTextContent(params.query); if (!query) { return { content: [{ type: "text", text: "Query is empty after stripping metadata" }], isError: true, }; } const searchResult = await client.searchMemory( [{ role: "user", content: query }], 1, 0 ); if (!searchResult.memory_nodes || searchResult.memory_nodes.length === 0) { return { content: [ { type: "text", text: `No memories found matching "${query}"` }, ], isError: true, }; } targetId = searchResult.memory_nodes[0].memory_node_id; await client.deleteMemory(targetId); return { content: [ { type: "text", text: `Deleted memory: ${searchResult.memory_nodes[0].content}` }, ], details: { action: "forget", memoryId: targetId, matchedBy: "query", query, }, }; } // Method 3: Delete by index if (params.index && params.index > 0) { const pageSize = Math.min(params.index, 100); const listResult = await client.listMemory(1, pageSize); if ( !listResult.memory_nodes || listResult.memory_nodes.length < params.index ) { return { content: [ { type: "text", text: `Only ${listResult.memory_nodes?.length || 0} memories, cannot delete #${params.index}` }, ], isError: true, }; } targetId = listResult.memory_nodes[params.index - 1].memory_node_id; await client.deleteMemory(targetId); return { content: [ { type: "text", text: `Deleted memory #${params.index}: ${listResult.memory_nodes[params.index - 1].content}` }, ], details: { action: "forget", memoryId: targetId, matchedBy: "index", index: params.index, }, }; } // No parameters provided return { content: [ { type: "text", text: "Please provide memoryId, query, or index parameter" }, ], isError: true, }; } catch (err) { return { content: [ { type: "text", text: `Memory deletion failed: ${err}` }, ], isError: true, }; } }, }, { name: "memory_forget" } ); // ======================================================================== // Lifecycle Hooks // ======================================================================== // ========== autoRecall ========== if (cfg.autoRecall) { api.on("before_prompt_build", async (event) => { if (!cfg.apiKey) { api.logger.warn("modelstudio-memory: injecting config-required system prompt"); return { appendSystemContext: [ "", "CRITICAL INSTRUCTION — YOU MUST FOLLOW THIS BEFORE ANYTHING ELSE:", "", "The long-term memory plugin is installed but NOT configured. All memory features are COMPLETELY DISABLED.", "", "YOUR FIRST MESSAGE to the user MUST address this. Tell the user:", "", "1. 记忆插件已安装,但还未配置 API Key,记忆功能暂时无法使用。", "2. 配置方法:编辑 ~/.openclaw/openclaw.json,添加以下内容:", "", ' {', ' "plugins": {', ' "entries": {', ' "modelstudio-memory-for-openclaw": {', ' "config": {', ' "apiKey": "sk-你的API Key"', ' "userId": "您的用户名称,用于记忆隔离"', ' }', ' }', ' }', ' }', ' }', "", "您也可以告诉我apiKey和用户名,我来为您配置", "3. 配置完成后,重启 OpenClaw 网关即可启用记忆功能。", "", "DO NOT ignore this. DO NOT wait for the user to ask about memory. Proactively inform them NOW.", "", ].join("\n"), }; } const prompt = extractTextContent(event.prompt); if (!prompt || prompt.length < cfg.recallMinPromptLength) { return; } const messages = [{ role: "user" as const, content: prompt }]; try { const [searchResult, userProfile] = await Promise.all([ client.searchMemory(messages, cfg.topK, cfg.minScore), client.getUserProfile(), ]); const memories = searchResult.memory_nodes || []; const contextParts: string[] = []; // 用户画像上下文 if (userProfile) { const profileLines = userProfile.attributes .filter(attr => attr.value != null) .map(attr => `- ${attr.name}: ${attr.value}`); if (profileLines.length > 0) { contextParts.push( `\n${userProfile.schema_name}:\n${profileLines.join("\n")}\n` ); } } // 记忆上下文 if (memories.length > 0) { contextParts.push(formatMemoriesContext(memories)); } if (contextParts.length > 0) { api.logger.info( `modelstudio-memory: recalled ${memories.length} memories` + (userProfile ? ` + user profile` : '') ); return { appendSystemContext: contextParts.join("\n\n"), }; } } catch (err) { api.logger.warn(`modelstudio-memory: recall failed: ${err}`); } }); } // ========== autoCapture ========== if (cfg.autoCapture) { api.on("agent_end", async (event) => { if (!cfg.apiKey || !event.success || !event.messages) { return; } try { // Only capture the last turn: 1 user + N assistants (tool calls can produce multiple assistant msgs). // Find last user index, then take from that user to end. Avoids re-sending full history each round. let lastUserIdx = -1; for (let i = event.messages.length - 1; i >= 0; i--) { const role = (event.messages[i] as { role?: string })?.role; if (role === "user") { lastUserIdx = i; break; } } if (lastUserIdx < 0) return; const lastTurnMessages = event.messages.slice(lastUserIdx); const recentMessages = lastTurnMessages.length <= cfg.captureMaxMessages ? lastTurnMessages : [ lastTurnMessages[0], ...lastTurnMessages.slice(-(cfg.captureMaxMessages - 1)), ]; // Format messages (extractTextContent already strips metadata and injected context) const formattedMessages: Array<{ role: string; content: string }> = []; for (const msg of recentMessages) { if (!msg || typeof msg !== "object") continue; const role = (msg as { role?: string }).role; if (role !== "user" && role !== "assistant") continue; const content = extractTextContent((msg as { content?: unknown }).content); if (!content) continue; formattedMessages.push({ role, content }); } if (formattedMessages.length === 0) return; // Call add memory API (async, non-blocking) const result = await client.addAsyncMemory(formattedMessages); const addedCount = result.memory_nodes?.length || 0; if (addedCount > 0) { api.logger.info( `modelstudio-memory: captured ${addedCount} memories` ); } } catch (err) { api.logger.warn(`modelstudio-memory: capture failed: ${err}`); } }); } // ======================================================================== // CLI Commands // ======================================================================== api.registerCli( ({ program }) => { const modelstudio = program .command("modelstudio-memory") .description("Bailian memory plugin commands"); modelstudio .command("search") .description("Search memories in Bailian") .argument("", "Search query") .option("--limit ", "Max results", String(cfg.topK)) .action(async (query: string, opts: { limit: string }) => { if (!cfg.apiKey) { console.error(CONFIG_REQUIRED_MESSAGE); return; } try { const limit = Math.min(100, Math.max(1, parseInt(opts.limit, 10) || cfg.topK)); const cleanQuery = extractTextContent(query) || query.trim(); if (!cleanQuery) { console.error("Search query is empty."); return; } const messages = [{ role: "user" as const, content: cleanQuery }]; const result = await client.searchMemory(messages, limit, cfg.minScore); const memories = result.memory_nodes || []; if (memories.length === 0) { console.log("No memories found."); return; } const output = memories.map((m) => ({ id: m.memory_node_id, content: m.content, score: m.score, })); console.log(JSON.stringify(output, null, 2)); } catch (err) { console.error(`Search failed: ${err}`); } }); // stats command modelstudio .command("stats") .description("Show memory statistics") .action(async () => { if (!cfg.apiKey) { console.error(CONFIG_REQUIRED_MESSAGE); return; } try { const result = await client.listMemory(1, 1); console.log(`User: ${cfg.userId}`); console.log(`Total memories: ${result.total}`); console.log(`Auto-capture: ${cfg.autoCapture}`); console.log(`Auto-recall: ${cfg.autoRecall}`); console.log(`Top-K: ${cfg.topK}`); } catch (err) { console.error(`Stats failed: ${err}`); } }); // list command modelstudio .command("list") .description("List all memories") .option("--page ", "Page number", "1") .option("--size ", "Page size", "10") .action(async (opts: { page: string; size: string }) => { if (!cfg.apiKey) { console.error(CONFIG_REQUIRED_MESSAGE); return; } try { const page = Math.max(1, parseInt(opts.page, 10) || 1); const size = Math.min(100, Math.max(1, parseInt(opts.size, 10) || 10)); const result = await client.listMemory(page, size); const memories = result.memory_nodes || []; if (memories.length === 0) { console.log("No memories found."); return; } const output = memories.map((m) => ({ id: m.memory_node_id, content: m.content, created_at: m.created_at, updated_at: m.updated_at, })); const total = result.total ?? 0; const pageSize = result.page_size || 1; console.log( `Total: ${total}, Page: ${result.page_num ?? 1}/${Math.ceil(total / pageSize) || 1}` ); console.log(JSON.stringify(output, null, 2)); } catch (err) { console.error(`List failed: ${err}`); } }); }, { commands: ["modelstudio-memory"] } ); // ======================================================================== // Service // ======================================================================== api.registerService({ id: "modelstudio-memory-for-openclaw", start: () => { api.logger.info("modelstudio-memory: service started"); }, stop: () => { api.logger.info("modelstudio-memory: service stopped"); }, }); }, }; export default modelstudioMemoryPlugin;