@memtensor/memos-cloud-openclaw-plugin 0.1.8-beta.9 → 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/README.md CHANGED
@@ -97,6 +97,15 @@ MEMOS_API_KEY=YOUR_TOKEN
97
97
  - `MEMOS_CONVERSATION_PREFIX` / `MEMOS_CONVERSATION_SUFFIX` (optional)
98
98
  - `MEMOS_CONVERSATION_SUFFIX_MODE` (`none` | `counter`, default: `none`)
99
99
  - `MEMOS_CONVERSATION_RESET_ON_NEW` (default: `true`, requires hooks.internal.enabled)
100
+ - `MEMOS_RECALL_FILTER_ENABLED` (default: `false`; run model-based memory filtering before injection)
101
+ - `MEMOS_RECALL_FILTER_BASE_URL` (OpenAI-compatible base URL, e.g. `http://127.0.0.1:11434/v1`)
102
+ - `MEMOS_RECALL_FILTER_API_KEY` (optional; required if your endpoint needs auth)
103
+ - `MEMOS_RECALL_FILTER_MODEL` (model name used to filter recall candidates)
104
+ - `MEMOS_RECALL_FILTER_TIMEOUT_MS` (default: `6000`)
105
+ - `MEMOS_RECALL_FILTER_RETRIES` (default: `0`)
106
+ - `MEMOS_RECALL_FILTER_CANDIDATE_LIMIT` (default: `30` per category)
107
+ - `MEMOS_RECALL_FILTER_MAX_ITEM_CHARS` (default: `500`)
108
+ - `MEMOS_RECALL_FILTER_FAIL_OPEN` (default: `true`; fallback to unfiltered recall on failure)
100
109
 
101
110
  ## Optional Plugin Config
102
111
  In `plugins.entries.memos-cloud-openclaw-plugin.config`:
@@ -127,7 +136,16 @@ In `plugins.entries.memos-cloud-openclaw-plugin.config`:
127
136
  "tags": ["openclaw"],
128
137
  "agentId": "",
129
138
  "multiAgentMode": false,
130
- "asyncMode": true
139
+ "asyncMode": true,
140
+ "recallFilterEnabled": false,
141
+ "recallFilterBaseUrl": "http://127.0.0.1:11434/v1",
142
+ "recallFilterApiKey": "",
143
+ "recallFilterModel": "qwen2.5:7b",
144
+ "recallFilterTimeoutMs": 6000,
145
+ "recallFilterRetries": 0,
146
+ "recallFilterCandidateLimit": 30,
147
+ "recallFilterMaxItemChars": 500,
148
+ "recallFilterFailOpen": true
131
149
  }
132
150
  ```
133
151
 
@@ -135,7 +153,8 @@ In `plugins.entries.memos-cloud-openclaw-plugin.config`:
135
153
  - **Recall** (`before_agent_start`)
136
154
  - Builds a `/search/memory` request using `user_id`, `query` (= prompt + optional prefix), and optional filters.
137
155
  - Default **global recall**: when `recallGlobal=true`, it does **not** pass `conversation_id`.
138
- - Formats a MemOS prompt (Role/System/Memory/Skill/Protocols) from `/search/memory` results, then injects via `prependContext`.
156
+ - Optional second-pass filtering: if `recallFilterEnabled=true`, candidates are sent to your configured model and only returned `keep` items are injected.
157
+ - Injects a stable MemOS recall protocol via `appendSystemContext`, while the retrieved `<memories>` block remains in `prependContext`.
139
158
 
140
159
  - **Add** (`agent_end`)
141
160
  - Builds a `/add/message` request with the **last turn** by default (user + assistant).
package/README_ZH.md CHANGED
@@ -99,6 +99,15 @@ MEMOS_API_KEY=YOUR_TOKEN
99
99
  - `MEMOS_CONVERSATION_PREFIX` / `MEMOS_CONVERSATION_SUFFIX`(可选)
100
100
  - `MEMOS_CONVERSATION_SUFFIX_MODE`(`none` | `counter`,默认 `none`)
101
101
  - `MEMOS_CONVERSATION_RESET_ON_NEW`(默认 `true`,需 hooks.internal.enabled)
102
+ - `MEMOS_RECALL_FILTER_ENABLED`(默认 `false`;开启后先用你指定的模型过滤召回记忆再注入)
103
+ - `MEMOS_RECALL_FILTER_BASE_URL`(OpenAI 兼容接口,例如 `http://127.0.0.1:11434/v1`)
104
+ - `MEMOS_RECALL_FILTER_API_KEY`(可选,若你的接口需要鉴权)
105
+ - `MEMOS_RECALL_FILTER_MODEL`(用于筛选记忆的模型名)
106
+ - `MEMOS_RECALL_FILTER_TIMEOUT_MS`(默认 `6000`)
107
+ - `MEMOS_RECALL_FILTER_RETRIES`(默认 `0`)
108
+ - `MEMOS_RECALL_FILTER_CANDIDATE_LIMIT`(默认每类 `30` 条)
109
+ - `MEMOS_RECALL_FILTER_MAX_ITEM_CHARS`(默认 `500`)
110
+ - `MEMOS_RECALL_FILTER_FAIL_OPEN`(默认 `true`;筛选失败时回退为“不过滤”)
102
111
 
