@memtensor/memos-cloud-openclaw-plugin 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,844 +1,844 @@
1
- #!/usr/bin/env node
2
- import {
3
- addMessage,
4
- buildConfig,
5
- extractResultData,
6
- extractText,
7
- formatRecallHookResult,
8
- isAgentAllowed,
9
- resolveAgentConfig,
10
- searchMemory,
11
- stripOpenClawInjectedPrefix,
12
- } from "./lib/memos-cloud-api.js";
13
- import { reportRumEvent } from "./lib/arms-reporter.js";
14
- import { startUpdateChecker } from "./lib/check-update.js";
15
- import {
16
- closeConfigUiService,
17
- compareVersionStrings,
18
- detectHostVersion,
19
- ensureConfigUiService,
20
- ensurePluginHookPolicy,
21
- isGatewayRuntimeStartup,
22
- waitForGatewayReady,
23
- } from "./lib/config-ui-server.js";
24
- let lastCaptureTime = 0;
25
- const conversationCounters = new Map();
26
- const API_KEY_HELP_URL = "https://memos-dashboard.openmem.net/cn/apikeys/";
27
- const ENV_FILE_SEARCH_HINTS = ["~/.openclaw/.env", "~/.moltbot/.env", "~/.clawdbot/.env"];
28
- const MEMOS_SOURCE = (() => {
29
- const platform = process.platform;
30
- if (platform === "win32") return "openclaw_win";
31
- if (platform === "darwin") return "openclaw_mac";
32
- if (platform === "linux") return "openclaw_linux";
33
- return "openclaw";
34
- })();
35
-
36
- // Heartbeat prompts are always injected at the very beginning of the user
37
- // content by the host (OpenClaw). Anchoring at start prevents false positives
38
- // when a legitimate user message happens to mention these phrases.
39
- const HEARTBEAT_PROMPT_PATTERN =
40
- /^\s*(?:Read HEARTBEAT\.md if it exists\b|\[OpenClaw heartbeat poll\])/i;
41
- const SYSTEM_COMMAND_PATTERN = /^\/(?:new|reset|clear|stop|status|help|dock_|undock)\b/i;
42
- const INTERNAL_SYSTEM_PROMPT_PATTERNS = [
43
- /^A new session was started via \/new or \/reset\./i,
44
- /^Based on this conversation, generate a short 1-2 word filename slug\b[\s\S]*\bReply with ONLY the slug\b/i,
45
- ];
46
-
47
- function isHeartbeatPrompt(text) {
48
- return typeof text === "string" && HEARTBEAT_PROMPT_PATTERN.test(text);
49
- }
50
-
51
- export function isSystemCommandPrompt(text) {
52
- if (typeof text !== "string") return false;
53
- const prompt = text.trimStart();
54
- return SYSTEM_COMMAND_PATTERN.test(prompt) || INTERNAL_SYSTEM_PROMPT_PATTERNS.some((pattern) => pattern.test(prompt));
55
- }
56
-
57
- function warnMissingApiKey(log, context) {
58
- const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)";
59
- const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`;
60
- log.warn?.(
61
- [
62
- header,
63
- "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc",
64
- "source ~/.zshrc",
65
- "or",
66
- "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc",
67
- "source ~/.bashrc",
68
- "or",
69
- "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")",
70
- `Get API key: ${API_KEY_HELP_URL}`,
71
- ].join("\n"),
72
- );
73
- }
74
-
75
- function getCounterSuffix(sessionKey) {
76
- if (!sessionKey) return "";
77
- const current = conversationCounters.get(sessionKey) ?? 0;
78
- return current > 0 ? `#${current}` : "";
79
- }
80
-
81
- function bumpConversationCounter(sessionKey) {
82
- if (!sessionKey) return;
83
- const current = conversationCounters.get(sessionKey) ?? 0;
84
- conversationCounters.set(sessionKey, current + 1);
85
- }
86
-
87
- function getEffectiveAgentId(cfg, ctx) {
88
- if (!cfg.multiAgentMode) {
89
- return cfg.agentId;
90
- }
91
- const agentId = ctx?.agentId || cfg.agentId;
92
- return agentId === "main" ? undefined : agentId;
93
- }
94
-
95
- export function extractDirectSessionUserId(sessionKey) {
96
- if (!sessionKey || typeof sessionKey !== "string") return "";
97
- const parts = sessionKey.split(":");
98
- const directIndex = parts.lastIndexOf("direct");
99
- if (directIndex === -1) return "";
100
- return parts[directIndex + 1] || "";
101
- }
102
-
103
- export function resolveMemosUserId(cfg, ctx) {
104
- const fallback = cfg?.userId || "openclaw-user";
105
- if (!cfg?.useDirectSessionUserId) return fallback;
106
- const directUserId = extractDirectSessionUserId(ctx?.sessionKey);
107
- return directUserId || fallback;
108
- }
109
-
110
- function resolveConversationId(cfg, ctx) {
111
- if (cfg.conversationId) return cfg.conversationId;
112
- // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId).
113
- const agentId = getEffectiveAgentId(cfg, ctx);
114
- const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : "");
115
- const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : "";
116
- const prefix = cfg.conversationIdPrefix || "";
117
- const suffix = cfg.conversationIdSuffix || "";
118
- if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`;
119
- return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`;
120
- }
121
-
122
- export function buildSearchPayload(cfg, prompt, ctx) {
123
- const cleanPrompt = stripOpenClawInjectedPrefix(prompt);
124
- const queryRaw = `${cfg.queryPrefix || ""}${cleanPrompt}`;
125
- const query =
126
- Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0
127
- ? queryRaw.slice(0, cfg.maxQueryChars)
128
- : queryRaw;
129
-
130
- const payload = {
131
- user_id: resolveMemosUserId(cfg, ctx),
132
- query,
133
- source: MEMOS_SOURCE,
134
- };
135
-
136
- if (!cfg.recallGlobal) {
137
- const conversationId = resolveConversationId(cfg, ctx);
138
- if (conversationId) payload.conversation_id = conversationId;
139
- }
140
-
141
- let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null;
142
- const agentId = getEffectiveAgentId(cfg, ctx);
143
-
144
- // Check if the filter is already in the categorized format (filter1)
145
- const isCategorized = filterObj && (filterObj.user !== undefined || filterObj.knowledgebase !== undefined || filterObj.public !== undefined);
146
- let userFilter = isCategorized ? (filterObj.user || null) : filterObj;
147
-
148
- if (agentId) {
149
- if (userFilter && Object.keys(userFilter).length > 0) {
150
- if (Array.isArray(userFilter.and)) {
151
- userFilter.and.push({ agent_id: agentId });
152
- } else {
153
- userFilter = { and: [userFilter, { agent_id: agentId }] };
154
- }
155
- } else {
156
- userFilter = { and: [{ agent_id: agentId }] };
157
- }
158
- }
159
-
160
- if (isCategorized) {
161
- if (userFilter && Object.keys(userFilter).length > 0) filterObj.user = userFilter;
162
- if (Object.keys(filterObj).length > 0) payload.filter = filterObj;
163
- } else if (userFilter && Object.keys(userFilter).length > 0) {
164
- // If not categorized, wrap it in 'user' so knowledgebase is not filtered
165
- payload.filter = { user: userFilter };
166
- }
167
-
168
- if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds;
169
-
170
- payload.memory_limit_number = cfg.memoryLimitNumber;
171
- payload.include_preference = cfg.includePreference;
172
- payload.preference_limit_number = cfg.preferenceLimitNumber;
173
- payload.include_tool_memory = cfg.includeToolMemory;
174
- payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber;
175
- payload.relativity = cfg.relativity;
176
-
177
- return payload;
178
- }
179
-
180
- export function buildAddMessagePayload(cfg, messages, ctx) {
181
- const payload = {
182
- user_id: resolveMemosUserId(cfg, ctx),
183
- conversation_id: resolveConversationId(cfg, ctx),
184
- messages,
185
- source: MEMOS_SOURCE,
186
- };
187
-
188
- const agentId = getEffectiveAgentId(cfg, ctx);
189
- if (agentId) payload.agent_id = agentId;
190
- if (cfg.appId) payload.app_id = cfg.appId;
191
- if (cfg.tags?.length) payload.tags = cfg.tags;
192
-
193
- const info = {
194
- source: MEMOS_SOURCE,
195
- sessionKey: ctx?.sessionKey,
196
- agentId: ctx?.agentId,
197
- ...(cfg.info || {}),
198
- };
199
- if (Object.keys(info).length > 0) payload.info = info;
200
-
201
- payload.allow_public = cfg.allowPublic;
202
- if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds;
203
- payload.async_mode = cfg.asyncMode;
204
-
205
- return payload;
206
- }
207
-
208
- function convertAssistantMessage(msg, cfg) {
209
- const contentArr = Array.isArray(msg.content)
210
- ? msg.content
211
- : msg.content
212
- ? [{ type: "text", text: String(msg.content) }]
213
- : [];
214
-
215
- const textContent = contentArr
216
- .filter((c) => c?.type === "text")
217
- .map((c) => c.text || "")
218
- .filter(Boolean)
219
- .join("\n");
220
-
221
- const toolCallItems = contentArr.filter((c) => c?.type === "toolCall");
222
-
223
- const result = { role: "assistant" };
224
-
225
- if (textContent) {
226
- result.content = truncate(textContent, cfg.maxMessageChars);
227
- }
228
-
229
- if (cfg.includeToolMemory && toolCallItems.length > 0) {
230
- result.tool_calls = toolCallItems.map((tc) => ({
231
- id: tc.id,
232
- type: "function",
233
- function: {
234
- name: tc.name,
235
- arguments: typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {}),
236
- },
237
- }));
238
- }
239
-
240
- if (!result.content && !result.tool_calls) return null;
241
- return result;
242
- }
243
-
244
- function safeStringify(value) {
245
- try {
246
- return JSON.stringify(value);
247
- } catch {
248
- return "";
249
- }
250
- }
251
-
252
- // 把单个附件值(URL / data URI / 裸 base64)统一描述成可读 text:
253
- // - http(s):// / 其它协议 URL:[<kind>: <url>]
254
- // - data:<mediaType>;base64,...:[<kind> (<mediaType> base64, ~<size> chars)]
255
- // - 其它(视为裸 base64):[<kind> (base64, ~<size> chars)]
256
- function describeAttachment(kind, value) {
257
- const dataMatch = /^data:([^;,]+)/i.exec(value);
258
- if (dataMatch) {
259
- return `[${kind} (${dataMatch[1] || kind} base64, ~${value.length} chars)]`;
260
- }
261
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
262
- return `[${kind}: ${value}]`;
263
- }
264
- return `[${kind} (base64, ~${value.length} chars)]`;
265
- }
266
-
267
- // MemOS 是文本记忆服务,召回路径上图片/文件 block 几乎只有文本价值。
268
- // 这里把所有 block 一律归一成 [{type:"text", text}],但**保留 URL 文字本身**:
269
- // - text block:透传文本(按 cfg.maxMessageChars 截头)
270
- // - URL 形态:输出 "[image: <url>]" / "[file: <url>]",URL 作为可检索文字保留
271
- // - data URI / base64 形态:输出 "[image (<media_type> base64, ~<size> chars)]" 元数据描述,永不 inline base64
272
- // - 未识别 type:含 url 字段则 "[<type>: <url>]",否则 JSON.stringify 兜底
273
- function normalizeToolResultContent(content, cfg) {
274
- const blocks = [];
275
-
276
- const pushText = (raw) => {
277
- const text = truncate(String(raw ?? ""), cfg.maxMessageChars);
278
- if (text) blocks.push({ type: "text", text });
279
- };
280
-
281
- // 解析所有协议下的 image 类 block,提取出统一的"附件值"再交给 describeAttachment 描述。
282
- // 覆盖:
283
- // {type:"image_url", image_url:{url}} / {image_url:"<str>"} / 顶层 url (OpenAI 风格)
284
- // {type:"image", data, media_type} / {type:"image", source:{data, media_type}} (Claude 风格)
285
- // {type:"image", url} (少见)
286
- const tryPushImageBlock = (block) => {
287
- const claudeData =
288
- (block.source && typeof block.source === "object" && block.source.data) || block.data || "";
289
- if (claudeData) {
290
- const mediaType =
291
- (block.source && typeof block.source === "object" && block.source.media_type) ||
292
- block.media_type ||
293
- block.mimeType ||
294
- "image";
295
- pushText(describeAttachment("image", `data:${mediaType};base64,${String(claudeData)}`));
296
- return true;
297
- }
298
- const url =
299
- (block.image_url && typeof block.image_url === "object" && block.image_url.url) ||
300
- (typeof block.image_url === "string" ? block.image_url : "") ||
301
- block.url ||
302
- "";
303
- if (!url) return false;
304
- pushText(describeAttachment("image", String(url)));
305
- return true;
306
- };
307
-
308
- // MemOS schema 标准 file block:{type:"file", file:{file_data}},兼容顶层 file_data。
309
- const tryPushFileBlock = (block) => {
310
- const fileData =
311
- (block.file && typeof block.file === "object" && block.file.file_data) ||
312
- block.file_data ||
313
- "";
314
- if (!fileData) return false;
315
- pushText(describeAttachment("file", String(fileData)));
316
- return true;
317
- };
318
-
319
- const tryPushTypedBlock = (block) => {
320
- if (!block || typeof block !== "object") return false;
321
- if (block.type === "text") {
322
- pushText(block.text);
323
- return true;
324
- }
325
- if (block.type === "image_url" || block.type === "image") return tryPushImageBlock(block);
326
- if (block.type === "file") return tryPushFileBlock(block);
327
- return false;
328
- };
329
-
330
- // 未识别 type:有 url 字段则给可读占位,否则整体 stringify。
331
- const fallbackSerialize = (block) => {
332
- if (
333
- block &&
334
- typeof block === "object" &&
335
- typeof block.type === "string" &&
336
- typeof block.url === "string" &&
337
- block.url
338
- ) {
339
- pushText(`[${block.type}: ${block.url}]`);
340
- return;
341
- }
342
- const serialized = safeStringify(block);
343
- if (serialized) pushText(serialized);
344
- };
345
-
346
- if (content == null || content === "") return blocks;
347
-
348
- if (typeof content === "string") {
349
- pushText(content);
350
- return blocks;
351
- }
352
-
353
- if (Array.isArray(content)) {
354
- for (const block of content) {
355
- if (block == null) continue;
356
- if (typeof block === "string") {
357
- pushText(block);
358
- continue;
359
- }
360
- if (typeof block !== "object") continue;
361
- if (tryPushTypedBlock(block)) continue;
362
- fallbackSerialize(block);
363
- }
364
- return blocks;
365
- }
366
-
367
- if (typeof content === "object") {
368
- if (!tryPushTypedBlock(content)) {
369
- fallbackSerialize(content);
370
- }
371
- return blocks;
372
- }
373
-
374
- return blocks;
375
- }
376
-
377
- function convertToolResultMessage(msg, cfg) {
378
- const toolCallId = msg.toolCallId || msg.tool_call_id;
379
- if (!toolCallId) return null;
380
- const blocks = normalizeToolResultContent(msg.content, cfg);
381
- if (blocks.length === 0) return null;
382
- return {
383
- role: "tool",
384
- tool_call_id: toolCallId,
385
- content: blocks,
386
- };
387
- }
388
-
389
- // 把 OpenClaw 的单条原始消息转成 MemOS /add/message 接受的形态。
390
- // 三类 role 分发:user / assistant / toolResult,其它 role(system/...)直接丢弃返 null。
391
- function convertSessionMessage(msg, cfg) {
392
- if (!msg || !msg.role) return null;
393
- if (msg.role === "user") {
394
- const content = stripOpenClawInjectedPrefix(extractText(msg.content));
395
- if (!content) return null;
396
- return { role: "user", content: truncate(content, cfg.maxMessageChars) };
397
- }
398
- if (msg.role === "assistant" && cfg.includeAssistant) {
399
- return convertAssistantMessage(msg, cfg);
400
- }
401
- if (msg.role === "toolResult" && cfg.includeToolMemory) {
402
- return convertToolResultMessage(msg, cfg);
403
- }
404
- return null;
405
- }
406
-
407
- function pickLastTurnMessages(messages, cfg) {
408
- let lastUserIndex = -1;
409
- for (let i = messages.length - 1; i >= 0; i--) {
410
- if (messages[i]?.role === "user") {
411
- lastUserIndex = i;
412
- break;
413
- }
414
- }
415
- if (lastUserIndex < 0) return [];
416
- return messages
417
- .slice(lastUserIndex)
418
- .map((m) => convertSessionMessage(m, cfg))
419
- .filter(Boolean);
420
- }
421
-
422
- function pickFullSessionMessages(messages, cfg) {
423
- return messages.map((m) => convertSessionMessage(m, cfg)).filter(Boolean);
424
- }
425
-
426
- function truncate(text, maxLen) {
427
- if (!text) return "";
428
- if (!maxLen) return text;
429
- return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
430
- }
431
-
432
- function sleep(ms) {
433
- return new Promise((resolve) => setTimeout(resolve, ms));
434
- }
435
-
436
- function parseModelJson(text) {
437
- if (!text || typeof text !== "string") return null;
438
- const trimmed = text.trim();
439
- if (!trimmed) return null;
440
- try {
441
- return JSON.parse(trimmed);
442
- } catch {
443
- // Some models wrap JSON in markdown code fences.
444
- }
445
- const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
446
- if (fenceMatch?.[1]) {
447
- try {
448
- return JSON.parse(fenceMatch[1].trim());
449
- } catch {
450
- return null;
451
- }
452
- }
453
- const first = trimmed.indexOf("{");
454
- const last = trimmed.lastIndexOf("}");
455
- if (first >= 0 && last > first) {
456
- try {
457
- return JSON.parse(trimmed.slice(first, last + 1));
458
- } catch {
459
- return null;
460
- }
461
- }
462
- return null;
463
- }
464
-
465
- function normalizeIndexList(value, maxLen) {
466
- if (!Array.isArray(value)) return [];
467
- const seen = new Set();
468
- const out = [];
469
- for (const v of value) {
470
- if (!Number.isInteger(v)) continue;
471
- if (v < 0 || v >= maxLen) continue;
472
- if (seen.has(v)) continue;
473
- seen.add(v);
474
- out.push(v);
475
- }
476
- return out;
477
- }
478
-
479
- function buildRecallCandidates(data, cfg) {
480
- const limit = Number.isFinite(cfg.recallFilterCandidateLimit) ? Math.max(0, cfg.recallFilterCandidateLimit) : 30;
481
- const maxChars = Number.isFinite(cfg.recallFilterMaxItemChars) ? Math.max(80, cfg.recallFilterMaxItemChars) : 500;
482
- const memoryList = Array.isArray(data?.memory_detail_list) ? data.memory_detail_list : [];
483
- const preferenceList = Array.isArray(data?.preference_detail_list) ? data.preference_detail_list : [];
484
- const toolList = Array.isArray(data?.tool_memory_detail_list) ? data.tool_memory_detail_list : [];
485
-
486
- const memoryCandidates = memoryList.slice(0, limit).map((item, idx) => ({
487
- idx,
488
- text: truncate(item?.memory_value || item?.memory_key || "", maxChars),
489
- relativity: item?.relativity,
490
- }));
491
- const preferenceCandidates = preferenceList.slice(0, limit).map((item, idx) => ({
492
- idx,
493
- text: truncate(item?.preference || "", maxChars),
494
- relativity: item?.relativity,
495
- preference_type: item?.preference_type || "",
496
- }));
497
- const toolCandidates = toolList.slice(0, limit).map((item, idx) => ({
498
- idx,
499
- text: truncate(item?.tool_value || "", maxChars),
500
- relativity: item?.relativity,
501
- }));
502
-
503
- return {
504
- memoryList,
505
- preferenceList,
506
- toolList,
507
- candidatePayload: {
508
- memory: memoryCandidates,
509
- preference: preferenceCandidates,
510
- tool_memory: toolCandidates,
511
- },
512
- };
513
- }
514
-
515
- function applyRecallDecision(data, decision, lists) {
516
- const keep = decision?.keep || {};
517
- const memoryIdx = normalizeIndexList(keep.memory, lists.memoryList.length);
518
- const preferenceIdx = normalizeIndexList(keep.preference, lists.preferenceList.length);
519
- const toolIdx = normalizeIndexList(keep.tool_memory, lists.toolList.length);
520
-
521
- return {
522
- ...data,
523
- memory_detail_list: memoryIdx.map((idx) => lists.memoryList[idx]),
524
- preference_detail_list: preferenceIdx.map((idx) => lists.preferenceList[idx]),
525
- tool_memory_detail_list: toolIdx.map((idx) => lists.toolList[idx]),
526
- };
527
- }
528
-
529
- async function callRecallFilterModel(cfg, userPrompt, candidatePayload) {
530
- const headers = {
531
- "Content-Type": "application/json",
532
- };
533
- if (cfg.recallFilterApiKey) {
534
- headers.Authorization = `Bearer ${cfg.recallFilterApiKey}`;
535
- }
536
-
537
- const modelInput = {
538
- user_query: userPrompt,
539
- candidate_memories: candidatePayload,
540
- output_schema: {
541
- keep: {
542
- memory: ["number index"],
543
- preference: ["number index"],
544
- tool_memory: ["number index"],
545
- },
546
- reason: "optional short string",
547
- },
548
- };
549
-
550
- const body = {
551
- model: cfg.recallFilterModel,
552
- temperature: 0,
553
- messages: [
554
- {
555
- role: "system",
556
- content:
557
- "You are a strict memory relevance judge. Return JSON only. Keep only items directly useful for answering current user query. If unsure, do not keep.",
558
- },
559
- {
560
- role: "user",
561
- content: JSON.stringify(modelInput),
562
- },
563
- ],
564
- };
565
-
566
- let lastError;
567
- const retries = Number.isFinite(cfg.recallFilterRetries) ? Math.max(0, cfg.recallFilterRetries) : 1;
568
- const timeoutMs = Number.isFinite(cfg.recallFilterTimeoutMs) ? Math.max(1000, cfg.recallFilterTimeoutMs) : 30000;
569
-
570
- for (let attempt = 0; attempt <= retries; attempt += 1) {
571
- let timeoutId;
572
- try {
573
- const controller = new AbortController();
574
- timeoutId = setTimeout(() => controller.abort(), timeoutMs);
575
- const res = await fetch(`${cfg.recallFilterBaseUrl}/chat/completions`, {
576
- method: "POST",
577
- headers,
578
- body: JSON.stringify(body),
579
- signal: controller.signal,
580
- });
581
- if (!res.ok) {
582
- throw new Error(`HTTP ${res.status}`);
583
- }
584
- const json = await res.json();
585
- const text = json?.choices?.[0]?.message?.content || "";
586
- const parsed = parseModelJson(text);
587
- if (!parsed || typeof parsed !== "object") {
588
- throw new Error("invalid JSON output from recall filter model");
589
- }
590
- return parsed;
591
- } catch (err) {
592
- const isAbort = err?.name === "AbortError" || /aborted/i.test(String(err?.message ?? err));
593
- lastError = isAbort
594
- ? new Error(
595
- `timed out after ${timeoutMs}ms (raise recallFilterTimeoutMs; local LLMs often need 30s+ on cold start)`,
596
- )
597
- : err;
598
- if (attempt < retries) {
599
- await sleep(120 * (attempt + 1));
600
- }
601
- } finally {
602
- if (timeoutId !== undefined) clearTimeout(timeoutId);
603
- }
604
- }
605
- throw lastError;
606
- }
607
-
608
- async function maybeFilterRecallData(cfg, data, userPrompt, log, ctx) {
609
- if (!cfg.recallFilterEnabled) return data;
610
- if (!cfg.recallFilterBaseUrl || !cfg.recallFilterModel) {
611
- log.warn?.("[memos-cloud] recall filter enabled but missing recallFilterBaseUrl/recallFilterModel; skip filter");
612
- return data;
613
- }
614
- const lists = buildRecallCandidates(data, cfg);
615
- const hasCandidates =
616
- lists.candidatePayload.memory.length > 0 ||
617
- lists.candidatePayload.preference.length > 0 ||
618
- lists.candidatePayload.tool_memory.length > 0;
619
- if (!hasCandidates) return data;
620
-
621
- try {
622
- reportRumEvent("recall_filter", { recall_filter_enable: cfg.recallFilterEnabled }, cfg, ctx, log);
623
- const decision = await callRecallFilterModel(cfg, userPrompt, lists.candidatePayload);
624
- const filtered = applyRecallDecision(data, decision, lists);
625
- log.info?.(
626
- `[memos-cloud] recall filter applied: memory ${lists.memoryList.length}->${filtered.memory_detail_list?.length ?? 0}, ` +
627
- `preference ${lists.preferenceList.length}->${filtered.preference_detail_list?.length ?? 0}, ` +
628
- `tool_memory ${lists.toolList.length}->${filtered.tool_memory_detail_list?.length ?? 0}`,
629
- );
630
- return filtered;
631
- } catch (err) {
632
- log.warn?.(`[memos-cloud] recall filter failed: ${String(err)}`);
633
- return cfg.recallFilterFailOpen ? data : { ...data, memory_detail_list: [], preference_detail_list: [], tool_memory_detail_list: [] };
634
- }
635
- }
636
-
637
- export default {
638
- id: "memos-cloud-openclaw-plugin",
639
- name: "MemOS Cloud OpenClaw Plugin",
640
- description: "MemOS Cloud recall + add memory via lifecycle hooks",
641
- kind: "lifecycle",
642
-
643
- register(api) {
644
- const cfg = buildConfig(api.pluginConfig);
645
- const log = api.logger ?? console;
646
- let configUiStartupCancelled = false;
647
-
648
- // Start 12-hour background update interval
649
- startUpdateChecker(log);
650
-
651
- // Detect the host CLI version once so every hook registration branch can reference it.
652
- const hostVersion = detectHostVersion();
653
-
654
- // Side effects below are only meaningful when the host CLI was actually
655
- // launched to run the gateway (`openclaw gateway run|start|restart`).
656
- // Other entry points (e.g. `plugins install`, `security audit`) also
657
- // load this plugin to inspect/register it, but:
658
- // - `ensurePluginHookPolicy` writes to `openclaw.json` and would race
659
- // against the install command's own commit (ConfigMutationConflictError).
660
- // - `waitForGatewayReady` would keep the short-lived event loop alive
661
- // for 45s probing a gateway that will never come up, then emit a
662
- // misleading "probe timed out" warning before the process exits.
663
- // Gate them all in one place so the policy is explicit and discoverable.
664
- if (isGatewayRuntimeStartup()) {
665
- // `allowConversationAccess` hook policy was introduced in 2026.4.23;
666
- // older hosts do not understand the field and don't need it patched in.
667
- const HOOK_POLICY_MIN_VERSION = "2026.4.23";
668
- const needsHookPolicy =
669
- hostVersion === null ||
670
- compareVersionStrings(hostVersion, HOOK_POLICY_MIN_VERSION) >= 0;
671
-
672
- void (async () => {
673
- const ready = await waitForGatewayReady(api.config, log);
674
- if (!ready || configUiStartupCancelled) return;
675
-
676
- // Patch hook policy AFTER gateway is fully ready. Writing the config
677
- // file at this point triggers the gateway's built-in config-change
678
- // watcher which will auto-restart, making agent_end effective without
679
- // requiring the user to manually restart.
680
- if (needsHookPolicy) {
681
- try {
682
- const policyResult = ensurePluginHookPolicy(api.config, log);
683
- if (policyResult?.error) {
684
- log.warn?.(
685
- `[memos-cloud] hook policy check skipped due to error: ${String(policyResult.error?.message ?? policyResult.error)}`,
686
- );
687
- }
688
- } catch (error) {
689
- log.warn?.(
690
- `[memos-cloud] failed to ensure plugin hook policy: ${String(error?.message ?? error)}`,
691
- );
692
- }
693
- }
694
-
695
- await ensureConfigUiService(log);
696
- })().catch((error) => {
697
- log.warn?.(`[memos-cloud] config UI failed to start: ${String(error)}`);
698
- });
699
- }
700
-
701
- if (!cfg.envFileStatus?.found) {
702
- const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", ");
703
- log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`);
704
- }
705
-
706
- if (cfg.multiAgentMode && cfg.allowedAgents?.length > 0) {
707
- log.info?.(`[memos-cloud] Multi-agent mode enabled. Allowed agents: [${cfg.allowedAgents.join(", ")}]`);
708
- }
709
-
710
- const overrideAgentIds = Object.keys(cfg._agentOverrides || {});
711
- if (overrideAgentIds.length > 0) {
712
- log.info?.(`[memos-cloud] Per-agent overrides configured for: [${overrideAgentIds.join(", ")}]`);
713
- }
714
-
715
- if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) {
716
- if (api.config?.hooks?.internal?.enabled !== true) {
717
- log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true");
718
- }
719
- api.registerHook(
720
- ["command:new"],
721
- (event) => {
722
- if (event?.type === "command" && event?.action === "new") {
723
- bumpConversationCounter(event.sessionKey);
724
- }
725
- },
726
- {
727
- name: "memos-cloud-conversation-new",
728
- description: "Increment MemOS conversation suffix on /new",
729
- },
730
- );
731
- }
732
-
733
- const runRecall = async (event, ctx) => {
734
- // Skip system events: heartbeat, /new, /reset, and other commands
735
- const prompt = event?.prompt || "";
736
- const isHeartbeat = isHeartbeatPrompt(prompt);
737
- const isSystemCommand = isSystemCommandPrompt(prompt);
738
-
739
- if (isHeartbeat || isSystemCommand) {
740
- log.info?.(`[memos-cloud] recall skipped: system event detected (heartbeat=${isHeartbeat}, command=${isSystemCommand}, prompt="${prompt.substring(0, 50)}...")`);
741
- return;
742
- }
743
-
744
- if (!isAgentAllowed(cfg, ctx)) {
745
- log.info?.(`[memos-cloud] recall skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
746
- return;
747
- }
748
- const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
749
- if (!agentCfg.recallEnabled) return;
750
- const userPrompt = stripOpenClawInjectedPrefix(event?.prompt || "");
751
- if (!userPrompt || userPrompt.length < 3) return;
752
- if (!agentCfg.apiKey) {
753
- warnMissingApiKey(log, "recall");
754
- return;
755
- }
756
-
757
- try {
758
- const payload = buildSearchPayload(agentCfg, userPrompt, ctx);
759
- reportRumEvent('search_memory', payload, agentCfg, ctx, log);
760
- const result = await searchMemory(agentCfg, payload);
761
- const resultData = extractResultData(result);
762
- if (!resultData) return;
763
- const filteredData = await maybeFilterRecallData(agentCfg, resultData, userPrompt, log, ctx);
764
- const hookResult = formatRecallHookResult({ data: filteredData }, {
765
- wrapTagBlocks: true,
766
- relativity: payload.relativity,
767
- maxItemChars: agentCfg.maxItemChars,
768
- });
769
- if (!hookResult.appendSystemContext && !hookResult.prependContext) return;
770
-
771
- return hookResult;
772
- } catch (err) {
773
- log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
774
- }
775
- };
776
-
777
- // Recall mutates prompt context only, so the phase-specific replacement for
778
- // legacy before_agent_start is before_prompt_build. Do not register both on
779
- // new hosts, otherwise the same memory block can be injected twice.
780
- const PROMPT_BUILD_HOOK_MIN_VERSION = "2026.5.7";
781
- const usesBeforePromptBuild =
782
- hostVersion !== null &&
783
- compareVersionStrings(hostVersion, PROMPT_BUILD_HOOK_MIN_VERSION) >= 0;
784
-
785
- if (usesBeforePromptBuild) {
786
- api.on("before_prompt_build", runRecall);
787
- } else {
788
- api.on("before_agent_start", runRecall);
789
- }
790
-
791
- api.on("agent_end", async (event, ctx) => {
792
- // Skip system events: heartbeat and commands
793
- // Check the last user message to determine if this was a system event
794
- const messages = event?.messages || [];
795
- const lastUserMsg = messages.slice().reverse().find(m => m?.role === "user");
796
- const lastUserContent = extractText(lastUserMsg?.content || "");
797
-
798
- const isHeartbeat = isHeartbeatPrompt(lastUserContent);
799
- const isSystemCommand = isSystemCommandPrompt(lastUserContent);
800
-
801
- if (isHeartbeat || isSystemCommand) {
802
- log.info?.(`[memos-cloud] add skipped: system event detected (heartbeat=${isHeartbeat}, command=${isSystemCommand}, content="${lastUserContent.substring(0, 50)}...")`);
803
- return;
804
- }
805
-
806
- if (!isAgentAllowed(cfg, ctx)) {
807
- log.info?.(`[memos-cloud] add skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
808
- return;
809
- }
810
- const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
811
- if (!agentCfg.addEnabled) return;
812
- if (!event?.success || !event?.messages?.length) return;
813
- if (!agentCfg.apiKey) {
814
- warnMissingApiKey(log, "add");
815
- return;
816
- }
817
-
818
- const now = Date.now();
819
- if (agentCfg.throttleMs && now - lastCaptureTime < agentCfg.throttleMs) {
820
- return;
821
- }
822
- lastCaptureTime = now;
823
-
824
- try {
825
- const messages =
826
- agentCfg.captureStrategy === "full_session"
827
- ? pickFullSessionMessages(event.messages, agentCfg)
828
- : pickLastTurnMessages(event.messages, agentCfg);
829
-
830
- if (!messages.length) return;
831
-
832
- const payload = buildAddMessagePayload(agentCfg, messages, ctx);
833
- await addMessage(agentCfg, payload);
834
- } catch (err) {
835
- log.warn?.(`[memos-cloud] add failed: ${String(err)}`);
836
- }
837
- });
838
-
839
- return () => {
840
- configUiStartupCancelled = true;
841
- void closeConfigUiService();
842
- };
843
- },
844
- };
1
+ #!/usr/bin/env node
2
+ import {
3
+ addMessage,
4
+ buildConfig,
5
+ extractResultData,
6
+ extractText,
7
+ formatRecallHookResult,
8
+ isAgentAllowed,
9
+ resolveAgentConfig,
10
+ searchMemory,
11
+ stripOpenClawInjectedPrefix,
12
+ } from "./lib/memos-cloud-api.js";
13
+ import { reportRumEvent } from "./lib/arms-reporter.js";
14
+ import { startUpdateChecker } from "./lib/check-update.js";
15
+ import {
16
+ closeConfigUiService,
17
+ compareVersionStrings,
18
+ detectHostVersion,
19
+ ensureConfigUiService,
20
+ ensurePluginHookPolicy,
21
+ isGatewayRuntimeStartup,
22
+ waitForGatewayReady,
23
+ } from "./lib/config-ui-server.js";
24
+ let lastCaptureTime = 0;
25
+ const conversationCounters = new Map();
26
+ const API_KEY_HELP_URL = "https://memos-dashboard.openmem.net/cn/apikeys/";
27
+ const ENV_FILE_SEARCH_HINTS = ["~/.openclaw/.env", "~/.moltbot/.env", "~/.clawdbot/.env"];
28
+ const MEMOS_SOURCE = (() => {
29
+ const platform = process.platform;
30
+ if (platform === "win32") return "openclaw_win";
31
+ if (platform === "darwin") return "openclaw_mac";
32
+ if (platform === "linux") return "openclaw_linux";
33
+ return "openclaw";
34
+ })();
35
+
36
+ // Heartbeat prompts are always injected at the very beginning of the user
37
+ // content by the host (OpenClaw). Anchoring at start prevents false positives
38
+ // when a legitimate user message happens to mention these phrases.
39
+ const HEARTBEAT_PROMPT_PATTERN =
40
+ /^\s*(?:Read HEARTBEAT\.md if it exists\b|\[OpenClaw heartbeat poll\])/i;
41
+ const SYSTEM_COMMAND_PATTERN = /^\/(?:new|reset|clear|stop|status|help|dock_|undock)\b/i;
42
+ const INTERNAL_SYSTEM_PROMPT_PATTERNS = [
43
+ /^A new session was started via \/new or \/reset\./i,
44
+ /^Based on this conversation, generate a short 1-2 word filename slug\b[\s\S]*\bReply with ONLY the slug\b/i,
45
+ ];
46
+
47
+ function isHeartbeatPrompt(text) {
48
+ return typeof text === "string" && HEARTBEAT_PROMPT_PATTERN.test(text);
49
+ }
50
+
51
+ export function isSystemCommandPrompt(text) {
52
+ if (typeof text !== "string") return false;
53
+ const prompt = text.trimStart();
54
+ return SYSTEM_COMMAND_PATTERN.test(prompt) || INTERNAL_SYSTEM_PROMPT_PATTERNS.some((pattern) => pattern.test(prompt));
55
+ }
56
+
57
+ function warnMissingApiKey(log, context) {
58
+ const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)";
59
+ const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`;
60
+ log.warn?.(
61
+ [
62
+ header,
63
+ "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc",
64
+ "source ~/.zshrc",
65
+ "or",
66
+ "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc",
67
+ "source ~/.bashrc",
68
+ "or",
69
+ "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")",
70
+ `Get API key: ${API_KEY_HELP_URL}`,
71
+ ].join("\n"),
72
+ );
73
+ }
74
+
75
+ function getCounterSuffix(sessionKey) {
76
+ if (!sessionKey) return "";
77
+ const current = conversationCounters.get(sessionKey) ?? 0;
78
+ return current > 0 ? `#${current}` : "";
79
+ }
80
+
81
+ function bumpConversationCounter(sessionKey) {
82
+ if (!sessionKey) return;
83
+ const current = conversationCounters.get(sessionKey) ?? 0;
84
+ conversationCounters.set(sessionKey, current + 1);
85
+ }
86
+
87
+ function getEffectiveAgentId(cfg, ctx) {
88
+ if (!cfg.multiAgentMode) {
89
+ return cfg.agentId;
90
+ }
91
+ const agentId = ctx?.agentId || cfg.agentId;
92
+ return agentId === "main" ? undefined : agentId;
93
+ }
94
+
95
+ export function extractDirectSessionUserId(sessionKey) {
96
+ if (!sessionKey || typeof sessionKey !== "string") return "";
97
+ const parts = sessionKey.split(":");
98
+ const directIndex = parts.lastIndexOf("direct");
99
+ if (directIndex === -1) return "";
100
+ return parts[directIndex + 1] || "";
101
+ }
102
+
103
+ export function resolveMemosUserId(cfg, ctx) {
104
+ const fallback = cfg?.userId || "openclaw-user";
105
+ if (!cfg?.useDirectSessionUserId) return fallback;
106
+ const directUserId = extractDirectSessionUserId(ctx?.sessionKey);
107
+ return directUserId || fallback;
108
+ }
109
+
110
+ function resolveConversationId(cfg, ctx) {
111
+ if (cfg.conversationId) return cfg.conversationId;
112
+ // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId).
113
+ const agentId = getEffectiveAgentId(cfg, ctx);
114
+ const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : "");
115
+ const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : "";
116
+ const prefix = cfg.conversationIdPrefix || "";
117
+ const suffix = cfg.conversationIdSuffix || "";
118
+ if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`;
119
+ return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`;
120
+ }
121
+
122
+ export function buildSearchPayload(cfg, prompt, ctx) {
123
+ const cleanPrompt = stripOpenClawInjectedPrefix(prompt);
124
+ const queryRaw = `${cfg.queryPrefix || ""}${cleanPrompt}`;
125
+ const query =
126
+ Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0
127
+ ? queryRaw.slice(0, cfg.maxQueryChars)
128
+ : queryRaw;
129
+
130
+ const payload = {
131
+ user_id: resolveMemosUserId(cfg, ctx),
132
+ query,
133
+ source: MEMOS_SOURCE,
134
+ };
135
+
136
+ if (!cfg.recallGlobal) {
137
+ const conversationId = resolveConversationId(cfg, ctx);
138
+ if (conversationId) payload.conversation_id = conversationId;
139
+ }
140
+
141
+ let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null;
142
+ const agentId = getEffectiveAgentId(cfg, ctx);
143
+
144
+ // Check if the filter is already in the categorized format (filter1)
145
+ const isCategorized = filterObj && (filterObj.user !== undefined || filterObj.knowledgebase !== undefined || filterObj.public !== undefined);
146
+ let userFilter = isCategorized ? (filterObj.user || null) : filterObj;
147
+
148
+ if (agentId) {
149
+ if (userFilter && Object.keys(userFilter).length > 0) {
150
+ if (Array.isArray(userFilter.and)) {
151
+ userFilter.and.push({ agent_id: agentId });
152
+ } else {
153
+ userFilter = { and: [userFilter, { agent_id: agentId }] };
154
+ }
155
+ } else {
156
+ userFilter = { and: [{ agent_id: agentId }] };
157
+ }
158
+ }
159
+
160
+ if (isCategorized) {
161
+ if (userFilter && Object.keys(userFilter).length > 0) filterObj.user = userFilter;
162
+ if (Object.keys(filterObj).length > 0) payload.filter = filterObj;
163
+ } else if (userFilter && Object.keys(userFilter).length > 0) {
164
+ // If not categorized, wrap it in 'user' so knowledgebase is not filtered
165
+ payload.filter = { user: userFilter };
166
+ }
167
+
168
+ if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds;
169
+
170
+ payload.memory_limit_number = cfg.memoryLimitNumber;
171
+ payload.include_preference = cfg.includePreference;
172
+ payload.preference_limit_number = cfg.preferenceLimitNumber;
173
+ payload.include_tool_memory = cfg.includeToolMemory;
174
+ payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber;
175
+ payload.relativity = cfg.relativity;
176
+
177
+ return payload;
178
+ }
179
+
180
+ export function buildAddMessagePayload(cfg, messages, ctx) {
181
+ const payload = {
182
+ user_id: resolveMemosUserId(cfg, ctx),
183
+ conversation_id: resolveConversationId(cfg, ctx),
184
+ messages,
185
+ source: MEMOS_SOURCE,
186
+ };
187
+
188
+ const agentId = getEffectiveAgentId(cfg, ctx);
189
+ if (agentId) payload.agent_id = agentId;
190
+ if (cfg.appId) payload.app_id = cfg.appId;
191
+ if (cfg.tags?.length) payload.tags = cfg.tags;
192
+
193
+ const info = {
194
+ source: MEMOS_SOURCE,
195
+ sessionKey: ctx?.sessionKey,
196
+ agentId: ctx?.agentId,
197
+ ...(cfg.info || {}),
198
+ };
199
+ if (Object.keys(info).length > 0) payload.info = info;
200
+
201
+ payload.allow_public = cfg.allowPublic;
202
+ if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds;
203
+ payload.async_mode = cfg.asyncMode;
204
+
205
+ return payload;
206
+ }
207
+
208
+ function convertAssistantMessage(msg, cfg) {
209
+ const contentArr = Array.isArray(msg.content)
210
+ ? msg.content
211
+ : msg.content
212
+ ? [{ type: "text", text: String(msg.content) }]
213
+ : [];
214
+
215
+ const textContent = contentArr
216
+ .filter((c) => c?.type === "text")
217
+ .map((c) => c.text || "")
218
+ .filter(Boolean)
219
+ .join("\n");
220
+
221
+ const toolCallItems = contentArr.filter((c) => c?.type === "toolCall");
222
+
223
+ const result = { role: "assistant" };
224
+
225
+ if (textContent) {
226
+ result.content = truncate(textContent, cfg.maxMessageChars);
227
+ }
228
+
229
+ if (cfg.includeToolMemory && toolCallItems.length > 0) {
230
+ result.tool_calls = toolCallItems.map((tc) => ({
231
+ id: tc.id,
232
+ type: "function",
233
+ function: {
234
+ name: tc.name,
235
+ arguments: typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {}),
236
+ },
237
+ }));
238
+ }
239
+
240
+ if (!result.content && !result.tool_calls) return null;
241
+ return result;
242
+ }
243
+
244
+ function safeStringify(value) {
245
+ try {
246
+ return JSON.stringify(value);
247
+ } catch {
248
+ return "";
249
+ }
250
+ }
251
+
252
+ // 把单个附件值(URL / data URI / 裸 base64)统一描述成可读 text:
253
+ // - http(s):// / 其它协议 URL:[<kind>: <url>]
254
+ // - data:<mediaType>;base64,...:[<kind> (<mediaType> base64, ~<size> chars)]
255
+ // - 其它(视为裸 base64):[<kind> (base64, ~<size> chars)]
256
+ function describeAttachment(kind, value) {
257
+ const dataMatch = /^data:([^;,]+)/i.exec(value);
258
+ if (dataMatch) {
259
+ return `[${kind} (${dataMatch[1] || kind} base64, ~${value.length} chars)]`;
260
+ }
261
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
262
+ return `[${kind}: ${value}]`;
263
+ }
264
+ return `[${kind} (base64, ~${value.length} chars)]`;
265
+ }
266
+
267
+ // MemOS 是文本记忆服务,召回路径上图片/文件 block 几乎只有文本价值。
268
+ // 这里把所有 block 一律归一成 [{type:"text", text}],但**保留 URL 文字本身**:
269
+ // - text block:透传文本(按 cfg.maxMessageChars 截头)
270
+ // - URL 形态:输出 "[image: <url>]" / "[file: <url>]",URL 作为可检索文字保留
271
+ // - data URI / base64 形态:输出 "[image (<media_type> base64, ~<size> chars)]" 元数据描述,永不 inline base64
272
+ // - 未识别 type:含 url 字段则 "[<type>: <url>]",否则 JSON.stringify 兜底
273
+ function normalizeToolResultContent(content, cfg) {
274
+ const blocks = [];
275
+
276
+ const pushText = (raw) => {
277
+ const text = truncate(String(raw ?? ""), cfg.maxMessageChars);
278
+ if (text) blocks.push({ type: "text", text });
279
+ };
280
+
281
+ // 解析所有协议下的 image 类 block,提取出统一的"附件值"再交给 describeAttachment 描述。
282
+ // 覆盖:
283
+ // {type:"image_url", image_url:{url}} / {image_url:"<str>"} / 顶层 url (OpenAI 风格)
284
+ // {type:"image", data, media_type} / {type:"image", source:{data, media_type}} (Claude 风格)
285
+ // {type:"image", url} (少见)
286
+ const tryPushImageBlock = (block) => {
287
+ const claudeData =
288
+ (block.source && typeof block.source === "object" && block.source.data) || block.data || "";
289
+ if (claudeData) {
290
+ const mediaType =
291
+ (block.source && typeof block.source === "object" && block.source.media_type) ||
292
+ block.media_type ||
293
+ block.mimeType ||
294
+ "image";
295
+ pushText(describeAttachment("image", `data:${mediaType};base64,${String(claudeData)}`));
296
+ return true;
297
+ }
298
+ const url =
299
+ (block.image_url && typeof block.image_url === "object" && block.image_url.url) ||
300
+ (typeof block.image_url === "string" ? block.image_url : "") ||
301
+ block.url ||
302
+ "";
303
+ if (!url) return false;
304
+ pushText(describeAttachment("image", String(url)));
305
+ return true;
306
+ };
307
+
308
+ // MemOS schema 标准 file block:{type:"file", file:{file_data}},兼容顶层 file_data。
309
+ const tryPushFileBlock = (block) => {
310
+ const fileData =
311
+ (block.file && typeof block.file === "object" && block.file.file_data) ||
312
+ block.file_data ||
313
+ "";
314
+ if (!fileData) return false;
315
+ pushText(describeAttachment("file", String(fileData)));
316
+ return true;
317
+ };
318
+
319
+ const tryPushTypedBlock = (block) => {
320
+ if (!block || typeof block !== "object") return false;
321
+ if (block.type === "text") {
322
+ pushText(block.text);
323
+ return true;
324
+ }
325
+ if (block.type === "image_url" || block.type === "image") return tryPushImageBlock(block);
326
+ if (block.type === "file") return tryPushFileBlock(block);
327
+ return false;
328
+ };
329
+
330
+ // 未识别 type:有 url 字段则给可读占位,否则整体 stringify。
331
+ const fallbackSerialize = (block) => {
332
+ if (
333
+ block &&
334
+ typeof block === "object" &&
335
+ typeof block.type === "string" &&
336
+ typeof block.url === "string" &&
337
+ block.url
338
+ ) {
339
+ pushText(`[${block.type}: ${block.url}]`);
340
+ return;
341
+ }
342
+ const serialized = safeStringify(block);
343
+ if (serialized) pushText(serialized);
344
+ };
345
+
346
+ if (content == null || content === "") return blocks;
347
+
348
+ if (typeof content === "string") {
349
+ pushText(content);
350
+ return blocks;
351
+ }
352
+
353
+ if (Array.isArray(content)) {
354
+ for (const block of content) {
355
+ if (block == null) continue;
356
+ if (typeof block === "string") {
357
+ pushText(block);
358
+ continue;
359
+ }
360
+ if (typeof block !== "object") continue;
361
+ if (tryPushTypedBlock(block)) continue;
362
+ fallbackSerialize(block);
363
+ }
364
+ return blocks;
365
+ }
366
+
367
+ if (typeof content === "object") {
368
+ if (!tryPushTypedBlock(content)) {
369
+ fallbackSerialize(content);
370
+ }
371
+ return blocks;
372
+ }
373
+
374
+ return blocks;
375
+ }
376
+
377
+ function convertToolResultMessage(msg, cfg) {
378
+ const toolCallId = msg.toolCallId || msg.tool_call_id;
379
+ if (!toolCallId) return null;
380
+ const blocks = normalizeToolResultContent(msg.content, cfg);
381
+ if (blocks.length === 0) return null;
382
+ return {
383
+ role: "tool",
384
+ tool_call_id: toolCallId,
385
+ content: blocks,
386
+ };
387
+ }
388
+
389
+ // 把 OpenClaw 的单条原始消息转成 MemOS /add/message 接受的形态。
390
+ // 三类 role 分发:user / assistant / toolResult,其它 role(system/...)直接丢弃返 null。
391
+ function convertSessionMessage(msg, cfg) {
392
+ if (!msg || !msg.role) return null;
393
+ if (msg.role === "user") {
394
+ const content = stripOpenClawInjectedPrefix(extractText(msg.content));
395
+ if (!content) return null;
396
+ return { role: "user", content: truncate(content, cfg.maxMessageChars) };
397
+ }
398
+ if (msg.role === "assistant" && cfg.includeAssistant) {
399
+ return convertAssistantMessage(msg, cfg);
400
+ }
401
+ if (msg.role === "toolResult" && cfg.includeToolMemory) {
402
+ return convertToolResultMessage(msg, cfg);
403
+ }
404
+ return null;
405
+ }
406
+
407
+ function pickLastTurnMessages(messages, cfg) {
408
+ let lastUserIndex = -1;
409
+ for (let i = messages.length - 1; i >= 0; i--) {
410
+ if (messages[i]?.role === "user") {
411
+ lastUserIndex = i;
412
+ break;
413
+ }
414
+ }
415
+ if (lastUserIndex < 0) return [];
416
+ return messages
417
+ .slice(lastUserIndex)
418
+ .map((m) => convertSessionMessage(m, cfg))
419
+ .filter(Boolean);
420
+ }
421
+
422
+ function pickFullSessionMessages(messages, cfg) {
423
+ return messages.map((m) => convertSessionMessage(m, cfg)).filter(Boolean);
424
+ }
425
+
426
+ function truncate(text, maxLen) {
427
+ if (!text) return "";
428
+ if (!maxLen) return text;
429
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
430
+ }
431
+
432
+ function sleep(ms) {
433
+ return new Promise((resolve) => setTimeout(resolve, ms));
434
+ }
435
+
436
+ function parseModelJson(text) {
437
+ if (!text || typeof text !== "string") return null;
438
+ const trimmed = text.trim();
439
+ if (!trimmed) return null;
440
+ try {
441
+ return JSON.parse(trimmed);
442
+ } catch {
443
+ // Some models wrap JSON in markdown code fences.
444
+ }
445
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
446
+ if (fenceMatch?.[1]) {
447
+ try {
448
+ return JSON.parse(fenceMatch[1].trim());
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ const first = trimmed.indexOf("{");
454
+ const last = trimmed.lastIndexOf("}");
455
+ if (first >= 0 && last > first) {
456
+ try {
457
+ return JSON.parse(trimmed.slice(first, last + 1));
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+ return null;
463
+ }
464
+
465
+ function normalizeIndexList(value, maxLen) {
466
+ if (!Array.isArray(value)) return [];
467
+ const seen = new Set();
468
+ const out = [];
469
+ for (const v of value) {
470
+ if (!Number.isInteger(v)) continue;
471
+ if (v < 0 || v >= maxLen) continue;
472
+ if (seen.has(v)) continue;
473
+ seen.add(v);
474
+ out.push(v);
475
+ }
476
+ return out;
477
+ }
478
+
479
+ function buildRecallCandidates(data, cfg) {
480
+ const limit = Number.isFinite(cfg.recallFilterCandidateLimit) ? Math.max(0, cfg.recallFilterCandidateLimit) : 30;
481
+ const maxChars = Number.isFinite(cfg.recallFilterMaxItemChars) ? Math.max(80, cfg.recallFilterMaxItemChars) : 500;
482
+ const memoryList = Array.isArray(data?.memory_detail_list) ? data.memory_detail_list : [];
483
+ const preferenceList = Array.isArray(data?.preference_detail_list) ? data.preference_detail_list : [];
484
+ const toolList = Array.isArray(data?.tool_memory_detail_list) ? data.tool_memory_detail_list : [];
485
+
486
+ const memoryCandidates = memoryList.slice(0, limit).map((item, idx) => ({
487
+ idx,
488
+ text: truncate(item?.memory_value || item?.memory_key || "", maxChars),
489
+ relativity: item?.relativity,
490
+ }));
491
+ const preferenceCandidates = preferenceList.slice(0, limit).map((item, idx) => ({
492
+ idx,
493
+ text: truncate(item?.preference || "", maxChars),
494
+ relativity: item?.relativity,
495
+ preference_type: item?.preference_type || "",
496
+ }));
497
+ const toolCandidates = toolList.slice(0, limit).map((item, idx) => ({
498
+ idx,
499
+ text: truncate(item?.tool_value || "", maxChars),
500
+ relativity: item?.relativity,
501
+ }));
502
+
503
+ return {
504
+ memoryList,
505
+ preferenceList,
506
+ toolList,
507
+ candidatePayload: {
508
+ memory: memoryCandidates,
509
+ preference: preferenceCandidates,
510
+ tool_memory: toolCandidates,
511
+ },
512
+ };
513
+ }
514
+
515
+ function applyRecallDecision(data, decision, lists) {
516
+ const keep = decision?.keep || {};
517
+ const memoryIdx = normalizeIndexList(keep.memory, lists.memoryList.length);
518
+ const preferenceIdx = normalizeIndexList(keep.preference, lists.preferenceList.length);
519
+ const toolIdx = normalizeIndexList(keep.tool_memory, lists.toolList.length);
520
+
521
+ return {
522
+ ...data,
523
+ memory_detail_list: memoryIdx.map((idx) => lists.memoryList[idx]),
524
+ preference_detail_list: preferenceIdx.map((idx) => lists.preferenceList[idx]),
525
+ tool_memory_detail_list: toolIdx.map((idx) => lists.toolList[idx]),
526
+ };
527
+ }
528
+
529
+ async function callRecallFilterModel(cfg, userPrompt, candidatePayload) {
530
+ const headers = {
531
+ "Content-Type": "application/json",
532
+ };
533
+ if (cfg.recallFilterApiKey) {
534
+ headers.Authorization = `Bearer ${cfg.recallFilterApiKey}`;
535
+ }
536
+
537
+ const modelInput = {
538
+ user_query: userPrompt,
539
+ candidate_memories: candidatePayload,
540
+ output_schema: {
541
+ keep: {
542
+ memory: ["number index"],
543
+ preference: ["number index"],
544
+ tool_memory: ["number index"],
545
+ },
546
+ reason: "optional short string",
547
+ },
548
+ };
549
+
550
+ const body = {
551
+ model: cfg.recallFilterModel,
552
+ temperature: 0,
553
+ messages: [
554
+ {
555
+ role: "system",
556
+ content:
557
+ "You are a strict memory relevance judge. Return JSON only. Keep only items directly useful for answering current user query. If unsure, do not keep.",
558
+ },
559
+ {
560
+ role: "user",
561
+ content: JSON.stringify(modelInput),
562
+ },
563
+ ],
564
+ };
565
+
566
+ let lastError;
567
+ const retries = Number.isFinite(cfg.recallFilterRetries) ? Math.max(0, cfg.recallFilterRetries) : 1;
568
+ const timeoutMs = Number.isFinite(cfg.recallFilterTimeoutMs) ? Math.max(1000, cfg.recallFilterTimeoutMs) : 30000;
569
+
570
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
571
+ let timeoutId;
572
+ try {
573
+ const controller = new AbortController();
574
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
575
+ const res = await fetch(`${cfg.recallFilterBaseUrl}/chat/completions`, {
576
+ method: "POST",
577
+ headers,
578
+ body: JSON.stringify(body),
579
+ signal: controller.signal,
580
+ });
581
+ if (!res.ok) {
582
+ throw new Error(`HTTP ${res.status}`);
583
+ }
584
+ const json = await res.json();
585
+ const text = json?.choices?.[0]?.message?.content || "";
586
+ const parsed = parseModelJson(text);
587
+ if (!parsed || typeof parsed !== "object") {
588
+ throw new Error("invalid JSON output from recall filter model");
589
+ }
590
+ return parsed;
591
+ } catch (err) {
592
+ const isAbort = err?.name === "AbortError" || /aborted/i.test(String(err?.message ?? err));
593
+ lastError = isAbort
594
+ ? new Error(
595
+ `timed out after ${timeoutMs}ms (raise recallFilterTimeoutMs; local LLMs often need 30s+ on cold start)`,
596
+ )
597
+ : err;
598
+ if (attempt < retries) {
599
+ await sleep(120 * (attempt + 1));
600
+ }
601
+ } finally {
602
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
603
+ }
604
+ }
605
+ throw lastError;
606
+ }
607
+
608
+ async function maybeFilterRecallData(cfg, data, userPrompt, log, ctx) {
609
+ if (!cfg.recallFilterEnabled) return data;
610
+ if (!cfg.recallFilterBaseUrl || !cfg.recallFilterModel) {
611
+ log.warn?.("[memos-cloud] recall filter enabled but missing recallFilterBaseUrl/recallFilterModel; skip filter");
612
+ return data;
613
+ }
614
+ const lists = buildRecallCandidates(data, cfg);
615
+ const hasCandidates =
616
+ lists.candidatePayload.memory.length > 0 ||
617
+ lists.candidatePayload.preference.length > 0 ||
618
+ lists.candidatePayload.tool_memory.length > 0;
619
+ if (!hasCandidates) return data;
620
+
621
+ try {
622
+ reportRumEvent("recall_filter", { recall_filter_enable: cfg.recallFilterEnabled }, cfg, ctx, log);
623
+ const decision = await callRecallFilterModel(cfg, userPrompt, lists.candidatePayload);
624
+ const filtered = applyRecallDecision(data, decision, lists);
625
+ log.info?.(
626
+ `[memos-cloud] recall filter applied: memory ${lists.memoryList.length}->${filtered.memory_detail_list?.length ?? 0}, ` +
627
+ `preference ${lists.preferenceList.length}->${filtered.preference_detail_list?.length ?? 0}, ` +
628
+ `tool_memory ${lists.toolList.length}->${filtered.tool_memory_detail_list?.length ?? 0}`,
629
+ );
630
+ return filtered;
631
+ } catch (err) {
632
+ log.warn?.(`[memos-cloud] recall filter failed: ${String(err)}`);
633
+ return cfg.recallFilterFailOpen ? data : { ...data, memory_detail_list: [], preference_detail_list: [], tool_memory_detail_list: [] };
634
+ }
635
+ }
636
+
637
+ export default {
638
+ id: "memos-cloud-openclaw-plugin",
639
+ name: "MemOS Cloud OpenClaw Plugin",
640
+ description: "MemOS Cloud recall + add memory via lifecycle hooks",
641
+ kind: "lifecycle",
642
+
643
+ register(api) {
644
+ const cfg = buildConfig(api.pluginConfig);
645
+ const log = api.logger ?? console;
646
+ let configUiStartupCancelled = false;
647
+
648
+ // Start 12-hour background update interval
649
+ startUpdateChecker(log);
650
+
651
+ // Detect the host CLI version once so every hook registration branch can reference it.
652
+ const hostVersion = detectHostVersion();
653
+
654
+ // Side effects below are only meaningful when the host CLI was actually
655
+ // launched to run the gateway (`openclaw gateway run|start|restart`).
656
+ // Other entry points (e.g. `plugins install`, `security audit`) also
657
+ // load this plugin to inspect/register it, but:
658
+ // - `ensurePluginHookPolicy` writes to `openclaw.json` and would race
659
+ // against the install command's own commit (ConfigMutationConflictError).
660
+ // - `waitForGatewayReady` would keep the short-lived event loop alive
661
+ // for 45s probing a gateway that will never come up, then emit a
662
+ // misleading "probe timed out" warning before the process exits.
663
+ // Gate them all in one place so the policy is explicit and discoverable.
664
+ if (isGatewayRuntimeStartup()) {
665
+ // `allowConversationAccess` hook policy was introduced in 2026.4.23;
666
+ // older hosts do not understand the field and don't need it patched in.
667
+ const HOOK_POLICY_MIN_VERSION = "2026.4.23";
668
+ const needsHookPolicy =
669
+ hostVersion === null ||
670
+ compareVersionStrings(hostVersion, HOOK_POLICY_MIN_VERSION) >= 0;
671
+
672
+ void (async () => {
673
+ const ready = await waitForGatewayReady(api.config, log);
674
+ if (!ready || configUiStartupCancelled) return;
675
+
676
+ // Patch hook policy AFTER gateway is fully ready. Writing the config
677
+ // file at this point triggers the gateway's built-in config-change
678
+ // watcher which will auto-restart, making agent_end effective without
679
+ // requiring the user to manually restart.
680
+ if (needsHookPolicy) {
681
+ try {
682
+ const policyResult = ensurePluginHookPolicy(api.config, log);
683
+ if (policyResult?.error) {
684
+ log.warn?.(
685
+ `[memos-cloud] hook policy check skipped due to error: ${String(policyResult.error?.message ?? policyResult.error)}`,
686
+ );
687
+ }
688
+ } catch (error) {
689
+ log.warn?.(
690
+ `[memos-cloud] failed to ensure plugin hook policy: ${String(error?.message ?? error)}`,
691
+ );
692
+ }
693
+ }
694
+
695
+ await ensureConfigUiService(log);
696
+ })().catch((error) => {
697
+ log.warn?.(`[memos-cloud] config UI failed to start: ${String(error)}`);
698
+ });
699
+ }
700
+
701
+ if (!cfg.envFileStatus?.found) {
702
+ const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", ");
703
+ log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`);
704
+ }
705
+
706
+ if (cfg.multiAgentMode && cfg.allowedAgents?.length > 0) {
707
+ log.info?.(`[memos-cloud] Multi-agent mode enabled. Allowed agents: [${cfg.allowedAgents.join(", ")}]`);
708
+ }
709
+
710
+ const overrideAgentIds = Object.keys(cfg._agentOverrides || {});
711
+ if (overrideAgentIds.length > 0) {
712
+ log.info?.(`[memos-cloud] Per-agent overrides configured for: [${overrideAgentIds.join(", ")}]`);
713
+ }
714
+
715
+ if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) {
716
+ if (api.config?.hooks?.internal?.enabled !== true) {
717
+ log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true");
718
+ }
719
+ api.registerHook(
720
+ ["command:new"],
721
+ (event) => {
722
+ if (event?.type === "command" && event?.action === "new") {
723
+ bumpConversationCounter(event.sessionKey);
724
+ }
725
+ },
726
+ {
727
+ name: "memos-cloud-conversation-new",
728
+ description: "Increment MemOS conversation suffix on /new",
729
+ },
730
+ );
731
+ }
732
+
733
+ const runRecall = async (event, ctx) => {
734
+ // Skip system events: heartbeat, /new, /reset, and other commands
735
+ const prompt = event?.prompt || "";
736
+ const isHeartbeat = isHeartbeatPrompt(prompt);
737
+ const isSystemCommand = isSystemCommandPrompt(prompt);
738
+
739
+ if (isHeartbeat || isSystemCommand) {
740
+ log.info?.(`[memos-cloud] recall skipped: system event detected (heartbeat=${isHeartbeat}, command=${isSystemCommand}, prompt="${prompt.substring(0, 50)}...")`);
741
+ return;
742
+ }
743
+
744
+ if (!isAgentAllowed(cfg, ctx)) {
745
+ log.info?.(`[memos-cloud] recall skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
746
+ return;
747
+ }
748
+ const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
749
+ if (!agentCfg.recallEnabled) return;
750
+ const userPrompt = stripOpenClawInjectedPrefix(event?.prompt || "");
751
+ if (!userPrompt || userPrompt.length < 3) return;
752
+ if (!agentCfg.apiKey) {
753
+ warnMissingApiKey(log, "recall");
754
+ return;
755
+ }
756
+
757
+ try {
758
+ const payload = buildSearchPayload(agentCfg, userPrompt, ctx);
759
+ reportRumEvent('search_memory', payload, agentCfg, ctx, log);
760
+ const result = await searchMemory(agentCfg, payload);
761
+ const resultData = extractResultData(result);
762
+ if (!resultData) return;
763
+ const filteredData = await maybeFilterRecallData(agentCfg, resultData, userPrompt, log, ctx);
764
+ const hookResult = formatRecallHookResult({ data: filteredData }, {
765
+ wrapTagBlocks: true,
766
+ relativity: payload.relativity,
767
+ maxItemChars: agentCfg.maxItemChars,
768
+ });
769
+ if (!hookResult.appendSystemContext && !hookResult.prependContext) return;
770
+
771
+ return hookResult;
772
+ } catch (err) {
773
+ log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
774
+ }
775
+ };
776
+
777
+ // Recall mutates prompt context only, so the phase-specific replacement for
778
+ // legacy before_agent_start is before_prompt_build. Do not register both on
779
+ // new hosts, otherwise the same memory block can be injected twice.
780
+ const PROMPT_BUILD_HOOK_MIN_VERSION = "2026.5.7";
781
+ const usesBeforePromptBuild =
782
+ hostVersion !== null &&
783
+ compareVersionStrings(hostVersion, PROMPT_BUILD_HOOK_MIN_VERSION) >= 0;
784
+
785
+ if (usesBeforePromptBuild) {
786
+ api.on("before_prompt_build", runRecall);
787
+ } else {
788
+ api.on("before_agent_start", runRecall);
789
+ }
790
+
791
+ api.on("agent_end", async (event, ctx) => {
792
+ // Skip system events: heartbeat and commands
793
+ // Check the last user message to determine if this was a system event
794
+ const messages = event?.messages || [];
795
+ const lastUserMsg = messages.slice().reverse().find(m => m?.role === "user");
796
+ const lastUserContent = extractText(lastUserMsg?.content || "");
797
+
798
+ const isHeartbeat = isHeartbeatPrompt(lastUserContent);
799
+ const isSystemCommand = isSystemCommandPrompt(lastUserContent);
800
+
801
+ if (isHeartbeat || isSystemCommand) {
802
+ log.info?.(`[memos-cloud] add skipped: system event detected (heartbeat=${isHeartbeat}, command=${isSystemCommand}, content="${lastUserContent.substring(0, 50)}...")`);
803
+ return;
804
+ }
805
+
806
+ if (!isAgentAllowed(cfg, ctx)) {
807
+ log.info?.(`[memos-cloud] add skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
808
+ return;
809
+ }
810
+ const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
811
+ if (!agentCfg.addEnabled) return;
812
+ if (!event?.success || !event?.messages?.length) return;
813
+ if (!agentCfg.apiKey) {
814
+ warnMissingApiKey(log, "add");
815
+ return;
816
+ }
817
+
818
+ const now = Date.now();
819
+ if (agentCfg.throttleMs && now - lastCaptureTime < agentCfg.throttleMs) {
820
+ return;
821
+ }
822
+ lastCaptureTime = now;
823
+
824
+ try {
825
+ const messages =
826
+ agentCfg.captureStrategy === "full_session"
827
+ ? pickFullSessionMessages(event.messages, agentCfg)
828
+ : pickLastTurnMessages(event.messages, agentCfg);
829
+
830
+ if (!messages.length) return;
831
+
832
+ const payload = buildAddMessagePayload(agentCfg, messages, ctx);
833
+ await addMessage(agentCfg, payload);
834
+ } catch (err) {
835
+ log.warn?.(`[memos-cloud] add failed: ${String(err)}`);
836
+ }
837
+ });
838
+
839
+ return () => {
840
+ configUiStartupCancelled = true;
841
+ void closeConfigUiService();
842
+ };
843
+ },
844
+ };