@memtensor/memos-cloud-openclaw-plugin 0.1.8 → 0.1.9

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,285 +1,483 @@
1
- #!/usr/bin/env node
2
- import {
3
- addMessage,
4
- buildConfig,
5
- extractText,
6
- formatPromptBlock,
7
- USER_QUERY_MARKER,
8
- searchMemory,
9
- } from "./lib/memos-cloud-api.js";
10
- let lastCaptureTime = 0;
11
- const conversationCounters = new Map();
12
- const API_KEY_HELP_URL = "https://memos-dashboard.openmem.net/cn/apikeys/";
13
- const ENV_FILE_SEARCH_HINTS = ["~/.openclaw/.env", "~/.moltbot/.env", "~/.clawdbot/.env"];
14
- const MEMOS_SOURCE = "openclaw";
15
-
16
- function warnMissingApiKey(log, context) {
17
- const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)";
18
- const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`;
19
- log.warn?.(
20
- [
21
- header,
22
- "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc",
23
- "source ~/.zshrc",
24
- "or",
25
- "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc",
26
- "source ~/.bashrc",
27
- "or",
28
- "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")",
29
- `Get API key: ${API_KEY_HELP_URL}`,
30
- ].join("\n"),
31
- );
32
- }
33
-
34
- function stripPrependedPrompt(content) {
35
- if (!content) return content;
36
- const idx = content.lastIndexOf(USER_QUERY_MARKER);
37
- if (idx === -1) return content;
38
- return content.slice(idx + USER_QUERY_MARKER.length).trimStart();
39
- }
40
-
41
- function getCounterSuffix(sessionKey) {
42
- if (!sessionKey) return "";
43
- const current = conversationCounters.get(sessionKey) ?? 0;
44
- return current > 0 ? `#${current}` : "";
45
- }
46
-
47
- function bumpConversationCounter(sessionKey) {
48
- if (!sessionKey) return;
49
- const current = conversationCounters.get(sessionKey) ?? 0;
50
- conversationCounters.set(sessionKey, current + 1);
51
- }
52
-
53
- function getEffectiveAgentId(cfg, ctx) {
54
- if (!cfg.multiAgentMode) {
55
- return cfg.agentId;
56
- }
57
- const agentId = ctx?.agentId || cfg.agentId;
58
- return agentId === "main" ? undefined : agentId;
59
- }
60
-
61
- function resolveConversationId(cfg, ctx) {
62
- if (cfg.conversationId) return cfg.conversationId;
63
- // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId).
64
- const agentId = getEffectiveAgentId(cfg, ctx);
65
- const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : "");
66
- const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : "";
67
- const prefix = cfg.conversationIdPrefix || "";
68
- const suffix = cfg.conversationIdSuffix || "";
69
- if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`;
70
- return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`;
71
- }
72
-
73
- function buildSearchPayload(cfg, prompt, ctx) {
74
- const queryRaw = `${cfg.queryPrefix || ""}${prompt}`;
75
- const query =
76
- Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0
77
- ? queryRaw.slice(0, cfg.maxQueryChars)
78
- : queryRaw;
79
-
80
- const payload = {
81
- user_id: cfg.userId,
82
- query,
83
- source: MEMOS_SOURCE,
84
- };
85
-
86
- if (!cfg.recallGlobal) {
87
- const conversationId = resolveConversationId(cfg, ctx);
88
- if (conversationId) payload.conversation_id = conversationId;
89
- }
90
-
91
- let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null;
92
- const agentId = getEffectiveAgentId(cfg, ctx);
93
-
94
- if (agentId) {
95
- if (filterObj) {
96
- if (Array.isArray(filterObj.and)) {
97
- filterObj.and.push({ agent_id: agentId });
98
- } else {
99
- filterObj = { and: [filterObj, { agent_id: agentId }] };
100
- }
101
- } else {
102
- filterObj = { agent_id: agentId };
103
- }
104
- }
105
-
106
- if (filterObj) payload.filter = filterObj;
107
-
108
- if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds;
109
-
110
- payload.memory_limit_number = cfg.memoryLimitNumber;
111
- payload.include_preference = cfg.includePreference;
112
- payload.preference_limit_number = cfg.preferenceLimitNumber;
113
- payload.include_tool_memory = cfg.includeToolMemory;
114
- payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber;
115
- payload.relativity = cfg.relativity;
116
-
117
- return payload;
118
- }
119
-
120
- function buildAddMessagePayload(cfg, messages, ctx) {
121
- const payload = {
122
- user_id: cfg.userId,
123
- conversation_id: resolveConversationId(cfg, ctx),
124
- messages,
125
- source: MEMOS_SOURCE,
126
- };
127
-
128
- const agentId = getEffectiveAgentId(cfg, ctx);
129
- if (agentId) payload.agent_id = agentId;
130
- if (cfg.appId) payload.app_id = cfg.appId;
131
- if (cfg.tags?.length) payload.tags = cfg.tags;
132
-
133
- const info = {
134
- source: "openclaw",
135
- sessionKey: ctx?.sessionKey,
136
- agentId: ctx?.agentId,
137
- ...(cfg.info || {}),
138
- };
139
- if (Object.keys(info).length > 0) payload.info = info;
140
-
141
- payload.allow_public = cfg.allowPublic;
142
- if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds;
143
- payload.async_mode = cfg.asyncMode;
144
-
145
- return payload;
146
- }
147
-
148
- function pickLastTurnMessages(messages, cfg) {
149
- const lastUserIndex = messages
150
- .map((m, idx) => ({ m, idx }))
151
- .filter(({ m }) => m?.role === "user")
152
- .map(({ idx }) => idx)
153
- .pop();
154
-
155
- if (lastUserIndex === undefined) return [];
156
-
157
- const slice = messages.slice(lastUserIndex);
158
- const results = [];
159
-
160
- for (const msg of slice) {
161
- if (!msg || !msg.role) continue;
162
- if (msg.role === "user") {
163
- const content = stripPrependedPrompt(extractText(msg.content));
164
- if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
165
- continue;
166
- }
167
- if (msg.role === "assistant" && cfg.includeAssistant) {
168
- const content = extractText(msg.content);
169
- if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
170
- }
171
- }
172
-
173
- return results;
174
- }
175
-
176
- function pickFullSessionMessages(messages, cfg) {
177
- const results = [];
178
- for (const msg of messages) {
179
- if (!msg || !msg.role) continue;
180
- if (msg.role === "user") {
181
- const content = stripPrependedPrompt(extractText(msg.content));
182
- if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
183
- }
184
- if (msg.role === "assistant" && cfg.includeAssistant) {
185
- const content = extractText(msg.content);
186
- if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
187
- }
188
- }
189
- return results;
190
- }
191
-
192
- function truncate(text, maxLen) {
193
- if (!text) return "";
194
- if (!maxLen) return text;
195
- return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
196
- }
197
-
198
- export default {
199
- id: "memos-cloud-openclaw-plugin",
200
- name: "MemOS Cloud OpenClaw Plugin",
201
- description: "MemOS Cloud recall + add memory via lifecycle hooks",
202
- kind: "lifecycle",
203
-
204
- register(api) {
205
- const cfg = buildConfig(api.pluginConfig);
206
- const log = api.logger ?? console;
207
-
208
- if (!cfg.envFileStatus?.found) {
209
- const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", ");
210
- log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`);
211
- }
212
-
213
- if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) {
214
- if (api.config?.hooks?.internal?.enabled !== true) {
215
- log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true");
216
- }
217
- api.registerHook(
218
- ["command:new"],
219
- (event) => {
220
- if (event?.type === "command" && event?.action === "new") {
221
- bumpConversationCounter(event.sessionKey);
222
- }
223
- },
224
- {
225
- name: "memos-cloud-conversation-new",
226
- description: "Increment MemOS conversation suffix on /new",
227
- },
228
- );
229
- }
230
-
231
- api.on("before_agent_start", async (event, ctx) => {
232
- if (!cfg.recallEnabled) return;
233
- if (!event?.prompt || event.prompt.length < 3) return;
234
- if (!cfg.apiKey) {
235
- warnMissingApiKey(log, "recall");
236
- return;
237
- }
238
-
239
- try {
240
- const payload = buildSearchPayload(cfg, event.prompt, ctx);
241
- const result = await searchMemory(cfg, payload);
242
- const promptBlock = formatPromptBlock(result, {
243
- wrapTagBlocks: true,
244
- relativity: payload.relativity
245
- });
246
- if (!promptBlock) return;
247
-
248
- return {
249
- prependContext: promptBlock,
250
- };
251
- } catch (err) {
252
- log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
253
- }
254
- });
255
-
256
- api.on("agent_end", async (event, ctx) => {
257
- if (!cfg.addEnabled) return;
258
- if (!event?.success || !event?.messages?.length) return;
259
- if (!cfg.apiKey) {
260
- warnMissingApiKey(log, "add");
261
- return;
262
- }
263
-
264
- const now = Date.now();
265
- if (cfg.throttleMs && now - lastCaptureTime < cfg.throttleMs) {
266
- return;
267
- }
268
- lastCaptureTime = now;
269
-
270
- try {
271
- const messages =
272
- cfg.captureStrategy === "full_session"
273
- ? pickFullSessionMessages(event.messages, cfg)
274
- : pickLastTurnMessages(event.messages, cfg);
275
-
276
- if (!messages.length) return;
277
-
278
- const payload = buildAddMessagePayload(cfg, messages, ctx);
279
- await addMessage(cfg, payload);
280
- } catch (err) {
281
- log.warn?.(`[memos-cloud] add failed: ${String(err)}`);
282
- }
283
- });
284
- },
285
- };
1
+ #!/usr/bin/env node
2
+ import {
3
+ addMessage,
4
+ buildConfig,
5
+ extractResultData,
6
+ extractText,
7
+ formatRecallHookResult,
8
+ USER_QUERY_MARKER,
9
+ searchMemory,
10
+ } from "./lib/memos-cloud-api.js";
11
+ import { startUpdateChecker } from "./lib/check-update.js";
12
+ let lastCaptureTime = 0;
13
+ const conversationCounters = new Map();
14
+ const API_KEY_HELP_URL = "https://memos-dashboard.openmem.net/cn/apikeys/";
15
+ const ENV_FILE_SEARCH_HINTS = ["~/.openclaw/.env", "~/.moltbot/.env", "~/.clawdbot/.env"];
16
+ const MEMOS_SOURCE = "openclaw";
17
+
18
+ function warnMissingApiKey(log, context) {
19
+ const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)";
20
+ const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`;
21
+ log.warn?.(
22
+ [
23
+ header,
24
+ "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc",
25
+ "source ~/.zshrc",
26
+ "or",
27
+ "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc",
28
+ "source ~/.bashrc",
29
+ "or",
30
+ "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")",
31
+ `Get API key: ${API_KEY_HELP_URL}`,
32
+ ].join("\n"),
33
+ );
34
+ }
35
+
36
+ function stripPrependedPrompt(content) {
37
+ if (!content) return content;
38
+ const idx = content.lastIndexOf(USER_QUERY_MARKER);
39
+ if (idx === -1) return content;
40
+ return content.slice(idx + USER_QUERY_MARKER.length).trimStart();
41
+ }
42
+
43
+ function getCounterSuffix(sessionKey) {
44
+ if (!sessionKey) return "";
45
+ const current = conversationCounters.get(sessionKey) ?? 0;
46
+ return current > 0 ? `#${current}` : "";
47
+ }
48
+
49
+ function bumpConversationCounter(sessionKey) {
50
+ if (!sessionKey) return;
51
+ const current = conversationCounters.get(sessionKey) ?? 0;
52
+ conversationCounters.set(sessionKey, current + 1);
53
+ }
54
+
55
+ function getEffectiveAgentId(cfg, ctx) {
56
+ if (!cfg.multiAgentMode) {
57
+ return cfg.agentId;
58
+ }
59
+ const agentId = ctx?.agentId || cfg.agentId;
60
+ return agentId === "main" ? undefined : agentId;
61
+ }
62
+
63
+ function resolveConversationId(cfg, ctx) {
64
+ if (cfg.conversationId) return cfg.conversationId;
65
+ // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId).
66
+ const agentId = getEffectiveAgentId(cfg, ctx);
67
+ const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : "");
68
+ const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : "";
69
+ const prefix = cfg.conversationIdPrefix || "";
70
+ const suffix = cfg.conversationIdSuffix || "";
71
+ if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`;
72
+ return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`;
73
+ }
74
+
75
+ function buildSearchPayload(cfg, prompt, ctx) {
76
+ const queryRaw = `${cfg.queryPrefix || ""}${prompt}`;
77
+ const query =
78
+ Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0
79
+ ? queryRaw.slice(0, cfg.maxQueryChars)
80
+ : queryRaw;
81
+
82
+ const payload = {
83
+ user_id: cfg.userId,
84
+ query,
85
+ source: MEMOS_SOURCE,
86
+ };
87
+
88
+ if (!cfg.recallGlobal) {
89
+ const conversationId = resolveConversationId(cfg, ctx);
90
+ if (conversationId) payload.conversation_id = conversationId;
91
+ }
92
+
93
+ let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null;
94
+ const agentId = getEffectiveAgentId(cfg, ctx);
95
+
96
+ if (agentId) {
97
+ if (filterObj) {
98
+ if (Array.isArray(filterObj.and)) {
99
+ filterObj.and.push({ agent_id: agentId });
100
+ } else {
101
+ filterObj = { and: [filterObj, { agent_id: agentId }] };
102
+ }
103
+ } else {
104
+ filterObj = { agent_id: agentId };
105
+ }
106
+ }
107
+
108
+ if (filterObj) payload.filter = filterObj;
109
+
110
+ if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds;
111
+
112
+ payload.memory_limit_number = cfg.memoryLimitNumber;
113
+ payload.include_preference = cfg.includePreference;
114
+ payload.preference_limit_number = cfg.preferenceLimitNumber;
115
+ payload.include_tool_memory = cfg.includeToolMemory;
116
+ payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber;
117
+ payload.relativity = cfg.relativity;
118
+
119
+ return payload;
120
+ }
121
+
122
+ function buildAddMessagePayload(cfg, messages, ctx) {
123
+ const payload = {
124
+ user_id: cfg.userId,
125
+ conversation_id: resolveConversationId(cfg, ctx),
126
+ messages,
127
+ source: MEMOS_SOURCE,
128
+ };
129
+
130
+ const agentId = getEffectiveAgentId(cfg, ctx);
131
+ if (agentId) payload.agent_id = agentId;
132
+ if (cfg.appId) payload.app_id = cfg.appId;
133
+ if (cfg.tags?.length) payload.tags = cfg.tags;
134
+
135
+ const info = {
136
+ source: "openclaw",
137
+ sessionKey: ctx?.sessionKey,
138
+ agentId: ctx?.agentId,
139
+ ...(cfg.info || {}),
140
+ };
141
+ if (Object.keys(info).length > 0) payload.info = info;
142
+
143
+ payload.allow_public = cfg.allowPublic;
144
+ if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds;
145
+ payload.async_mode = cfg.asyncMode;
146
+
147
+ return payload;
148
+ }
149
+
150
+ function pickLastTurnMessages(messages, cfg) {
151
+ const lastUserIndex = messages
152
+ .map((m, idx) => ({ m, idx }))
153
+ .filter(({ m }) => m?.role === "user")
154
+ .map(({ idx }) => idx)
155
+ .pop();
156
+
157
+ if (lastUserIndex === undefined) return [];
158
+
159
+ const slice = messages.slice(lastUserIndex);
160
+ const results = [];
161
+
162
+ for (const msg of slice) {
163
+ if (!msg || !msg.role) continue;
164
+ if (msg.role === "user") {
165
+ const content = stripPrependedPrompt(extractText(msg.content));
166
+ if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
167
+ continue;
168
+ }
169
+ if (msg.role === "assistant" && cfg.includeAssistant) {
170
+ const content = extractText(msg.content);
171
+ if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
172
+ }
173
+ }
174
+
175
+ return results;
176
+ }
177
+
178
+ function pickFullSessionMessages(messages, cfg) {
179
+ const results = [];
180
+ for (const msg of messages) {
181
+ if (!msg || !msg.role) continue;
182
+ if (msg.role === "user") {
183
+ const content = stripPrependedPrompt(extractText(msg.content));
184
+ if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) });
185
+ }
186
+ if (msg.role === "assistant" && cfg.includeAssistant) {
187
+ const content = extractText(msg.content);
188
+ if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) });
189
+ }
190
+ }
191
+ return results;
192
+ }
193
+
194
+ function truncate(text, maxLen) {
195
+ if (!text) return "";
196
+ if (!maxLen) return text;
197
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
198
+ }
199
+
200
+ function sleep(ms) {
201
+ return new Promise((resolve) => setTimeout(resolve, ms));
202
+ }
203
+
204
+ function parseModelJson(text) {
205
+ if (!text || typeof text !== "string") return null;
206
+ const trimmed = text.trim();
207
+ if (!trimmed) return null;
208
+ try {
209
+ return JSON.parse(trimmed);
210
+ } catch {
211
+ // Some models wrap JSON in markdown code fences.
212
+ }
213
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
214
+ if (fenceMatch?.[1]) {
215
+ try {
216
+ return JSON.parse(fenceMatch[1].trim());
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+ const first = trimmed.indexOf("{");
222
+ const last = trimmed.lastIndexOf("}");
223
+ if (first >= 0 && last > first) {
224
+ try {
225
+ return JSON.parse(trimmed.slice(first, last + 1));
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ function normalizeIndexList(value, maxLen) {
234
+ if (!Array.isArray(value)) return [];
235
+ const seen = new Set();
236
+ const out = [];
237
+ for (const v of value) {
238
+ if (!Number.isInteger(v)) continue;
239
+ if (v < 0 || v >= maxLen) continue;
240
+ if (seen.has(v)) continue;
241
+ seen.add(v);
242
+ out.push(v);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function buildRecallCandidates(data, cfg) {
248
+ const limit = Number.isFinite(cfg.recallFilterCandidateLimit) ? Math.max(0, cfg.recallFilterCandidateLimit) : 30;
249
+ const maxChars = Number.isFinite(cfg.recallFilterMaxItemChars) ? Math.max(80, cfg.recallFilterMaxItemChars) : 500;
250
+ const memoryList = Array.isArray(data?.memory_detail_list) ? data.memory_detail_list : [];
251
+ const preferenceList = Array.isArray(data?.preference_detail_list) ? data.preference_detail_list : [];
252
+ const toolList = Array.isArray(data?.tool_memory_detail_list) ? data.tool_memory_detail_list : [];
253
+
254
+ const memoryCandidates = memoryList.slice(0, limit).map((item, idx) => ({
255
+ idx,
256
+ text: truncate(item?.memory_value || item?.memory_key || "", maxChars),
257
+ relativity: item?.relativity,
258
+ }));
259
+ const preferenceCandidates = preferenceList.slice(0, limit).map((item, idx) => ({
260
+ idx,
261
+ text: truncate(item?.preference || "", maxChars),
262
+ relativity: item?.relativity,
263
+ preference_type: item?.preference_type || "",
264
+ }));
265
+ const toolCandidates = toolList.slice(0, limit).map((item, idx) => ({
266
+ idx,
267
+ text: truncate(item?.tool_value || "", maxChars),
268
+ relativity: item?.relativity,
269
+ }));
270
+
271
+ return {
272
+ memoryList,
273
+ preferenceList,
274
+ toolList,
275
+ candidatePayload: {
276
+ memory: memoryCandidates,
277
+ preference: preferenceCandidates,
278
+ tool_memory: toolCandidates,
279
+ },
280
+ };
281
+ }
282
+
283
+ function applyRecallDecision(data, decision, lists) {
284
+ const keep = decision?.keep || {};
285
+ const memoryIdx = normalizeIndexList(keep.memory, lists.memoryList.length);
286
+ const preferenceIdx = normalizeIndexList(keep.preference, lists.preferenceList.length);
287
+ const toolIdx = normalizeIndexList(keep.tool_memory, lists.toolList.length);
288
+
289
+ return {
290
+ ...data,
291
+ memory_detail_list: memoryIdx.map((idx) => lists.memoryList[idx]),
292
+ preference_detail_list: preferenceIdx.map((idx) => lists.preferenceList[idx]),
293
+ tool_memory_detail_list: toolIdx.map((idx) => lists.toolList[idx]),
294
+ };
295
+ }
296
+
297
+ async function callRecallFilterModel(cfg, userPrompt, candidatePayload) {
298
+ const headers = {
299
+ "Content-Type": "application/json",
300
+ };
301
+ if (cfg.recallFilterApiKey) {
302
+ headers.Authorization = `Bearer ${cfg.recallFilterApiKey}`;
303
+ }
304
+
305
+ const modelInput = {
306
+ user_query: userPrompt,
307
+ candidate_memories: candidatePayload,
308
+ output_schema: {
309
+ keep: {
310
+ memory: ["number index"],
311
+ preference: ["number index"],
312
+ tool_memory: ["number index"],
313
+ },
314
+ reason: "optional short string",
315
+ },
316
+ };
317
+
318
+ const body = {
319
+ model: cfg.recallFilterModel,
320
+ temperature: 0,
321
+ messages: [
322
+ {
323
+ role: "system",
324
+ content:
325
+ "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.",
326
+ },
327
+ {
328
+ role: "user",
329
+ content: JSON.stringify(modelInput),
330
+ },
331
+ ],
332
+ };
333
+
334
+ let lastError;
335
+ const retries = Number.isFinite(cfg.recallFilterRetries) ? Math.max(0, cfg.recallFilterRetries) : 0;
336
+ const timeoutMs = Number.isFinite(cfg.recallFilterTimeoutMs) ? Math.max(1000, cfg.recallFilterTimeoutMs) : 6000;
337
+
338
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
339
+ try {
340
+ const controller = new AbortController();
341
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
342
+ const res = await fetch(`${cfg.recallFilterBaseUrl}/chat/completions`, {
343
+ method: "POST",
344
+ headers,
345
+ body: JSON.stringify(body),
346
+ signal: controller.signal,
347
+ });
348
+ clearTimeout(timeoutId);
349
+ if (!res.ok) {
350
+ throw new Error(`HTTP ${res.status}`);
351
+ }
352
+ const json = await res.json();
353
+ const text = json?.choices?.[0]?.message?.content || "";
354
+ const parsed = parseModelJson(text);
355
+ if (!parsed || typeof parsed !== "object") {
356
+ throw new Error("invalid JSON output from recall filter model");
357
+ }
358
+ return parsed;
359
+ } catch (err) {
360
+ lastError = err;
361
+ if (attempt < retries) {
362
+ await sleep(120 * (attempt + 1));
363
+ }
364
+ }
365
+ }
366
+ throw lastError;
367
+ }
368
+
369
+ async function maybeFilterRecallData(cfg, data, userPrompt, log) {
370
+ if (!cfg.recallFilterEnabled) return data;
371
+ if (!cfg.recallFilterBaseUrl || !cfg.recallFilterModel) {
372
+ log.warn?.("[memos-cloud] recall filter enabled but missing recallFilterBaseUrl/recallFilterModel; skip filter");
373
+ return data;
374
+ }
375
+ const lists = buildRecallCandidates(data, cfg);
376
+ const hasCandidates =
377
+ lists.candidatePayload.memory.length > 0 ||
378
+ lists.candidatePayload.preference.length > 0 ||
379
+ lists.candidatePayload.tool_memory.length > 0;
380
+ if (!hasCandidates) return data;
381
+
382
+ try {
383
+ const decision = await callRecallFilterModel(cfg, userPrompt, lists.candidatePayload);
384
+ return applyRecallDecision(data, decision, lists);
385
+ } catch (err) {
386
+ log.warn?.(`[memos-cloud] recall filter failed: ${String(err)}`);
387
+ return cfg.recallFilterFailOpen ? data : { ...data, memory_detail_list: [], preference_detail_list: [], tool_memory_detail_list: [] };
388
+ }
389
+ }
390
+
391
+ export default {
392
+ id: "memos-cloud-openclaw-plugin",
393
+ name: "MemOS Cloud OpenClaw Plugin",
394
+ description: "MemOS Cloud recall + add memory via lifecycle hooks",
395
+ kind: "lifecycle",
396
+
397
+ register(api) {
398
+ const cfg = buildConfig(api.pluginConfig);
399
+ const log = api.logger ?? console;
400
+
401
+ // Start 12-hour background update interval
402
+ startUpdateChecker(log);
403
+
404
+ if (!cfg.envFileStatus?.found) {
405
+ const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", ");
406
+ log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`);
407
+ }
408
+
409
+ if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) {
410
+ if (api.config?.hooks?.internal?.enabled !== true) {
411
+ log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true");
412
+ }
413
+ api.registerHook(
414
+ ["command:new"],
415
+ (event) => {
416
+ if (event?.type === "command" && event?.action === "new") {
417
+ bumpConversationCounter(event.sessionKey);
418
+ }
419
+ },
420
+ {
421
+ name: "memos-cloud-conversation-new",
422
+ description: "Increment MemOS conversation suffix on /new",
423
+ },
424
+ );
425
+ }
426
+
427
+ api.on("before_agent_start", async (event, ctx) => {
428
+ if (!cfg.recallEnabled) return;
429
+ if (!event?.prompt || event.prompt.length < 3) return;
430
+ if (!cfg.apiKey) {
431
+ warnMissingApiKey(log, "recall");
432
+ return;
433
+ }
434
+
435
+ try {
436
+ const payload = buildSearchPayload(cfg, event.prompt, ctx);
437
+ const result = await searchMemory(cfg, payload);
438
+ const resultData = extractResultData(result);
439
+ if (!resultData) return;
440
+ const filteredData = await maybeFilterRecallData(cfg, resultData, event.prompt, log);
441
+ const hookResult = formatRecallHookResult({ data: filteredData }, {
442
+ wrapTagBlocks: true,
443
+ relativity: payload.relativity,
444
+ maxItemChars: cfg.maxItemChars,
445
+ });
446
+ if (!hookResult.appendSystemContext && !hookResult.prependContext) return;
447
+
448
+ return hookResult;
449
+ } catch (err) {
450
+ log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
451
+ }
452
+ });
453
+
454
+ api.on("agent_end", async (event, ctx) => {
455
+ if (!cfg.addEnabled) return;
456
+ if (!event?.success || !event?.messages?.length) return;
457
+ if (!cfg.apiKey) {
458
+ warnMissingApiKey(log, "add");
459
+ return;
460
+ }
461
+
462
+ const now = Date.now();
463
+ if (cfg.throttleMs && now - lastCaptureTime < cfg.throttleMs) {
464
+ return;
465
+ }
466
+ lastCaptureTime = now;
467
+
468
+ try {
469
+ const messages =
470
+ cfg.captureStrategy === "full_session"
471
+ ? pickFullSessionMessages(event.messages, cfg)
472
+ : pickLastTurnMessages(event.messages, cfg);
473
+
474
+ if (!messages.length) return;
475
+
476
+ const payload = buildAddMessagePayload(cfg, messages, ctx);
477
+ await addMessage(cfg, payload);
478
+ } catch (err) {
479
+ log.warn?.(`[memos-cloud] add failed: ${String(err)}`);
480
+ }
481
+ });
482
+ },
483
+ };