@memtensor/memos-cloud-openclaw-plugin 0.1.19-beta.0 → 0.1.20-beta.0

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