@memtensor/memos-cloud-openclaw-plugin 0.1.13 → 0.1.14

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,554 +1,604 @@
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 { closeConfigUiService, ensureConfigUiService, waitForGatewayReady } from "./lib/config-ui-server.js";
16
- let lastCaptureTime = 0;
17
- const conversationCounters = new Map();
18
- const API_KEY_HELP_URL = "https://memos-dashboard.openmem.net/cn/apikeys/";
19
- const ENV_FILE_SEARCH_HINTS = ["~/.openclaw/.env", "~/.moltbot/.env", "~/.clawdbot/.env"];
20
- const MEMOS_SOURCE = "openclaw";
21
-
22
- function warnMissingApiKey(log, context) {
23
- const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)";
24
- const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`;
25
- log.warn?.(
26
- [
27
- header,
28
- "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc",
29
- "source ~/.zshrc",
30
- "or",
31
- "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc",
32
- "source ~/.bashrc",
33
- "or",
34
- "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")",
35
- `Get API key: ${API_KEY_HELP_URL}`,
36
- ].join("\n"),
37
- );
38
- }
39
-
40
- function getCounterSuffix(sessionKey) {
41
- if (!sessionKey) return "";
42
- const current = conversationCounters.get(sessionKey) ?? 0;
43
- return current > 0 ? `#${current}` : "";
44
- }
45
-
46
- function bumpConversationCounter(sessionKey) {
47
- if (!sessionKey) return;
48
- const current = conversationCounters.get(sessionKey) ?? 0;
49
- conversationCounters.set(sessionKey, current + 1);
50
- }
51
-
52
- function getEffectiveAgentId(cfg, ctx) {
53
- if (!cfg.multiAgentMode) {
54
- return cfg.agentId;
55
- }
56
- const agentId = ctx?.agentId || cfg.agentId;
57
- return agentId === "main" ? undefined : agentId;
58
- }
59
-
60
- export function extractDirectSessionUserId(sessionKey) {
61
- if (!sessionKey || typeof sessionKey !== "string") return "";
62
- const parts = sessionKey.split(":");
63
- const directIndex = parts.lastIndexOf("direct");
64
- if (directIndex === -1) return "";
65
- return parts[directIndex + 1] || "";
66
- }
67
-
68
- export function resolveMemosUserId(cfg, ctx) {
69
- const fallback = cfg?.userId || "openclaw-user";
70
- if (!cfg?.useDirectSessionUserId) return fallback;
71
- const directUserId = extractDirectSessionUserId(ctx?.sessionKey);
72
- return directUserId || fallback;
73
- }
74
-
75
- function resolveConversationId(cfg, ctx) {
76
- if (cfg.conversationId) return cfg.conversationId;
77
- // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId).
78
- const agentId = getEffectiveAgentId(cfg, ctx);
79
- const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : "");
80
- const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : "";
81
- const prefix = cfg.conversationIdPrefix || "";
82
- const suffix = cfg.conversationIdSuffix || "";
83
- if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`;
84
- return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`;
85
- }
86
-
87
- export function buildSearchPayload(cfg, prompt, ctx) {
88
- const cleanPrompt = stripOpenClawInjectedPrefix(prompt);
89
- const queryRaw = `${cfg.queryPrefix || ""}${cleanPrompt}`;
90
- const query =
91
- Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0
92
- ? queryRaw.slice(0, cfg.maxQueryChars)
93
- : queryRaw;
94
-
95
- const payload = {
96
- user_id: resolveMemosUserId(cfg, ctx),
97
- query,
98
- source: MEMOS_SOURCE,
99
- };
100
-
101
- if (!cfg.recallGlobal) {
102
- const conversationId = resolveConversationId(cfg, ctx);
103
- if (conversationId) payload.conversation_id = conversationId;
104
- }
105
-
106
- let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null;
107
- const agentId = getEffectiveAgentId(cfg, ctx);
108
-
109
- // Check if the filter is already in the categorized format (filter1)
110
- const isCategorized = filterObj && (filterObj.user !== undefined || filterObj.knowledgebase !== undefined || filterObj.public !== undefined);
111
- let userFilter = isCategorized ? (filterObj.user || null) : filterObj;
112
-
113
- if (agentId) {
114
- if (userFilter && Object.keys(userFilter).length > 0) {
115
- if (Array.isArray(userFilter.and)) {
116
- userFilter.and.push({ agent_id: agentId });
117
- } else {
118
- userFilter = { and: [userFilter, { agent_id: agentId }] };
119
- }
120
- } else {
121
- userFilter = { and: [{ agent_id: agentId }] };
122
- }
123
- }
124
-
125
- if (isCategorized) {
126
- if (userFilter && Object.keys(userFilter).length > 0) filterObj.user = userFilter;
127
- if (Object.keys(filterObj).length > 0) payload.filter = filterObj;
128
- } else if (userFilter && Object.keys(userFilter).length > 0) {
129
- // If not categorized, wrap it in 'user' so knowledgebase is not filtered
130
- payload.filter = { user: userFilter };
131
- }
132
-
133
- if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds;
134
-
135
- payload.memory_limit_number = cfg.memoryLimitNumber;
136
- payload.include_preference = cfg.includePreference;
137
- payload.preference_limit_number = cfg.preferenceLimitNumber;
138
- payload.include_tool_memory = cfg.includeToolMemory;
139
- payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber;
140
- payload.relativity = cfg.relativity;
141
-
142
- return payload;
143
- }
144
-
145
- export function buildAddMessagePayload(cfg, messages, ctx) {
146
- const payload = {
147
- user_id: resolveMemosUserId(cfg, ctx),
148
- conversation_id: resolveConversationId(cfg, ctx),
149
- messages,
150
- source: MEMOS_SOURCE,
151
- };
152
-
153
- const agentId = getEffectiveAgentId(cfg, ctx);
154
- if (agentId) payload.agent_id = agentId;
155
- if (cfg.appId) payload.app_id = cfg.appId;
156
- if (cfg.tags?.length) payload.tags = cfg.tags;
157
-
158
- const info = {
159
- source: "openclaw",
160
- sessionKey: ctx?.sessionKey,
161
- agentId: ctx?.agentId,
162
- ...(cfg.info || {}),
163
- };
164
- if (Object.keys(info).length > 0) payload.info = info;
165
-
166
- payload.allow_public = cfg.allowPublic;
167
- if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds;
168
- payload.async_mode = cfg.asyncMode;
169
-
170
- return payload;
171
- }
172
-
173
- function pickLastTurnMessages(messages, cfg) {
174
- const lastUserIndex = messages
175
- .map((m, idx) => ({ m, idx }))
176
- .filter(({ m }) => m?.role === "user")
177
- .map(({ idx }) => idx)
178
- .pop();
179
-
180
- if (lastUserIndex === undefined) return [];
181
-
182
- const slice = messages.slice(lastUserIndex);
183
- const results = [];
184
-
185
- for (const msg of slice) {
186
- if (!msg || !msg.role) continue;
187
- if (msg.role === "user") {
188
- const content = stripOpenClawInjectedPrefix(extractText(msg.content));
189
- if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
190
- continue;
191
- }
192
- if (msg.role === "assistant" && cfg.includeAssistant) {
193
- const content = extractText(msg.content);
194
- if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
195
- }
196
- }
197
-
198
- return results;
199
- }
200
-
201
- function pickFullSessionMessages(messages, cfg) {
202
- const results = [];
203
- for (const msg of messages) {
204
- if (!msg || !msg.role) continue;
205
- if (msg.role === "user") {
206
- const content = stripOpenClawInjectedPrefix(extractText(msg.content));
207
- if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
208
- }
209
- if (msg.role === "assistant" && cfg.includeAssistant) {
210
- const content = extractText(msg.content);
211
- if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
212
- }
213
- }
214
- return results;
215
- }
216
-
217
- function truncate(text, maxLen) {
218
- if (!text) return "";
219
- if (!maxLen) return text;
220
- return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
221
- }
222
-
223
- function sleep(ms) {
224
- return new Promise((resolve) => setTimeout(resolve, ms));
225
- }
226
-
227
- function parseModelJson(text) {
228
- if (!text || typeof text !== "string") return null;
229
- const trimmed = text.trim();
230
- if (!trimmed) return null;
231
- try {
232
- return JSON.parse(trimmed);
233
- } catch {
234
- // Some models wrap JSON in markdown code fences.
235
- }
236
- const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
237
- if (fenceMatch?.[1]) {
238
- try {
239
- return JSON.parse(fenceMatch[1].trim());
240
- } catch {
241
- return null;
242
- }
243
- }
244
- const first = trimmed.indexOf("{");
245
- const last = trimmed.lastIndexOf("}");
246
- if (first >= 0 && last > first) {
247
- try {
248
- return JSON.parse(trimmed.slice(first, last + 1));
249
- } catch {
250
- return null;
251
- }
252
- }
253
- return null;
254
- }
255
-
256
- function normalizeIndexList(value, maxLen) {
257
- if (!Array.isArray(value)) return [];
258
- const seen = new Set();
259
- const out = [];
260
- for (const v of value) {
261
- if (!Number.isInteger(v)) continue;
262
- if (v < 0 || v >= maxLen) continue;
263
- if (seen.has(v)) continue;
264
- seen.add(v);
265
- out.push(v);
266
- }
267
- return out;
268
- }
269
-
270
- function buildRecallCandidates(data, cfg) {
271
- const limit = Number.isFinite(cfg.recallFilterCandidateLimit) ? Math.max(0, cfg.recallFilterCandidateLimit) : 30;
272
- const maxChars = Number.isFinite(cfg.recallFilterMaxItemChars) ? Math.max(80, cfg.recallFilterMaxItemChars) : 500;
273
- const memoryList = Array.isArray(data?.memory_detail_list) ? data.memory_detail_list : [];
274
- const preferenceList = Array.isArray(data?.preference_detail_list) ? data.preference_detail_list : [];
275
- const toolList = Array.isArray(data?.tool_memory_detail_list) ? data.tool_memory_detail_list : [];
276
-
277
- const memoryCandidates = memoryList.slice(0, limit).map((item, idx) => ({
278
- idx,
279
- text: truncate(item?.memory_value || item?.memory_key || "", maxChars),
280
- relativity: item?.relativity,
281
- }));
282
- const preferenceCandidates = preferenceList.slice(0, limit).map((item, idx) => ({
283
- idx,
284
- text: truncate(item?.preference || "", maxChars),
285
- relativity: item?.relativity,
286
- preference_type: item?.preference_type || "",
287
- }));
288
- const toolCandidates = toolList.slice(0, limit).map((item, idx) => ({
289
- idx,
290
- text: truncate(item?.tool_value || "", maxChars),
291
- relativity: item?.relativity,
292
- }));
293
-
294
- return {
295
- memoryList,
296
- preferenceList,
297
- toolList,
298
- candidatePayload: {
299
- memory: memoryCandidates,
300
- preference: preferenceCandidates,
301
- tool_memory: toolCandidates,
302
- },
303
- };
304
- }
305
-
306
- function applyRecallDecision(data, decision, lists) {
307
- const keep = decision?.keep || {};
308
- const memoryIdx = normalizeIndexList(keep.memory, lists.memoryList.length);
309
- const preferenceIdx = normalizeIndexList(keep.preference, lists.preferenceList.length);
310
- const toolIdx = normalizeIndexList(keep.tool_memory, lists.toolList.length);
311
-
312
- return {
313
- ...data,
314
- memory_detail_list: memoryIdx.map((idx) => lists.memoryList[idx]),
315
- preference_detail_list: preferenceIdx.map((idx) => lists.preferenceList[idx]),
316
- tool_memory_detail_list: toolIdx.map((idx) => lists.toolList[idx]),
317
- };
318
- }
319
-
320
- async function callRecallFilterModel(cfg, userPrompt, candidatePayload) {
321
- const headers = {
322
- "Content-Type": "application/json",
323
- };
324
- if (cfg.recallFilterApiKey) {
325
- headers.Authorization = `Bearer ${cfg.recallFilterApiKey}`;
326
- }
327
-
328
- const modelInput = {
329
- user_query: userPrompt,
330
- candidate_memories: candidatePayload,
331
- output_schema: {
332
- keep: {
333
- memory: ["number index"],
334
- preference: ["number index"],
335
- tool_memory: ["number index"],
336
- },
337
- reason: "optional short string",
338
- },
339
- };
340
-
341
- const body = {
342
- model: cfg.recallFilterModel,
343
- temperature: 0,
344
- messages: [
345
- {
346
- role: "system",
347
- content:
348
- "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.",
349
- },
350
- {
351
- role: "user",
352
- content: JSON.stringify(modelInput),
353
- },
354
- ],
355
- };
356
-
357
- let lastError;
358
- const retries = Number.isFinite(cfg.recallFilterRetries) ? Math.max(0, cfg.recallFilterRetries) : 1;
359
- const timeoutMs = Number.isFinite(cfg.recallFilterTimeoutMs) ? Math.max(1000, cfg.recallFilterTimeoutMs) : 30000;
360
-
361
- for (let attempt = 0; attempt <= retries; attempt += 1) {
362
- let timeoutId;
363
- try {
364
- const controller = new AbortController();
365
- timeoutId = setTimeout(() => controller.abort(), timeoutMs);
366
- const res = await fetch(`${cfg.recallFilterBaseUrl}/chat/completions`, {
367
- method: "POST",
368
- headers,
369
- body: JSON.stringify(body),
370
- signal: controller.signal,
371
- });
372
- if (!res.ok) {
373
- throw new Error(`HTTP ${res.status}`);
374
- }
375
- const json = await res.json();
376
- const text = json?.choices?.[0]?.message?.content || "";
377
- const parsed = parseModelJson(text);
378
- if (!parsed || typeof parsed !== "object") {
379
- throw new Error("invalid JSON output from recall filter model");
380
- }
381
- return parsed;
382
- } catch (err) {
383
- const isAbort = err?.name === "AbortError" || /aborted/i.test(String(err?.message ?? err));
384
- lastError = isAbort
385
- ? new Error(
386
- `timed out after ${timeoutMs}ms (raise recallFilterTimeoutMs; local LLMs often need 30s+ on cold start)`,
387
- )
388
- : err;
389
- if (attempt < retries) {
390
- await sleep(120 * (attempt + 1));
391
- }
392
- } finally {
393
- if (timeoutId !== undefined) clearTimeout(timeoutId);
394
- }
395
- }
396
- throw lastError;
397
- }
398
-
399
- async function maybeFilterRecallData(cfg, data, userPrompt, log, ctx) {
400
- if (!cfg.recallFilterEnabled) return data;
401
- if (!cfg.recallFilterBaseUrl || !cfg.recallFilterModel) {
402
- log.warn?.("[memos-cloud] recall filter enabled but missing recallFilterBaseUrl/recallFilterModel; skip filter");
403
- return data;
404
- }
405
- const lists = buildRecallCandidates(data, cfg);
406
- const hasCandidates =
407
- lists.candidatePayload.memory.length > 0 ||
408
- lists.candidatePayload.preference.length > 0 ||
409
- lists.candidatePayload.tool_memory.length > 0;
410
- if (!hasCandidates) return data;
411
-
412
- try {
413
- reportRumEvent("recall_filter", { recall_filter_enable: cfg.recallFilterEnabled }, cfg, ctx, log);
414
- const decision = await callRecallFilterModel(cfg, userPrompt, lists.candidatePayload);
415
- const filtered = applyRecallDecision(data, decision, lists);
416
- log.info?.(
417
- `[memos-cloud] recall filter applied: memory ${lists.memoryList.length}->${filtered.memory_detail_list?.length ?? 0}, ` +
418
- `preference ${lists.preferenceList.length}->${filtered.preference_detail_list?.length ?? 0}, ` +
419
- `tool_memory ${lists.toolList.length}->${filtered.tool_memory_detail_list?.length ?? 0}`,
420
- );
421
- return filtered;
422
- } catch (err) {
423
- log.warn?.(`[memos-cloud] recall filter failed: ${String(err)}`);
424
- return cfg.recallFilterFailOpen ? data : { ...data, memory_detail_list: [], preference_detail_list: [], tool_memory_detail_list: [] };
425
- }
426
- }
427
-
428
- export default {
429
- id: "memos-cloud-openclaw-plugin",
430
- name: "MemOS Cloud OpenClaw Plugin",
431
- description: "MemOS Cloud recall + add memory via lifecycle hooks",
432
- kind: "lifecycle",
433
-
434
- register(api) {
435
- const cfg = buildConfig(api.pluginConfig);
436
- const log = api.logger ?? console;
437
- let configUiStartupCancelled = false;
438
-
439
- // Start 12-hour background update interval
440
- startUpdateChecker(log);
441
- void (async () => {
442
- const ready = await waitForGatewayReady(api.config, log);
443
- if (!ready || configUiStartupCancelled) return;
444
- await ensureConfigUiService(log);
445
- })().catch((error) => {
446
- log.warn?.(`[memos-cloud] config UI failed to start: ${String(error)}`);
447
- });
448
-
449
- if (!cfg.envFileStatus?.found) {
450
- const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", ");
451
- log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`);
452
- }
453
-
454
- if (cfg.multiAgentMode && cfg.allowedAgents?.length > 0) {
455
- log.info?.(`[memos-cloud] Multi-agent mode enabled. Allowed agents: [${cfg.allowedAgents.join(", ")}]`);
456
- }
457
-
458
- const overrideAgentIds = Object.keys(cfg._agentOverrides || {});
459
- if (overrideAgentIds.length > 0) {
460
- log.info?.(`[memos-cloud] Per-agent overrides configured for: [${overrideAgentIds.join(", ")}]`);
461
- }
462
-
463
- if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) {
464
- if (api.config?.hooks?.internal?.enabled !== true) {
465
- log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true");
466
- }
467
- api.registerHook(
468
- ["command:new"],
469
- (event) => {
470
- if (event?.type === "command" && event?.action === "new") {
471
- bumpConversationCounter(event.sessionKey);
472
- }
473
- },
474
- {
475
- name: "memos-cloud-conversation-new",
476
- description: "Increment MemOS conversation suffix on /new",
477
- },
478
- );
479
- }
480
-
481
- api.on("before_agent_start", async (event, ctx) => {
482
- if (!isAgentAllowed(cfg, ctx)) {
483
- log.info?.(`[memos-cloud] recall skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
484
- return;
485
- }
486
- const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
487
- if (!agentCfg.recallEnabled) return;
488
- const userPrompt = stripOpenClawInjectedPrefix(event?.prompt || "");
489
- if (!userPrompt || userPrompt.length < 3) return;
490
- if (!agentCfg.apiKey) {
491
- warnMissingApiKey(log, "recall");
492
- return;
493
- }
494
-
495
- try {
496
- const payload = buildSearchPayload(agentCfg, userPrompt, ctx);
497
- reportRumEvent('search_memory', payload, agentCfg, ctx, log);
498
- const result = await searchMemory(agentCfg, payload);
499
- const resultData = extractResultData(result);
500
- if (!resultData) return;
501
- const filteredData = await maybeFilterRecallData(agentCfg, resultData, userPrompt, log, ctx);
502
- const hookResult = formatRecallHookResult({ data: filteredData }, {
503
- wrapTagBlocks: true,
504
- relativity: payload.relativity,
505
- maxItemChars: agentCfg.maxItemChars,
506
- });
507
- if (!hookResult.appendSystemContext && !hookResult.prependContext) return;
508
-
509
- return hookResult;
510
- } catch (err) {
511
- log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
512
- }
513
- });
514
-
515
- api.on("agent_end", async (event, ctx) => {
516
- if (!isAgentAllowed(cfg, ctx)) {
517
- log.info?.(`[memos-cloud] add skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
518
- return;
519
- }
520
- const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
521
- if (!agentCfg.addEnabled) return;
522
- if (!event?.success || !event?.messages?.length) return;
523
- if (!agentCfg.apiKey) {
524
- warnMissingApiKey(log, "add");
525
- return;
526
- }
527
-
528
- const now = Date.now();
529
- if (agentCfg.throttleMs && now - lastCaptureTime < agentCfg.throttleMs) {
530
- return;
531
- }
532
- lastCaptureTime = now;
533
-
534
- try {
535
- const messages =
536
- agentCfg.captureStrategy === "full_session"
537
- ? pickFullSessionMessages(event.messages, agentCfg)
538
- : pickLastTurnMessages(event.messages, agentCfg);
539
-
540
- if (!messages.length) return;
541
-
542
- const payload = buildAddMessagePayload(agentCfg, messages, ctx);
543
- await addMessage(agentCfg, payload);
544
- } catch (err) {
545
- log.warn?.(`[memos-cloud] add failed: ${String(err)}`);
546
- }
547
- });
548
-
549
- return () => {
550
- configUiStartupCancelled = true;
551
- void closeConfigUiService();
552
- };
553
- },
554
- };
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 = "openclaw";
29
+
30
+ function warnMissingApiKey(log, context) {
31
+ const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)";
32
+ const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`;
33
+ log.warn?.(
34
+ [
35
+ header,
36
+ "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc",
37
+ "source ~/.zshrc",
38
+ "or",
39
+ "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc",
40
+ "source ~/.bashrc",
41
+ "or",
42
+ "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")",
43
+ `Get API key: ${API_KEY_HELP_URL}`,
44
+ ].join("\n"),
45
+ );
46
+ }
47
+
48
+ function getCounterSuffix(sessionKey) {
49
+ if (!sessionKey) return "";
50
+ const current = conversationCounters.get(sessionKey) ?? 0;
51
+ return current > 0 ? `#${current}` : "";
52
+ }
53
+
54
+ function bumpConversationCounter(sessionKey) {
55
+ if (!sessionKey) return;
56
+ const current = conversationCounters.get(sessionKey) ?? 0;
57
+ conversationCounters.set(sessionKey, current + 1);
58
+ }
59
+
60
+ function getEffectiveAgentId(cfg, ctx) {
61
+ if (!cfg.multiAgentMode) {
62
+ return cfg.agentId;
63
+ }
64
+ const agentId = ctx?.agentId || cfg.agentId;
65
+ return agentId === "main" ? undefined : agentId;
66
+ }
67
+
68
+ export function extractDirectSessionUserId(sessionKey) {
69
+ if (!sessionKey || typeof sessionKey !== "string") return "";
70
+ const parts = sessionKey.split(":");
71
+ const directIndex = parts.lastIndexOf("direct");
72
+ if (directIndex === -1) return "";
73
+ return parts[directIndex + 1] || "";
74
+ }
75
+
76
+ export function resolveMemosUserId(cfg, ctx) {
77
+ const fallback = cfg?.userId || "openclaw-user";
78
+ if (!cfg?.useDirectSessionUserId) return fallback;
79
+ const directUserId = extractDirectSessionUserId(ctx?.sessionKey);
80
+ return directUserId || fallback;
81
+ }
82
+
83
+ function resolveConversationId(cfg, ctx) {
84
+ if (cfg.conversationId) return cfg.conversationId;
85
+ // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId).
86
+ const agentId = getEffectiveAgentId(cfg, ctx);
87
+ const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : "");
88
+ const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : "";
89
+ const prefix = cfg.conversationIdPrefix || "";
90
+ const suffix = cfg.conversationIdSuffix || "";
91
+ if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`;
92
+ return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`;
93
+ }
94
+
95
+ export function buildSearchPayload(cfg, prompt, ctx) {
96
+ const cleanPrompt = stripOpenClawInjectedPrefix(prompt);
97
+ const queryRaw = `${cfg.queryPrefix || ""}${cleanPrompt}`;
98
+ const query =
99
+ Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0
100
+ ? queryRaw.slice(0, cfg.maxQueryChars)
101
+ : queryRaw;
102
+
103
+ const payload = {
104
+ user_id: resolveMemosUserId(cfg, ctx),
105
+ query,
106
+ source: MEMOS_SOURCE,
107
+ };
108
+
109
+ if (!cfg.recallGlobal) {
110
+ const conversationId = resolveConversationId(cfg, ctx);
111
+ if (conversationId) payload.conversation_id = conversationId;
112
+ }
113
+
114
+ let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null;
115
+ const agentId = getEffectiveAgentId(cfg, ctx);
116
+
117
+ // Check if the filter is already in the categorized format (filter1)
118
+ const isCategorized = filterObj && (filterObj.user !== undefined || filterObj.knowledgebase !== undefined || filterObj.public !== undefined);
119
+ let userFilter = isCategorized ? (filterObj.user || null) : filterObj;
120
+
121
+ if (agentId) {
122
+ if (userFilter && Object.keys(userFilter).length > 0) {
123
+ if (Array.isArray(userFilter.and)) {
124
+ userFilter.and.push({ agent_id: agentId });
125
+ } else {
126
+ userFilter = { and: [userFilter, { agent_id: agentId }] };
127
+ }
128
+ } else {
129
+ userFilter = { and: [{ agent_id: agentId }] };
130
+ }
131
+ }
132
+
133
+ if (isCategorized) {
134
+ if (userFilter && Object.keys(userFilter).length > 0) filterObj.user = userFilter;
135
+ if (Object.keys(filterObj).length > 0) payload.filter = filterObj;
136
+ } else if (userFilter && Object.keys(userFilter).length > 0) {
137
+ // If not categorized, wrap it in 'user' so knowledgebase is not filtered
138
+ payload.filter = { user: userFilter };
139
+ }
140
+
141
+ if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds;
142
+
143
+ payload.memory_limit_number = cfg.memoryLimitNumber;
144
+ payload.include_preference = cfg.includePreference;
145
+ payload.preference_limit_number = cfg.preferenceLimitNumber;
146
+ payload.include_tool_memory = cfg.includeToolMemory;
147
+ payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber;
148
+ payload.relativity = cfg.relativity;
149
+
150
+ return payload;
151
+ }
152
+
153
+ export function buildAddMessagePayload(cfg, messages, ctx) {
154
+ const payload = {
155
+ user_id: resolveMemosUserId(cfg, ctx),
156
+ conversation_id: resolveConversationId(cfg, ctx),
157
+ messages,
158
+ source: MEMOS_SOURCE,
159
+ };
160
+
161
+ const agentId = getEffectiveAgentId(cfg, ctx);
162
+ if (agentId) payload.agent_id = agentId;
163
+ if (cfg.appId) payload.app_id = cfg.appId;
164
+ if (cfg.tags?.length) payload.tags = cfg.tags;
165
+
166
+ const info = {
167
+ source: "openclaw",
168
+ sessionKey: ctx?.sessionKey,
169
+ agentId: ctx?.agentId,
170
+ ...(cfg.info || {}),
171
+ };
172
+ if (Object.keys(info).length > 0) payload.info = info;
173
+
174
+ payload.allow_public = cfg.allowPublic;
175
+ if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds;
176
+ payload.async_mode = cfg.asyncMode;
177
+
178
+ return payload;
179
+ }
180
+
181
+ function pickLastTurnMessages(messages, cfg) {
182
+ const lastUserIndex = messages
183
+ .map((m, idx) => ({ m, idx }))
184
+ .filter(({ m }) => m?.role === "user")
185
+ .map(({ idx }) => idx)
186
+ .pop();
187
+
188
+ if (lastUserIndex === undefined) return [];
189
+
190
+ const slice = messages.slice(lastUserIndex);
191
+ const results = [];
192
+
193
+ for (const msg of slice) {
194
+ if (!msg || !msg.role) continue;
195
+ if (msg.role === "user") {
196
+ const content = stripOpenClawInjectedPrefix(extractText(msg.content));
197
+ if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
198
+ continue;
199
+ }
200
+ if (msg.role === "assistant" && cfg.includeAssistant) {
201
+ const content = extractText(msg.content);
202
+ if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
203
+ }
204
+ }
205
+
206
+ return results;
207
+ }
208
+
209
+ function pickFullSessionMessages(messages, cfg) {
210
+ const results = [];
211
+ for (const msg of messages) {
212
+ if (!msg || !msg.role) continue;
213
+ if (msg.role === "user") {
214
+ const content = stripOpenClawInjectedPrefix(extractText(msg.content));
215
+ if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
216
+ }
217
+ if (msg.role === "assistant" && cfg.includeAssistant) {
218
+ const content = extractText(msg.content);
219
+ if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
220
+ }
221
+ }
222
+ return results;
223
+ }
224
+
225
+ function truncate(text, maxLen) {
226
+ if (!text) return "";
227
+ if (!maxLen) return text;
228
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
229
+ }
230
+
231
+ function sleep(ms) {
232
+ return new Promise((resolve) => setTimeout(resolve, ms));
233
+ }
234
+
235
+ function parseModelJson(text) {
236
+ if (!text || typeof text !== "string") return null;
237
+ const trimmed = text.trim();
238
+ if (!trimmed) return null;
239
+ try {
240
+ return JSON.parse(trimmed);
241
+ } catch {
242
+ // Some models wrap JSON in markdown code fences.
243
+ }
244
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
245
+ if (fenceMatch?.[1]) {
246
+ try {
247
+ return JSON.parse(fenceMatch[1].trim());
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+ const first = trimmed.indexOf("{");
253
+ const last = trimmed.lastIndexOf("}");
254
+ if (first >= 0 && last > first) {
255
+ try {
256
+ return JSON.parse(trimmed.slice(first, last + 1));
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ function normalizeIndexList(value, maxLen) {
265
+ if (!Array.isArray(value)) return [];
266
+ const seen = new Set();
267
+ const out = [];
268
+ for (const v of value) {
269
+ if (!Number.isInteger(v)) continue;
270
+ if (v < 0 || v >= maxLen) continue;
271
+ if (seen.has(v)) continue;
272
+ seen.add(v);
273
+ out.push(v);
274
+ }
275
+ return out;
276
+ }
277
+
278
+ function buildRecallCandidates(data, cfg) {
279
+ const limit = Number.isFinite(cfg.recallFilterCandidateLimit) ? Math.max(0, cfg.recallFilterCandidateLimit) : 30;
280
+ const maxChars = Number.isFinite(cfg.recallFilterMaxItemChars) ? Math.max(80, cfg.recallFilterMaxItemChars) : 500;
281
+ const memoryList = Array.isArray(data?.memory_detail_list) ? data.memory_detail_list : [];
282
+ const preferenceList = Array.isArray(data?.preference_detail_list) ? data.preference_detail_list : [];
283
+ const toolList = Array.isArray(data?.tool_memory_detail_list) ? data.tool_memory_detail_list : [];
284
+
285
+ const memoryCandidates = memoryList.slice(0, limit).map((item, idx) => ({
286
+ idx,
287
+ text: truncate(item?.memory_value || item?.memory_key || "", maxChars),
288
+ relativity: item?.relativity,
289
+ }));
290
+ const preferenceCandidates = preferenceList.slice(0, limit).map((item, idx) => ({
291
+ idx,
292
+ text: truncate(item?.preference || "", maxChars),
293
+ relativity: item?.relativity,
294
+ preference_type: item?.preference_type || "",
295
+ }));
296
+ const toolCandidates = toolList.slice(0, limit).map((item, idx) => ({
297
+ idx,
298
+ text: truncate(item?.tool_value || "", maxChars),
299
+ relativity: item?.relativity,
300
+ }));
301
+
302
+ return {
303
+ memoryList,
304
+ preferenceList,
305
+ toolList,
306
+ candidatePayload: {
307
+ memory: memoryCandidates,
308
+ preference: preferenceCandidates,
309
+ tool_memory: toolCandidates,
310
+ },
311
+ };
312
+ }
313
+
314
+ function applyRecallDecision(data, decision, lists) {
315
+ const keep = decision?.keep || {};
316
+ const memoryIdx = normalizeIndexList(keep.memory, lists.memoryList.length);
317
+ const preferenceIdx = normalizeIndexList(keep.preference, lists.preferenceList.length);
318
+ const toolIdx = normalizeIndexList(keep.tool_memory, lists.toolList.length);
319
+
320
+ return {
321
+ ...data,
322
+ memory_detail_list: memoryIdx.map((idx) => lists.memoryList[idx]),
323
+ preference_detail_list: preferenceIdx.map((idx) => lists.preferenceList[idx]),
324
+ tool_memory_detail_list: toolIdx.map((idx) => lists.toolList[idx]),
325
+ };
326
+ }
327
+
328
+ async function callRecallFilterModel(cfg, userPrompt, candidatePayload) {
329
+ const headers = {
330
+ "Content-Type": "application/json",
331
+ };
332
+ if (cfg.recallFilterApiKey) {
333
+ headers.Authorization = `Bearer ${cfg.recallFilterApiKey}`;
334
+ }
335
+
336
+ const modelInput = {
337
+ user_query: userPrompt,
338
+ candidate_memories: candidatePayload,
339
+ output_schema: {
340
+ keep: {
341
+ memory: ["number index"],
342
+ preference: ["number index"],
343
+ tool_memory: ["number index"],
344
+ },
345
+ reason: "optional short string",
346
+ },
347
+ };
348
+
349
+ const body = {
350
+ model: cfg.recallFilterModel,
351
+ temperature: 0,
352
+ messages: [
353
+ {
354
+ role: "system",
355
+ content:
356
+ "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.",
357
+ },
358
+ {
359
+ role: "user",
360
+ content: JSON.stringify(modelInput),
361
+ },
362
+ ],
363
+ };
364
+
365
+ let lastError;
366
+ const retries = Number.isFinite(cfg.recallFilterRetries) ? Math.max(0, cfg.recallFilterRetries) : 1;
367
+ const timeoutMs = Number.isFinite(cfg.recallFilterTimeoutMs) ? Math.max(1000, cfg.recallFilterTimeoutMs) : 30000;
368
+
369
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
370
+ let timeoutId;
371
+ try {
372
+ const controller = new AbortController();
373
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
374
+ const res = await fetch(`${cfg.recallFilterBaseUrl}/chat/completions`, {
375
+ method: "POST",
376
+ headers,
377
+ body: JSON.stringify(body),
378
+ signal: controller.signal,
379
+ });
380
+ if (!res.ok) {
381
+ throw new Error(`HTTP ${res.status}`);
382
+ }
383
+ const json = await res.json();
384
+ const text = json?.choices?.[0]?.message?.content || "";
385
+ const parsed = parseModelJson(text);
386
+ if (!parsed || typeof parsed !== "object") {
387
+ throw new Error("invalid JSON output from recall filter model");
388
+ }
389
+ return parsed;
390
+ } catch (err) {
391
+ const isAbort = err?.name === "AbortError" || /aborted/i.test(String(err?.message ?? err));
392
+ lastError = isAbort
393
+ ? new Error(
394
+ `timed out after ${timeoutMs}ms (raise recallFilterTimeoutMs; local LLMs often need 30s+ on cold start)`,
395
+ )
396
+ : err;
397
+ if (attempt < retries) {
398
+ await sleep(120 * (attempt + 1));
399
+ }
400
+ } finally {
401
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
402
+ }
403
+ }
404
+ throw lastError;
405
+ }
406
+
407
+ async function maybeFilterRecallData(cfg, data, userPrompt, log, ctx) {
408
+ if (!cfg.recallFilterEnabled) return data;
409
+ if (!cfg.recallFilterBaseUrl || !cfg.recallFilterModel) {
410
+ log.warn?.("[memos-cloud] recall filter enabled but missing recallFilterBaseUrl/recallFilterModel; skip filter");
411
+ return data;
412
+ }
413
+ const lists = buildRecallCandidates(data, cfg);
414
+ const hasCandidates =
415
+ lists.candidatePayload.memory.length > 0 ||
416
+ lists.candidatePayload.preference.length > 0 ||
417
+ lists.candidatePayload.tool_memory.length > 0;
418
+ if (!hasCandidates) return data;
419
+
420
+ try {
421
+ reportRumEvent("recall_filter", { recall_filter_enable: cfg.recallFilterEnabled }, cfg, ctx, log);
422
+ const decision = await callRecallFilterModel(cfg, userPrompt, lists.candidatePayload);
423
+ const filtered = applyRecallDecision(data, decision, lists);
424
+ log.info?.(
425
+ `[memos-cloud] recall filter applied: memory ${lists.memoryList.length}->${filtered.memory_detail_list?.length ?? 0}, ` +
426
+ `preference ${lists.preferenceList.length}->${filtered.preference_detail_list?.length ?? 0}, ` +
427
+ `tool_memory ${lists.toolList.length}->${filtered.tool_memory_detail_list?.length ?? 0}`,
428
+ );
429
+ return filtered;
430
+ } catch (err) {
431
+ log.warn?.(`[memos-cloud] recall filter failed: ${String(err)}`);
432
+ return cfg.recallFilterFailOpen ? data : { ...data, memory_detail_list: [], preference_detail_list: [], tool_memory_detail_list: [] };
433
+ }
434
+ }
435
+
436
+ export default {
437
+ id: "memos-cloud-openclaw-plugin",
438
+ name: "MemOS Cloud OpenClaw Plugin",
439
+ description: "MemOS Cloud recall + add memory via lifecycle hooks",
440
+ kind: "lifecycle",
441
+
442
+ register(api) {
443
+ const cfg = buildConfig(api.pluginConfig);
444
+ const log = api.logger ?? console;
445
+ let configUiStartupCancelled = false;
446
+
447
+ // Start 12-hour background update interval
448
+ startUpdateChecker(log);
449
+
450
+ // Side effects below are only meaningful when the host CLI was actually
451
+ // launched to run the gateway (`openclaw gateway run|start|restart`).
452
+ // Other entry points (e.g. `plugins install`, `security audit`) also
453
+ // load this plugin to inspect/register it, but:
454
+ // - `ensurePluginHookPolicy` writes to `openclaw.json` and would race
455
+ // against the install command's own commit (ConfigMutationConflictError).
456
+ // - `waitForGatewayReady` would keep the short-lived event loop alive
457
+ // for 45s probing a gateway that will never come up, then emit a
458
+ // misleading "probe timed out" warning before the process exits.
459
+ // Gate them all in one place so the policy is explicit and discoverable.
460
+ if (isGatewayRuntimeStartup()) {
461
+ // Detect the host CLI version once so every branch below can reference it.
462
+ // `allowConversationAccess` hook policy was introduced in 2026.4.23;
463
+ // older hosts do not understand the field and don't need it patched in.
464
+ const hostVersion = detectHostVersion();
465
+
466
+ const HOOK_POLICY_MIN_VERSION = "2026.4.23";
467
+ const needsHookPolicy =
468
+ hostVersion === null ||
469
+ compareVersionStrings(hostVersion, HOOK_POLICY_MIN_VERSION) >= 0;
470
+
471
+ if (needsHookPolicy) {
472
+ // Ensure the gateway grants this plugin the typed-hook policies it
473
+ // needs (e.g. `allowConversationAccess` for `agent_end`). When a patch
474
+ // is applied the function prints its own eye-catching banner asking
475
+ // the user to restart; here we only surface unexpected errors.
476
+ try {
477
+ const policyResult = ensurePluginHookPolicy(api.config, log);
478
+ if (policyResult?.error) {
479
+ log.warn?.(
480
+ `[memos-cloud] hook policy check skipped due to error: ${String(policyResult.error?.message ?? policyResult.error)}`,
481
+ );
482
+ }
483
+ } catch (error) {
484
+ log.warn?.(
485
+ `[memos-cloud] failed to ensure plugin hook policy: ${String(error?.message ?? error)}`,
486
+ );
487
+ }
488
+ }
489
+
490
+ void (async () => {
491
+ const ready = await waitForGatewayReady(api.config, log);
492
+ if (!ready || configUiStartupCancelled) return;
493
+ await ensureConfigUiService(log);
494
+ })().catch((error) => {
495
+ log.warn?.(`[memos-cloud] config UI failed to start: ${String(error)}`);
496
+ });
497
+ }
498
+
499
+ if (!cfg.envFileStatus?.found) {
500
+ const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", ");
501
+ log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`);
502
+ }
503
+
504
+ if (cfg.multiAgentMode && cfg.allowedAgents?.length > 0) {
505
+ log.info?.(`[memos-cloud] Multi-agent mode enabled. Allowed agents: [${cfg.allowedAgents.join(", ")}]`);
506
+ }
507
+
508
+ const overrideAgentIds = Object.keys(cfg._agentOverrides || {});
509
+ if (overrideAgentIds.length > 0) {
510
+ log.info?.(`[memos-cloud] Per-agent overrides configured for: [${overrideAgentIds.join(", ")}]`);
511
+ }
512
+
513
+ if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) {
514
+ if (api.config?.hooks?.internal?.enabled !== true) {
515
+ log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true");
516
+ }
517
+ api.registerHook(
518
+ ["command:new"],
519
+ (event) => {
520
+ if (event?.type === "command" && event?.action === "new") {
521
+ bumpConversationCounter(event.sessionKey);
522
+ }
523
+ },
524
+ {
525
+ name: "memos-cloud-conversation-new",
526
+ description: "Increment MemOS conversation suffix on /new",
527
+ },
528
+ );
529
+ }
530
+
531
+ api.on("before_agent_start", async (event, ctx) => {
532
+ if (!isAgentAllowed(cfg, ctx)) {
533
+ log.info?.(`[memos-cloud] recall skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
534
+ return;
535
+ }
536
+ const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
537
+ if (!agentCfg.recallEnabled) return;
538
+ const userPrompt = stripOpenClawInjectedPrefix(event?.prompt || "");
539
+ if (!userPrompt || userPrompt.length < 3) return;
540
+ if (!agentCfg.apiKey) {
541
+ warnMissingApiKey(log, "recall");
542
+ return;
543
+ }
544
+
545
+ try {
546
+ const payload = buildSearchPayload(agentCfg, userPrompt, ctx);
547
+ reportRumEvent('search_memory', payload, agentCfg, ctx, log);
548
+ const result = await searchMemory(agentCfg, payload);
549
+ const resultData = extractResultData(result);
550
+ if (!resultData) return;
551
+ const filteredData = await maybeFilterRecallData(agentCfg, resultData, userPrompt, log, ctx);
552
+ const hookResult = formatRecallHookResult({ data: filteredData }, {
553
+ wrapTagBlocks: true,
554
+ relativity: payload.relativity,
555
+ maxItemChars: agentCfg.maxItemChars,
556
+ });
557
+ if (!hookResult.appendSystemContext && !hookResult.prependContext) return;
558
+
559
+ return hookResult;
560
+ } catch (err) {
561
+ log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
562
+ }
563
+ });
564
+
565
+ api.on("agent_end", async (event, ctx) => {
566
+ if (!isAgentAllowed(cfg, ctx)) {
567
+ log.info?.(`[memos-cloud] add skipped: agent "${ctx?.agentId}" not in allowedAgents [${cfg.allowedAgents?.join(", ")}]`);
568
+ return;
569
+ }
570
+ const agentCfg = resolveAgentConfig(cfg, ctx?.agentId);
571
+ if (!agentCfg.addEnabled) return;
572
+ if (!event?.success || !event?.messages?.length) return;
573
+ if (!agentCfg.apiKey) {
574
+ warnMissingApiKey(log, "add");
575
+ return;
576
+ }
577
+
578
+ const now = Date.now();
579
+ if (agentCfg.throttleMs && now - lastCaptureTime < agentCfg.throttleMs) {
580
+ return;
581
+ }
582
+ lastCaptureTime = now;
583
+
584
+ try {
585
+ const messages =
586
+ agentCfg.captureStrategy === "full_session"
587
+ ? pickFullSessionMessages(event.messages, agentCfg)
588
+ : pickLastTurnMessages(event.messages, agentCfg);
589
+
590
+ if (!messages.length) return;
591
+
592
+ const payload = buildAddMessagePayload(agentCfg, messages, ctx);
593
+ await addMessage(agentCfg, payload);
594
+ } catch (err) {
595
+ log.warn?.(`[memos-cloud] add failed: ${String(err)}`);
596
+ }
597
+ });
598
+
599
+ return () => {
600
+ configUiStartupCancelled = true;
601
+ void closeConfigUiService();
602
+ };
603
+ },
604
+ };