103
112
  ## 可选插件配置
104
113
  在 `plugins.entries.memos-cloud-openclaw-plugin.config` 中设置:
@@ -127,7 +136,16 @@ MEMOS_API_KEY=YOUR_TOKEN
127
136
  "tags": ["openclaw"],
128
137
  "agentId": "",
129
138
  "multiAgentMode": false,
130
- "asyncMode": true
139
+ "asyncMode": true,
140
+ "recallFilterEnabled": false,
141
+ "recallFilterBaseUrl": "http://127.0.0.1:11434/v1",
142
+ "recallFilterApiKey": "",
143
+ "recallFilterModel": "qwen2.5:7b",
144
+ "recallFilterTimeoutMs": 6000,
145
+ "recallFilterRetries": 0,
146
+ "recallFilterCandidateLimit": 30,
147
+ "recallFilterMaxItemChars": 500,
148
+ "recallFilterFailOpen": true
131
149
  }
132
150
  ```
133
151
 
@@ -137,7 +155,8 @@ MEMOS_API_KEY=YOUR_TOKEN
137
155
  - `user_id`、`query`(= prompt + 可选前缀)
138
156
  - 默认**全局召回**:`recallGlobal=true` 时不传 `conversation_id`
139
157
  - 可选 `filter` / `knowledgebase_ids`
140
- - 使用 `/search/memory` 结果按 MemOS 提示词模板(Role/System/Memory/Skill/Protocols)拼装,并通过 `prependContext` 注入
158
+ - (可选)若开启 `recallFilterEnabled`,会先把 `memory/preference/tool_memory` 候选发给你配置的模型做二次筛选,只保留 `keep` 的条目
159
+ - 将稳定的 MemOS 召回协议通过 `appendSystemContext` 注入,而检索到的 `<memories>` 数据块继续通过 `prependContext` 注入
141
160
 
142
161
  ### 2) 添加(agent_end)
143
162
  - 默认只写**最后一轮**(user + assistant)
@@ -2,7 +2,7 @@
2
2
  "id": "memos-cloud-openclaw-plugin",
3
3
  "name": "MemOS Cloud OpenClaw Plugin",
4
4
  "description": "MemOS Cloud recall + add memory via lifecycle hooks",
5
- "version": "0.1.8-beta.8",
5
+ "version": "0.1.9",
6
6
  "kind": "lifecycle",
7
7
  "main": "./index.js",
8
8
  "configSchema": {
package/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import {
3
3
  addMessage,
4
4
  buildConfig,
5
+ extractResultData,
5
6
  extractText,
6
- formatPromptBlock,
7
+ formatRecallHookResult,
7
8
  USER_QUERY_MARKER,
8
9
  searchMemory,
9
10
  } from "./lib/memos-cloud-api.js";
@@ -196,6 +197,197 @@ function truncate(text, maxLen) {
196
197
  return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
197
198
  }
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
+
199
391
  export default {
200
392
  id: "memos-cloud-openclaw-plugin",
201
393
  name: "MemOS Cloud OpenClaw Plugin",
@@ -243,16 +435,17 @@ export default {
243
435
  try {
244
436
  const payload = buildSearchPayload(cfg, event.prompt, ctx);
245
437
  const result = await searchMemory(cfg, payload);
246
- const promptBlock = formatPromptBlock(result, {
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 }, {
247
442
  wrapTagBlocks: true,
248
443
  relativity: payload.relativity,
249
- maxItemChars: cfg.maxItemChars
444
+ maxItemChars: cfg.maxItemChars,
250
445
  });
251
- if (!promptBlock) return;
446
+ if (!hookResult.appendSystemContext && !hookResult.prependContext) return;
252
447
 
253
- return {
254
- prependContext: promptBlock,
255
- };
448
+ return hookResult;
256
449
  } catch (err) {
257
450
  log.warn?.(`[memos-cloud] recall failed: ${String(err)}`);
258
451
  }
@@ -39,36 +39,6 @@ const ANSI = {
39
39
  RED: "\x1b[31m"
40
40
  };
41
41
 
42
- function canExecuteCli(cliName, timeoutMs = 8000) {
43
- return new Promise((resolve) => {
44
- let done = false;
45
- const child = spawn(cliName, ["--version"], { shell: true });
46
- const timer = setTimeout(() => {
47
- if (done) return;
48
- done = true;
49
- killProcessTree(child);
50
- resolve({ ok: false, reason: `timeout after ${timeoutMs}ms` });
51
- }, timeoutMs);
52
-
53
- child.on("error", (err) => {
54
- if (done) return;
55
- done = true;
56
- clearTimeout(timer);
57
- resolve({ ok: false, reason: err?.message || String(err) });
58
- });
59
-
60
- child.on("close", (code) => {
61
- if (done) return;
62
- done = true;
63
- clearTimeout(timer);
64
- resolve({
65
- ok: code === 0,
66
- reason: code === 0 ? "" : `exit code ${code}`,
67
- });
68
- });
69
- });
70
- }
71
-
72
42
 
73
43
  function getPackageVersion() {
74
44
  try {
@@ -188,15 +158,6 @@ export function startUpdateChecker(log) {
188
158
  return;
189
159
  }
190
160
 
191
- // Check if we have write permission to the plugin directory before attempting update
192
- const pluginDir = path.join(__dirname, "..");
193
- try {
194
- fs.accessSync(pluginDir, fs.constants.W_OK);
195
- } catch (err) {
196
- log.warn?.(`${ANSI.YELLOW}[memos-cloud] Update available (${latestVersion}), but skipping auto-update due to missing write permissions in ${pluginDir}. Please run manually with sudo.${ANSI.RESET}`);
197
- return;
198
- }
199
-
200
161
  log.info?.(`${ANSI.YELLOW}[memos-cloud] Update available: ${currentVersion} -> ${latestVersion}. Updating in background...${ANSI.RESET}`);
201
162
 
202
163
  let dotCount = 0;
@@ -216,14 +177,6 @@ export function startUpdateChecker(log) {
216
177
  return "openclaw";
217
178
  })();
218
179
 
219
- const preflight = await canExecuteCli(cliName);
220
- if (!preflight.ok) {
221
- log.warn?.(
222
- `${ANSI.YELLOW}[memos-cloud] Update available (${latestVersion}), but cannot execute CLI '${cliName}' (${preflight.reason}). Please update manually: ${cliName} plugins update memos-cloud-openclaw-plugin${ANSI.RESET}`,
223
- );
224
- return;
225
- }
226
-
227
180
  isUpdating = true;
228
181
  const spawnOpts = { shell: true };
229
182
  // On Unix, detach the process so we can kill the entire process group on timeout
@@ -231,14 +184,6 @@ export function startUpdateChecker(log) {
231
184
  spawnOpts.detached = true;
232
185
  }
233
186
  const child = spawn(cliName, ["plugins", "update", "memos-cloud-openclaw-plugin"], spawnOpts);
234
- let finished = false;
235
- const finishUpdateSequence = () => {
236
- if (finished) return;
237
- finished = true;
238
- clearTimeout(updateTimeout);
239
- clearInterval(progressInterval);
240
- isUpdating = false;
241
- };
242
187
 
243
188
  // Timeout mechanism: forcefully kill the update process if it hangs for more than the configured timeout
244
189
  const updateTimeout = setTimeout(() => {
@@ -248,7 +193,8 @@ export function startUpdateChecker(log) {
248
193
  // Fallback: if kill failed and the close event never fires, forcefully release the lock after 5 seconds
249
194
  setTimeout(() => {
250
195
  if (isUpdating) {
251
- finishUpdateSequence();
196
+ clearInterval(progressInterval);
197
+ isUpdating = false;
252
198
  }
253
199
  }, 5000);
254
200
  }, UPDATE_TIMEOUT);
@@ -274,7 +220,9 @@ export function startUpdateChecker(log) {
274
220
  });
275
221
 
276
222
  child.on("close", (code) => {
277
- finishUpdateSequence();
223
+ clearTimeout(updateTimeout);
224
+ clearInterval(progressInterval);
225
+ isUpdating = false;
278
226
 
279
227
  // Wait for a brief moment to let file system sync if needed
280
228
  setTimeout(() => {
@@ -289,13 +237,6 @@ export function startUpdateChecker(log) {
289
237
  }, 1000); // Small 1-second buffer for file systems
290
238
  });
291
239
 
292
- child.on("error", (err) => {
293
- finishUpdateSequence();
294
- log.warn?.(
295
- `${ANSI.RED}[memos-cloud] Failed to start auto-update process: ${err?.message || String(err)}. Please run manually: ${cliName} plugins update memos-cloud-openclaw-plugin${ANSI.RESET}`,
296
- );
297
- });
298
-
299
240
  } catch (error) {
300
241
  log.warn?.(`${ANSI.RED}[memos-cloud] Update check failed entirely: ${error.message}${ANSI.RESET}`);
301
242
  }
@@ -27,7 +27,7 @@ function stripQuotes(value) {
27
27
  return trimmed;
28
28
  }
29
29
 
30
- function extractResultData(result) {
30
+ export function extractResultData(result) {
31
31
  if (!result || typeof result !== "object") return null;
32
32
  return result.data ?? result.data?.data ?? result.data?.result ?? null;
33
33
  }
@@ -120,6 +120,12 @@ function parseBool(value, fallback) {
120
120
  return fallback;
121
121
  }
122
122
 
123
+ function parseNumber(value, fallback) {
124
+ if (value === undefined || value === null || value === "") return fallback;
125
+ const n = Number(value);
126
+ return Number.isFinite(n) ? n : fallback;
127
+ }
128
+
123
129
  export function buildConfig(pluginConfig = {}) {
124
130
  const cfg = pluginConfig ?? {};
125
131
 
@@ -147,6 +153,15 @@ export function buildConfig(pluginConfig = {}) {
147
153
  parseBool(loadEnvVar("MEMOS_MULTI_AGENT_MODE"), false),
148
154
  );
149
155
 
156
+ const recallFilterEnabled = parseBool(
157
+ cfg.recallFilterEnabled,
158
+ parseBool(loadEnvVar("MEMOS_RECALL_FILTER_ENABLED"), false),
159
+ );
160
+ const recallFilterFailOpen = parseBool(
161
+ cfg.recallFilterFailOpen,
162
+ parseBool(loadEnvVar("MEMOS_RECALL_FILTER_FAIL_OPEN"), true),
163
+ );
164
+
150
165
  return {
151
166
  baseUrl: baseUrl.replace(/\/+$/, ""),
152
167
  apiKey,
@@ -185,6 +200,21 @@ export function buildConfig(pluginConfig = {}) {
185
200
  allowKnowledgebaseIds: cfg.allowKnowledgebaseIds ?? [],
186
201
  asyncMode: cfg.asyncMode ?? true,
187
202
  multiAgentMode,
203
+ recallFilterEnabled,
204
+ recallFilterBaseUrl:
205
+ (cfg.recallFilterBaseUrl ?? loadEnvVar("MEMOS_RECALL_FILTER_BASE_URL") ?? "").replace(/\/+$/, ""),
206
+ recallFilterApiKey: cfg.recallFilterApiKey ?? loadEnvVar("MEMOS_RECALL_FILTER_API_KEY") ?? "",
207
+ recallFilterModel: cfg.recallFilterModel ?? loadEnvVar("MEMOS_RECALL_FILTER_MODEL") ?? "",
208
+ recallFilterTimeoutMs: parseNumber(
209
+ cfg.recallFilterTimeoutMs ?? loadEnvVar("MEMOS_RECALL_FILTER_TIMEOUT_MS"),
210
+ 6000,
211
+ ),
212
+ recallFilterRetries: parseNumber(cfg.recallFilterRetries ?? loadEnvVar("MEMOS_RECALL_FILTER_RETRIES"), 0),
213
+ recallFilterCandidateLimit:
214
+ parseNumber(cfg.recallFilterCandidateLimit ?? loadEnvVar("MEMOS_RECALL_FILTER_CANDIDATE_LIMIT"), 30),
215
+ recallFilterMaxItemChars:
216
+ parseNumber(cfg.recallFilterMaxItemChars ?? loadEnvVar("MEMOS_RECALL_FILTER_MAX_ITEM_CHARS"), 500),
217
+ recallFilterFailOpen,
188
218
  timeoutMs: cfg.timeoutMs ?? 5000,
189
219
  retries: cfg.retries ?? 1,
190
220
  throttleMs: cfg.throttleMs ?? 0,
@@ -295,9 +325,7 @@ function wrapCodeBlock(lines, options = {}) {
295
325
  return ["```text", ...lines, "```"];
296
326
  }
297
327
 
298
- function buildPromptFromData(data, options = {}) {
299
- const now = options.currentTime ?? Date.now();
300
- const nowText = formatTime(now) || formatTime(Date.now()) || "";
328
+ function buildMemorySections(data, options = {}) {
301
329
  const memoryList = data?.memory_detail_list ?? [];
302
330
  const preferenceList = data?.preference_detail_list ?? [];
303
331
 
@@ -325,8 +353,60 @@ function buildPromptFromData(data, options = {}) {
325
353
  })
326
354
  .filter(Boolean);
327
355
 
328
- const hasContent = memoryLines.length > 0 || preferenceLines.length > 0;
356
+ return { memoryLines, preferenceLines };
357
+ }
329
358
 
359
+ const STATIC_RECALL_SYSTEM_PROMPT = [
360
+ "# Role",
361
+ "",
362
+ "You are an intelligent assistant with long-term memory capabilities (MemOS Assistant). Your goal is to combine retrieved memory fragments to provide highly personalized, accurate, and logically rigorous responses.",
363
+ "",
364
+ "# System Context",
365
+ "",
366
+ "* Current Time: Use the runtime-provided current time as the baseline for freshness checks.",
367
+ "* Additional memory context for the current turn may be prepended before the original user query as a structured `<memories>` block.",
368
+ "",
369
+ "# Memory Data",
370
+ "",
371
+ 'Below is the information retrieved by MemOS, categorized into "Facts" and "Preferences".',
372
+ "* **Facts**: May include user attributes, historical conversations, or third-party details.",
373
+ "* **Special Note**: Content tagged with '[assistant观点]' or '[模型总结]' represents **past AI inference**, **not** direct user statements.",
374
+ "* **Preferences**: The user's explicit or implicit requirements on response style, format, or reasoning.",
375
+ "",
376
+ "# Critical Protocol: Memory Safety",
377
+ "",
378
+ "Retrieved memories may contain **AI speculation**, **irrelevant noise**, or **wrong subject attribution**. You must strictly apply the **Four-Step Verdict**. If any step fails, **discard the memory**:",
379
+ "",
380
+ "1. **Source Verification**:",
381
+ "* **Core**: Distinguish direct user statements from AI inference.",
382
+ "* If a memory has tags like '[assistant观点]' or '[模型总结]', treat it as a **hypothesis**, not a user-grounded fact.",
383
+ "* *Counterexample*: If memory says '[assistant观点] User loves mangoes' but the user never said that, do not assume it as fact.",
384
+ "* **Principle: AI summaries are reference-only and have much lower authority than direct user statements.**",
385
+ "",
386
+ "2. **Attribution Check**:",
387
+ "* Is the subject in memory definitely the user?",
388
+ "* If the memory describes a **third party** (e.g., candidate, interviewee, fictional character, case data), never attribute it to the user.",
389
+ "",
390
+ "3. **Strong Relevance Check**:",
391
+ "* Does the memory directly help answer the current 'Original Query'?",
392
+ "* If it is only a keyword overlap with different context, ignore it.",
393
+ "",
394
+ "4. **Freshness Check**:",
395
+ "* If memory conflicts with the user's latest intent, prioritize the current 'Original Query' as the highest source of truth.",
396
+ "",
397
+ "# Instructions",
398
+ "",
399
+ "1. **Review**: Read '<facts>' first and apply the Four-Step Verdict to remove noise and unreliable AI inference.",
400
+ "2. **Execute**:",
401
+ " - Use only memories that pass filtering as context.",
402
+ " - Strictly follow style requirements from '<preferences>'.",
403
+ "3. **Output**: Answer directly. Never mention internal terms such as \"memory store\", \"retrieval\", or \"AI opinions\".",
404
+ "4. **Attention**: Additional memory context may already be provided before the original user query. Do not read from or write to local `MEMORY.md` or `memory/*` files for reference, as they may be outdated or irrelevant to the current query.",
405
+ ].join("\n");
406
+
407
+ function buildMemoryPrependBlock(data, options = {}) {
408
+ const { memoryLines, preferenceLines } = buildMemorySections(data, options);
409
+ const hasContent = memoryLines.length > 0 || preferenceLines.length > 0;
330
410
  if (!hasContent) return "";
331
411
 
332
412
  const memoriesBlock = [
@@ -340,57 +420,17 @@ function buildPromptFromData(data, options = {}) {
340
420
  "</memories>",
341
421
  ];
342
422
 
343
- const lines = [
344
- "# Role",
345
- "",
346
- "You are an intelligent assistant with long-term memory capabilities (MemOS Assistant). Your goal is to combine retrieved memory fragments to provide highly personalized, accurate, and logically rigorous responses.",
347
- "",
348
- "# System Context",
349
- "",
350
- `* Current Time: ${nowText} (Use this as the baseline for freshness checks)`,
351
- "",
352
- "# Memory Data",
353
- "",
354
- 'Below is the information retrieved by MemOS, categorized into "Facts" and "Preferences".',
355
- "* **Facts**: May include user attributes, historical conversations, or third-party details.",
356
- "* **Special Note**: Content tagged with '[assistant观点]' or '[模型总结]' represents **past AI inference**, **not** direct user statements.",
357
- "* **Preferences**: The user's explicit or implicit requirements on response style, format, or reasoning.",
358
- "",
359
- ...wrapCodeBlock(memoriesBlock, options),
360
- "",
361
- "# Critical Protocol: Memory Safety",
362
- "",
363
- "Retrieved memories may contain **AI speculation**, **irrelevant noise**, or **wrong subject attribution**. You must strictly apply the **Four-Step Verdict**. If any step fails, **discard the memory**:",
364
- "",
365
- "1. **Source Verification**:",
366
- "* **Core**: Distinguish direct user statements from AI inference.",
367
- "* If a memory has tags like '[assistant观点]' or '[模型总结]', treat it as a **hypothesis**, not a user-grounded fact.",
368
- "* *Counterexample*: If memory says '[assistant观点] User loves mangoes' but the user never said that, do not assume it as fact.",
369
- "* **Principle: AI summaries are reference-only and have much lower authority than direct user statements.**",
370
- "",
371
- "2. **Attribution Check**:",
372
- "* Is the subject in memory definitely the user?",
373
- "* If the memory describes a **third party** (e.g., candidate, interviewee, fictional character, case data), never attribute it to the user.",
374
- "",
375
- "3. **Strong Relevance Check**:",
376
- "* Does the memory directly help answer the current 'Original Query'?",
377
- "* If it is only a keyword overlap with different context, ignore it.",
378
- "",
379
- "4. **Freshness Check**:",
380
- "* If memory conflicts with the user's latest intent, prioritize the current 'Original Query' as the highest source of truth.",
381
- "",
382
- "# Instructions",
383
- "",
384
- "1. **Review**: Read '<facts>' first and apply the Four-Step Verdict to remove noise and unreliable AI inference.",
385
- "2. **Execute**:",
386
- " - Use only memories that pass filtering as context.",
387
- " - Strictly follow style requirements from '<preferences>'.",
388
- "3. **Output**: Answer directly. Never mention internal terms such as \"memory store\", \"retrieval\", or \"AI opinions\".",
389
- "4. **Attention**: Additional memory context is already provided. Do not read from or write to local `MEMORY.md` or `memory/*` files for reference, as they may be outdated or irrelevant to the current query.",
390
- USER_QUERY_MARKER,
391
- ];
423
+ return [...wrapCodeBlock(memoriesBlock, options), "", USER_QUERY_MARKER].join("\n");
424
+ }
392
425
 
393
- return lines.join("\n");
426
+ export function formatPromptBlockFromData(data, options = {}) {
427
+ if (!data || typeof data !== "object") return "";
428
+ return buildMemoryPrependBlock(data, options);
429
+ }
430
+
431
+ export function formatPromptBlock(result, options = {}) {
432
+ const data = extractResultData(result);
433
+ return formatPromptBlockFromData(data, options);
394
434
  }
395
435
 
396
436
  export function formatContextBlock(result, options = {}) {
@@ -438,10 +478,20 @@ export function formatContextBlock(result, options = {}) {
438
478
  return lines.length > 0 ? lines.join("\n") : "";
439
479
  }
440
480
 
441
- export function formatPromptBlock(result, options = {}) {
481
+ export function formatRecallHookResult(result, options = {}) {
442
482
  const data = extractResultData(result);
443
- if (!data) return "";
444
- return buildPromptFromData(data, options);
483
+ if (!data) {
484
+ return {
485
+ appendSystemContext: "",
486
+ prependContext: "",
487
+ };
488
+ }
489
+
490
+ return {
491
+ // Keep this system addendum byte-stable across turns so provider-side prefix caching can hit.
492
+ appendSystemContext: STATIC_RECALL_SYSTEM_PROMPT,
493
+ prependContext: buildMemoryPrependBlock(data, options),
494
+ };
445
495
  }
446
496
 
447
497
  function truncate(text, maxLen) {
@@ -2,7 +2,7 @@
2
2
  "id": "memos-cloud-openclaw-plugin",
3
3
  "name": "MemOS Cloud OpenClaw Plugin",
4
4
  "description": "MemOS Cloud recall + add memory via lifecycle hooks",
5
- "version": "0.1.8-beta.8",
5
+ "version": "0.1.9",
6
6
  "kind": "lifecycle",
7
7
  "main": "./index.js",
8
8
  "configSchema": {
@@ -2,7 +2,7 @@
2
2
  "id": "memos-cloud-openclaw-plugin",
3
3
  "name": "MemOS Cloud OpenClaw Plugin",
4
4
  "description": "MemOS Cloud recall + add memory via lifecycle hooks",
5
- "version": "0.1.8-beta.8",
5
+ "version": "0.1.9",
6
6
  "kind": "lifecycle",
7
7
  "main": "./index.js",
8
8
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memtensor/memos-cloud-openclaw-plugin",
3
- "version": "0.1.8-beta.9",
3
+ "version": "0.1.9",
4
4
  "description": "OpenClaw lifecycle plugin for MemOS Cloud (add + recall memory)",
5
5
  "scripts": {
6
6
  "sync-version": "node scripts/sync-version.js",