@humanlikememory/human-like-mem 0.3.11 → 0.3.13

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
@@ -1,70 +1,70 @@
1
- # Human-Like Memory Plugin for OpenClaw
2
-
3
- [![npm version](https://img.shields.io/npm/v/@humanlikememory/human-like-mem.svg)](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
4
-
5
- Long-term memory plugin for OpenClaw with automatic memory recall and memory storage.
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install @humanlikememory/human-like-mem
11
- ```
12
-
13
- ## Enable In OpenClaw
14
-
15
- Edit `~/.openclaw/openclaw.json`:
16
-
17
- ```json
18
- {
19
- "plugins": {
20
- "entries": {
21
- "human-like-mem": {
22
- "enabled": true
23
- }
24
- }
25
- }
26
- }
27
- ```
28
-
29
- ## Required Environment Variables
30
-
31
- ```bash
32
- export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
33
- ```
34
-
35
- ## Optional Environment Variables
36
-
37
- ```bash
38
- export HUMAN_LIKE_MEM_BASE_URL="https://human-like.me"
39
- export HUMAN_LIKE_MEM_USER_ID="your-user-id"
40
- export HUMAN_LIKE_MEM_AGENT_ID="main"
41
- export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
42
- export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
43
- export HUMAN_LIKE_MEM_MIN_TURNS="5"
44
- export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
45
- ```
46
-
47
- ## How It Works
48
-
49
- - Before response: the plugin retrieves relevant memories and injects them into context.
50
- - After response: the plugin caches the conversation and flushes memory by turn threshold or timeout.
51
-
52
- Default storage behavior:
53
-
54
- - `minTurnsToStore = 5`
55
- - `maxTurnsToStore = minTurnsToStore * 2`
56
- - `sessionTimeoutMs = 300000` (5 minutes)
57
-
58
- ## Troubleshooting
59
-
60
- - If you see `HUMAN_LIKE_MEM_API_KEY not configured`, make sure the key is set in your runtime environment.
61
- - If you see request timeout logs, increase plugin `timeoutMs` (for example `30000`).
62
- - Check logs with:
63
-
64
- ```bash
65
- openclaw logs --plain --limit 200
66
- ```
67
-
68
- ## License
69
-
70
- Apache-2.0
1
+ # Human-Like Memory Plugin for OpenClaw
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@humanlikememory/human-like-mem.svg)](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
4
+
5
+ Long-term memory plugin for OpenClaw with automatic memory recall and memory storage.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @humanlikememory/human-like-mem
11
+ ```
12
+
13
+ ## Enable In OpenClaw
14
+
15
+ Edit `~/.openclaw/openclaw.json`:
16
+
17
+ ```json
18
+ {
19
+ "plugins": {
20
+ "entries": {
21
+ "human-like-mem": {
22
+ "enabled": true
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Required Environment Variables
30
+
31
+ ```bash
32
+ export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
33
+ ```
34
+
35
+ ## Optional Environment Variables
36
+
37
+ ```bash
38
+ export HUMAN_LIKE_MEM_BASE_URL="https://plugin.human-like.me"
39
+ export HUMAN_LIKE_MEM_USER_ID="your-user-id"
40
+ export HUMAN_LIKE_MEM_AGENT_ID="main"
41
+ export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
42
+ export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
43
+ export HUMAN_LIKE_MEM_MIN_TURNS="5"
44
+ export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
45
+ ```
46
+
47
+ ## How It Works
48
+
49
+ - Before response: the plugin retrieves relevant memories and injects them into context.
50
+ - After response: the plugin caches the conversation and flushes memory by turn threshold or timeout.
51
+
52
+ Default storage behavior:
53
+
54
+ - `minTurnsToStore = 5`
55
+ - `maxTurnsToStore = minTurnsToStore * 2`
56
+ - `sessionTimeoutMs = 300000` (5 minutes)
57
+
58
+ ## Troubleshooting
59
+
60
+ - If you see `HUMAN_LIKE_MEM_API_KEY not configured`, make sure the key is set in your runtime environment.
61
+ - If you see request timeout logs, increase plugin `timeoutMs` (for example `30000`).
62
+ - Check logs with:
63
+
64
+ ```bash
65
+ openclaw logs --plain --limit 200
66
+ ```
67
+
68
+ ## License
69
+
70
+ Apache-2.0
package/README_ZH.md CHANGED
@@ -1,70 +1,70 @@
1
- # Human-Like Memory Plugin for OpenClaw
2
-
3
- [![npm version](https://img.shields.io/npm/v/@humanlikememory/human-like-mem.svg)](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
4
-
5
- OpenClaw 长期记忆插件,支持自动召回和自动存储对话记忆。
6
-
7
- ## 安装
8
-
9
- ```bash
10
- npm install @humanlikememory/human-like-mem
11
- ```
12
-
13
- ## 在 OpenClaw 中启用
14
-
15
- 编辑 `~/.openclaw/openclaw.json`:
16
-
17
- ```json
18
- {
19
- "plugins": {
20
- "entries": {
21
- "human-like-mem": {
22
- "enabled": true
23
- }
24
- }
25
- }
26
- }
27
- ```
28
-
29
- ## 必填环境变量
30
-
31
- ```bash
32
- export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
33
- ```
34
-
35
- ## 可选环境变量
36
-
37
- ```bash
38
- export HUMAN_LIKE_MEM_BASE_URL="https://human-like.me"
39
- export HUMAN_LIKE_MEM_USER_ID="your-user-id"
40
- export HUMAN_LIKE_MEM_AGENT_ID="main"
41
- export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
42
- export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
43
- export HUMAN_LIKE_MEM_MIN_TURNS="5"
44
- export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
45
- ```
46
-
47
- ## 工作机制
48
-
49
- - 回答前:插件检索相关记忆并注入上下文。
50
- - 回答后:插件缓存会话,在满足轮次阈值或超时后写入记忆。
51
-
52
- 默认存储策略:
53
-
54
- - `minTurnsToStore = 5`
55
- - `maxTurnsToStore = minTurnsToStore * 2`
56
- - `sessionTimeoutMs = 300000`(5 分钟)
57
-
58
- ## 常见问题
59
-
60
- - 如果日志出现 `HUMAN_LIKE_MEM_API_KEY not configured`,请确认运行时环境变量已生效。
61
- - 如果日志出现请求超时,请把插件 `timeoutMs` 调大(例如 `30000`)。
62
- - 查看日志:
63
-
64
- ```bash
65
- openclaw logs --plain --limit 200
66
- ```
67
-
68
- ## 许可证
69
-
70
- Apache-2.0
1
+ # Human-Like Memory Plugin for OpenClaw
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@humanlikememory/human-like-mem.svg)](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
4
+
5
+ OpenClaw 长期记忆插件,支持自动召回和自动存储对话记忆。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm install @humanlikememory/human-like-mem
11
+ ```
12
+
13
+ ## 在 OpenClaw 中启用
14
+
15
+ 编辑 `~/.openclaw/openclaw.json`:
16
+
17
+ ```json
18
+ {
19
+ "plugins": {
20
+ "entries": {
21
+ "human-like-mem": {
22
+ "enabled": true
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## 必填环境变量
30
+
31
+ ```bash
32
+ export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
33
+ ```
34
+
35
+ ## 可选环境变量
36
+
37
+ ```bash
38
+ export HUMAN_LIKE_MEM_BASE_URL="https://plugin.human-like.me"
39
+ export HUMAN_LIKE_MEM_USER_ID="your-user-id"
40
+ export HUMAN_LIKE_MEM_AGENT_ID="main"
41
+ export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
42
+ export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
43
+ export HUMAN_LIKE_MEM_MIN_TURNS="5"
44
+ export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
45
+ ```
46
+
47
+ ## 工作机制
48
+
49
+ - 回答前:插件检索相关记忆并注入上下文。
50
+ - 回答后:插件缓存会话,在满足轮次阈值或超时后写入记忆。
51
+
52
+ 默认存储策略:
53
+
54
+ - `minTurnsToStore = 5`
55
+ - `maxTurnsToStore = minTurnsToStore * 2`
56
+ - `sessionTimeoutMs = 300000`(5 分钟)
57
+
58
+ ## 常见问题
59
+
60
+ - 如果日志出现 `HUMAN_LIKE_MEM_API_KEY not configured`,请确认运行时环境变量已生效。
61
+ - 如果日志出现请求超时,请把插件 `timeoutMs` 调大(例如 `30000`)。
62
+ - 查看日志:
63
+
64
+ ```bash
65
+ openclaw logs --plain --limit 200
66
+ ```
67
+
68
+ ## 许可证
69
+
70
+ Apache-2.0
package/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const PLUGIN_VERSION = "0.3.4";
10
10
  const USER_QUERY_MARKER = "--- User Query ---";
11
+ const CACHE_DEDUP_WINDOW_MS = 4000;
11
12
 
12
13
  /**
13
14
  * Session cache for tracking conversation history
@@ -59,6 +60,127 @@ function extractText(content) {
59
60
  return "";
60
61
  }
61
62
 
63
+ function isToolCallBlock(block) {
64
+ if (!block || typeof block !== "object") return false;
65
+ const type = String(block.type || "").trim().toLowerCase();
66
+ return type === "toolcall" || type === "tool_call";
67
+ }
68
+
69
+ function normalizeToolCallBlock(block) {
70
+ if (!isToolCallBlock(block)) return null;
71
+
72
+ const name =
73
+ block.function?.name ||
74
+ block.toolName ||
75
+ block.name ||
76
+ "unknown";
77
+ const args =
78
+ block.function?.arguments ??
79
+ block.arguments ??
80
+ block.args ??
81
+ block.input ??
82
+ {};
83
+ const callId =
84
+ block.id ||
85
+ block.callId ||
86
+ block.toolCallId ||
87
+ block.tool_call_id ||
88
+ null;
89
+
90
+ return {
91
+ id: callId,
92
+ name,
93
+ arguments: args,
94
+ function: {
95
+ name,
96
+ arguments: args,
97
+ },
98
+ };
99
+ }
100
+
101
+ function extractToolCallsFromContent(content) {
102
+ if (!Array.isArray(content)) return [];
103
+
104
+ return content
105
+ .map((block) => normalizeToolCallBlock(block))
106
+ .filter(Boolean);
107
+ }
108
+
109
+ function getMessageToolCalls(msg) {
110
+ if (!msg || typeof msg !== "object") return [];
111
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
112
+ return msg.tool_calls;
113
+ }
114
+ return extractToolCallsFromContent(msg.content);
115
+ }
116
+
117
+ function getToolResultCallId(msg) {
118
+ if (!msg || typeof msg !== "object") return null;
119
+ return (
120
+ msg.tool_call_id ||
121
+ msg.toolCallId ||
122
+ msg.call_id ||
123
+ msg.callId ||
124
+ null
125
+ );
126
+ }
127
+
128
+ function getToolResultName(msg) {
129
+ if (!msg || typeof msg !== "object") return undefined;
130
+ return msg.name || msg.toolName || msg.tool_name || undefined;
131
+ }
132
+
133
+ function normalizeCacheRole(role) {
134
+ if (role === "toolResult") return "tool";
135
+ return role;
136
+ }
137
+
138
+ function isAssistantLikeRole(role) {
139
+ return role === "assistant" || role === "tool" || role === "toolResult";
140
+ }
141
+
142
+ function buildCacheMessageFromTranscript(msg) {
143
+ if (!msg || msg.role === "system") return null;
144
+
145
+ const normalizedRole = normalizeCacheRole(msg.role);
146
+ let content = "";
147
+ let rawSource = msg.content;
148
+ let messageId = "";
149
+
150
+ if (msg.role === "user") {
151
+ content = normalizeUserMessageContent(msg.content);
152
+ rawSource = stripPrependedPrompt(msg.content);
153
+ messageId = extractRelayMessageId(rawSource || "");
154
+ } else if (msg.role === "assistant") {
155
+ content = normalizeAssistantMessageContent(msg.content);
156
+ rawSource = extractText(msg.content);
157
+ } else if (normalizedRole === "tool") {
158
+ content = extractText(msg.content);
159
+ rawSource = content;
160
+ } else {
161
+ return null;
162
+ }
163
+
164
+ const toolCalls = getMessageToolCalls(msg);
165
+ const toolCallId = getToolResultCallId(msg);
166
+ const toolName = getToolResultName(msg);
167
+ const hasToolContext =
168
+ (normalizedRole === "assistant" && toolCalls.length > 0) ||
169
+ (normalizedRole === "tool" && (!!toolCallId || !!toolName));
170
+
171
+ if (!content && !hasToolContext) return null;
172
+
173
+ return {
174
+ role: normalizedRole,
175
+ content: content || "",
176
+ rawContent: rawSource || undefined,
177
+ messageId: messageId || undefined,
178
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
179
+ tool_call_id: toolCallId || undefined,
180
+ name: toolName,
181
+ };
182
+ }
183
+
62
184
  /**
63
185
  * Strip prepended prompt markers from message content
64
186
  */
@@ -123,6 +245,65 @@ function isMetadataOnlyText(text) {
123
245
  return false;
124
246
  }
125
247
 
248
+ /**
249
+ * Remove injected relay metadata blocks/lines from user content.
250
+ */
251
+ function stripInjectedMetadata(text) {
252
+ if (!text || typeof text !== "string") return "";
253
+
254
+ let result = text.replace(/\r\n/g, "\n");
255
+
256
+ // Remove labeled untrusted metadata JSON blocks.
257
+ // Examples:
258
+ // Conversation info (untrusted metadata): ```json ... ```
259
+ // Sender (untrusted metadata): ```json ... ```
260
+ result = result.replace(
261
+ /(^|\n)[^\n]*\(untrusted metadata(?:,\s*for context)?\)\s*:\s*```json[\s\S]*?```(?=\n|$)/gi,
262
+ "\n"
263
+ );
264
+
265
+ // Remove transport/system hint lines that are not user utterances.
266
+ result = result.replace(/^\[System:[^\n]*\]\s*$/gim, "");
267
+ result = result.replace(/^\[message_id:\s*[^\]]+\]\s*$/gim, "");
268
+
269
+ // Collapse extra blank lines.
270
+ result = result.replace(/\n{3,}/g, "\n\n").trim();
271
+ return result;
272
+ }
273
+
274
+ /**
275
+ * Keep the latest meaningful line from cleaned metadata-injected payload.
276
+ */
277
+ function extractLatestUtteranceFromCleanText(text) {
278
+ if (!text || typeof text !== "string") return "";
279
+ const lines = text
280
+ .split("\n")
281
+ .map((line) => line.trim())
282
+ .filter(Boolean)
283
+ .filter((line) => !isMetadataOnlyText(line));
284
+ if (lines.length === 0) return "";
285
+ return lines[lines.length - 1];
286
+ }
287
+
288
+ function escapeRegex(text) {
289
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
290
+ }
291
+
292
+ /**
293
+ * Prefix utterance with parsed sender name when available.
294
+ * This preserves who said it in multi-user group contexts.
295
+ */
296
+ function attachSenderPrefix(utterance, senderName) {
297
+ const content = String(utterance || "").trim();
298
+ if (!content) return "";
299
+ const sender = String(senderName || "").trim();
300
+ if (!sender) return content;
301
+
302
+ const senderRegex = new RegExp(`^${escapeRegex(sender)}\\s*[::]`);
303
+ if (senderRegex.test(content)) return content;
304
+ return `${sender}: ${content}`;
305
+ }
306
+
126
307
  /**
127
308
  * Normalize user message text before caching/storing.
128
309
  * For channel-formatted payloads (e.g. Feishu), keep only the actual user utterance
@@ -135,6 +316,17 @@ function normalizeUserMessageContent(content) {
135
316
  const normalized = String(text).replace(/\r\n/g, "\n").trim();
136
317
  if (!normalized) return "";
137
318
 
319
+ const parsedIdentity = parseIdentityFromUntrustedMetadata(normalized);
320
+
321
+ // New relay payload format: strip injected metadata blocks first.
322
+ const strippedMetadata = stripInjectedMetadata(normalized);
323
+ if (strippedMetadata && strippedMetadata !== normalized) {
324
+ const latestUtterance = extractLatestUtteranceFromCleanText(strippedMetadata);
325
+ if (latestUtterance && !isMetadataOnlyText(latestUtterance)) {
326
+ return attachSenderPrefix(latestUtterance, parsedIdentity?.userName);
327
+ }
328
+ }
329
+
138
330
  // Feishu: preserve the final traceable tail block (contains platform user id/message_id).
139
331
  const feishuTailBlock = extractFeishuTailBlock(normalized);
140
332
  if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
@@ -168,7 +360,9 @@ function normalizeUserMessageContent(content) {
168
360
  if (!isMetadataOnlyText(candidate)) return candidate;
169
361
  }
170
362
 
171
- return isMetadataOnlyText(normalized) ? "" : normalized;
363
+ return isMetadataOnlyText(normalized)
364
+ ? ""
365
+ : attachSenderPrefix(normalized, parsedIdentity?.userName);
172
366
  }
173
367
 
174
368
  /**
@@ -183,6 +377,125 @@ function normalizeAssistantMessageContent(content) {
183
377
  return normalized;
184
378
  }
185
379
 
380
+ /**
381
+ * Parse JSON objects from markdown code fences after a specific label.
382
+ * Example:
383
+ * Sender (untrusted metadata):
384
+ * ```json
385
+ * { "id": "123" }
386
+ * ```
387
+ */
388
+ function parseJsonFencesAfterLabel(text, labelPattern) {
389
+ if (!text || typeof text !== "string") return [];
390
+
391
+ const results = [];
392
+ const regex = new RegExp(`${labelPattern}\\s*:\\s*\`\`\`json\\s*([\\s\\S]*?)\\s*\`\`\``, "gi");
393
+ let match;
394
+ while ((match = regex.exec(text)) !== null) {
395
+ if (!match[1]) continue;
396
+ try {
397
+ const parsed = JSON.parse(match[1]);
398
+ if (parsed && typeof parsed === "object") {
399
+ results.push(parsed);
400
+ }
401
+ } catch (_) {
402
+ // Ignore malformed JSON blocks and continue matching others.
403
+ }
404
+ }
405
+ return results;
406
+ }
407
+
408
+ /**
409
+ * Normalize potential user id values into non-empty strings.
410
+ */
411
+ function normalizeParsedUserId(value) {
412
+ if (value === undefined || value === null) return "";
413
+ const normalized = String(value).trim();
414
+ return normalized || "";
415
+ }
416
+
417
+ /**
418
+ * Extract user id from a display label like:
419
+ * "用户250389 (ou_xxx)" / "name (123456789)".
420
+ */
421
+ function extractUserIdFromLabel(label) {
422
+ const text = normalizeParsedUserId(label);
423
+ if (!text) return "";
424
+ const match = text.match(/\(\s*((?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)$/i);
425
+ return match?.[1] ? match[1] : "";
426
+ }
427
+
428
+ /**
429
+ * Extract transport message id from current relay payload.
430
+ */
431
+ function extractRelayMessageId(text) {
432
+ if (!text || typeof text !== "string") return "";
433
+
434
+ const conversationMetaList = parseJsonFencesAfterLabel(
435
+ text,
436
+ "Conversation info \\(untrusted metadata\\)"
437
+ );
438
+ for (let i = conversationMetaList.length - 1; i >= 0; i--) {
439
+ const conversationMeta = conversationMetaList[i];
440
+ const messageId =
441
+ normalizeParsedUserId(conversationMeta?.message_id) ||
442
+ normalizeParsedUserId(conversationMeta?.messageId);
443
+ if (messageId) return messageId;
444
+ }
445
+
446
+ const messageIdMatch = text.match(/\[message_id:\s*([^\]\n]+)\]/i);
447
+ if (messageIdMatch?.[1]) return messageIdMatch[1].trim();
448
+
449
+ return "";
450
+ }
451
+
452
+ /**
453
+ * Parse identity from new "untrusted metadata" JSON blocks.
454
+ * Priority:
455
+ * 1) Sender (untrusted metadata).id
456
+ * 2) Conversation info (untrusted metadata).sender_id
457
+ */
458
+ function parseIdentityFromUntrustedMetadata(text) {
459
+ if (!text || typeof text !== "string") return null;
460
+
461
+ const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
462
+ for (let i = senderMetaList.length - 1; i >= 0; i--) {
463
+ const senderMeta = senderMetaList[i];
464
+ const userId =
465
+ normalizeParsedUserId(senderMeta?.id) ||
466
+ extractUserIdFromLabel(senderMeta?.label);
467
+ if (userId) {
468
+ return {
469
+ platform: null,
470
+ userId,
471
+ userName: senderMeta?.name || senderMeta?.username || senderMeta?.label || null,
472
+ source: "sender-untrusted-metadata-json",
473
+ };
474
+ }
475
+ }
476
+
477
+ const conversationMetaList = parseJsonFencesAfterLabel(
478
+ text,
479
+ "Conversation info \\(untrusted metadata\\)"
480
+ );
481
+ for (let i = conversationMetaList.length - 1; i >= 0; i--) {
482
+ const conversationMeta = conversationMetaList[i];
483
+ const userId =
484
+ normalizeParsedUserId(conversationMeta?.sender_id) ||
485
+ normalizeParsedUserId(conversationMeta?.senderId);
486
+ if (userId) {
487
+ return {
488
+ platform: null,
489
+ userId,
490
+ userName: conversationMeta?.sender || null,
491
+ source: "conversation-untrusted-metadata-json",
492
+ };
493
+ }
494
+ }
495
+
496
+ return null;
497
+ }
498
+
186
499
  /**
187
500
  * Parse platform identity hints from channel-formatted message text.
188
501
  * Returns null when no platform-specific id can be extracted.
@@ -190,6 +503,14 @@ function normalizeAssistantMessageContent(content) {
190
503
  function parsePlatformIdentity(text) {
191
504
  if (!text || typeof text !== "string") return null;
192
505
 
506
+ // New Discord format first:
507
+ // Sender (untrusted metadata): ```json { "id": "116..." } ```
508
+ // Conversation info (untrusted metadata): ```json { "sender_id": "116..." } ```
509
+ const metadataIdentity = parseIdentityFromUntrustedMetadata(text);
510
+ if (metadataIdentity?.userId) {
511
+ return metadataIdentity;
512
+ }
513
+
193
514
  // Discord example:
194
515
  // [from: huang yongqing (1470374017541079042)]
195
516
  const discordFrom = text.match(/\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/i);
@@ -224,11 +545,32 @@ function parseAllPlatformUserIds(text) {
224
545
  if (!text || typeof text !== "string") return [];
225
546
 
226
547
  const ids = [];
548
+ let match;
549
+
550
+ // New format: "Sender (untrusted metadata)" JSON blocks.
551
+ const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
552
+ for (const senderMeta of senderMetaList) {
553
+ const parsedId =
554
+ normalizeParsedUserId(senderMeta?.id) ||
555
+ extractUserIdFromLabel(senderMeta?.label);
556
+ if (parsedId) ids.push(parsedId);
557
+ }
558
+
559
+ // New format: "Conversation info (untrusted metadata)" JSON blocks.
560
+ const conversationMetaList = parseJsonFencesAfterLabel(
561
+ text,
562
+ "Conversation info \\(untrusted metadata\\)"
563
+ );
564
+ for (const conversationMeta of conversationMetaList) {
565
+ const parsedId =
566
+ normalizeParsedUserId(conversationMeta?.sender_id) ||
567
+ normalizeParsedUserId(conversationMeta?.senderId);
568
+ if (parsedId) ids.push(parsedId);
569
+ }
227
570
 
228
571
  // Discord example:
229
572
  // [from: huang yongqing (1470374017541079042)]
230
573
  const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
231
- let match;
232
574
  while ((match = discordRegex.exec(text)) !== null) {
233
575
  if (match[1]) ids.push(match[1]);
234
576
  }
@@ -266,6 +608,90 @@ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
266
608
  return Array.from(unique);
267
609
  }
268
610
 
611
+ function extractUserNameFromLabel(label) {
612
+ const text = normalizeParsedUserId(label);
613
+ if (!text) return "";
614
+ const match = text.match(
615
+ /^(.*?)\s*\(\s*(?:(?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)\s*$/i
616
+ );
617
+ return match?.[1] ? match[1].trim() : text;
618
+ }
619
+
620
+ /**
621
+ * Parse name-id pairs from supported relay payload formats.
622
+ */
623
+ function parseNameIdPairsFromText(text) {
624
+ if (!text || typeof text !== "string") return [];
625
+ const pairs = [];
626
+
627
+ // New format: Sender (untrusted metadata)
628
+ const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
629
+ for (const senderMeta of senderMetaList) {
630
+ const id =
631
+ normalizeParsedUserId(senderMeta?.id) ||
632
+ extractUserIdFromLabel(senderMeta?.label);
633
+ const name =
634
+ normalizeParsedUserId(senderMeta?.name) ||
635
+ normalizeParsedUserId(senderMeta?.username) ||
636
+ extractUserNameFromLabel(senderMeta?.label);
637
+ if (name && id) pairs.push([name, id]);
638
+ }
639
+
640
+ // New format: Conversation info (untrusted metadata)
641
+ const conversationMetaList = parseJsonFencesAfterLabel(
642
+ text,
643
+ "Conversation info \\(untrusted metadata\\)"
644
+ );
645
+ for (const conversationMeta of conversationMetaList) {
646
+ const id =
647
+ normalizeParsedUserId(conversationMeta?.sender_id) ||
648
+ normalizeParsedUserId(conversationMeta?.senderId);
649
+ const name = normalizeParsedUserId(conversationMeta?.sender);
650
+ if (name && id) pairs.push([name, id]);
651
+ }
652
+
653
+ // Old Discord: [from: name (id)]
654
+ const discordRegex = /\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/gi;
655
+ let match;
656
+ while ((match = discordRegex.exec(text)) !== null) {
657
+ const name = normalizeParsedUserId(match[1]);
658
+ const id = normalizeParsedUserId(match[2]);
659
+ if (name && id) pairs.push([name, id]);
660
+ }
661
+
662
+ // Old Feishu: [Feishu ...:ou_xxx ...] name:
663
+ const feishuRegex = /\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*([^:\n]+):/gi;
664
+ while ((match = feishuRegex.exec(text)) !== null) {
665
+ const id = normalizeParsedUserId(match[1]);
666
+ const name = normalizeParsedUserId(match[2]);
667
+ if (name && id) pairs.push([name, id]);
668
+ }
669
+
670
+ return pairs;
671
+ }
672
+
673
+ /**
674
+ * Collect distinct name->id mapping from all messages.
675
+ */
676
+ function collectNameIdMapFromMessages(messages) {
677
+ const map = {};
678
+
679
+ if (!Array.isArray(messages)) return map;
680
+
681
+ for (const msg of messages) {
682
+ if (!msg) continue;
683
+ const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
684
+ const text = typeof sourceText === "string" ? sourceText : extractText(sourceText);
685
+ const pairs = parseNameIdPairsFromText(text);
686
+ for (const [name, id] of pairs) {
687
+ if (!name || !id) continue;
688
+ map[name] = id;
689
+ }
690
+ }
691
+
692
+ return map;
693
+ }
694
+
269
695
  /**
270
696
  * Get latest user message text from cached messages.
271
697
  */
@@ -284,7 +710,7 @@ function getLatestUserMessageText(messages) {
284
710
  * Resolve request identity with fallback:
285
711
  * platform user id -> configured user id -> "openclaw-user"
286
712
  */
287
- function resolveRequestIdentity(promptText, cfg, ctx) {
713
+ function resolveRequestIdentity(promptText, cfg) {
288
714
  const parsed = parsePlatformIdentity(promptText);
289
715
  if (parsed?.userId) {
290
716
  return {
@@ -304,15 +730,6 @@ function resolveRequestIdentity(promptText, cfg, ctx) {
304
730
  };
305
731
  }
306
732
 
307
- if (ctx?.userId) {
308
- return {
309
- userId: ctx.userId,
310
- userName: null,
311
- platform: null,
312
- source: "ctx-user-id",
313
- };
314
- }
315
-
316
733
  return {
317
734
  userId: "openclaw-user",
318
735
  userName: null,
@@ -507,7 +924,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
507
924
  if (log?.info) {
508
925
  log.info(`[Memory Plugin] Recall request URL: ${url}`);
509
926
  }
510
- const identity = resolveRequestIdentity(prompt, cfg, ctx);
927
+ const identity = resolveRequestIdentity(prompt, cfg);
511
928
  const userId = sanitizeUserId(identity.userId);
512
929
  const payload = {
513
930
  query: prompt,
@@ -554,7 +971,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
554
971
  /**
555
972
  * Add memories to the API
556
973
  */
557
- async function addMemory(messages, cfg, ctx, log) {
974
+ async function addMemory(messages, cfg, ctx, log, explicitSessionId) {
558
975
  const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
559
976
  const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
560
977
 
@@ -564,9 +981,9 @@ async function addMemory(messages, cfg, ctx, log) {
564
981
  if (log?.info) {
565
982
  log.info(`[Memory Plugin] Add-memory request URL: ${url}`);
566
983
  }
567
- const sessionId = resolveSessionId(ctx, null) || `session-${Date.now()}`;
984
+ const sessionId = explicitSessionId || resolveSessionId(ctx, null) || `session-${Date.now()}`;
568
985
  const latestUserText = getLatestUserMessageText(messages);
569
- const identity = resolveRequestIdentity(latestUserText, cfg, ctx);
986
+ const identity = resolveRequestIdentity(latestUserText, cfg);
570
987
  const userId = sanitizeUserId(identity.userId);
571
988
  const metadataUserIds = (() => {
572
989
  const parsed = collectUniqueUserIdsFromMessages(messages, null)
@@ -577,6 +994,13 @@ async function addMemory(messages, cfg, ctx, log) {
577
994
  }
578
995
  return [userId];
579
996
  })();
997
+ const nameIdMap = (() => {
998
+ const parsed = collectNameIdMapFromMessages(messages);
999
+ if (identity?.userName && userId && !parsed[identity.userName]) {
1000
+ parsed[identity.userName] = userId;
1001
+ }
1002
+ return parsed;
1003
+ })();
580
1004
  const agentId = cfg.agentId || ctx?.agentId || "main";
581
1005
 
582
1006
  const payload = {
@@ -594,6 +1018,7 @@ async function addMemory(messages, cfg, ctx, log) {
594
1018
  metadata: JSON.stringify({
595
1019
  user_ids: metadataUserIds,
596
1020
  agent_ids: [agentId],
1021
+ name_id_map: nameIdMap,
597
1022
  session_id: sessionId,
598
1023
  scenario: cfg.scenario || "openclaw-plugin",
599
1024
  }),
@@ -603,7 +1028,7 @@ async function addMemory(messages, cfg, ctx, log) {
603
1028
 
604
1029
  if (log?.debug) {
605
1030
  log.debug(
606
- `[Memory Plugin] add/message payload: user_id=${userId}, agent_id=${agentId}, conversation_id=${sessionId}, metadata.user_ids=${JSON.stringify(metadataUserIds)}, metadata.agent_ids=${JSON.stringify([agentId])}`
1031
+ `[Memory Plugin] add/message payload: user_id=${userId}, agent_id=${agentId}, conversation_id=${sessionId}, metadata.user_ids=${JSON.stringify(metadataUserIds)}, metadata.agent_ids=${JSON.stringify([agentId])}, metadata.name_id_map=${JSON.stringify(nameIdMap)}`
607
1032
  );
608
1033
  }
609
1034
 
@@ -753,8 +1178,8 @@ function formatMemoriesForContext(memories, options = {}) {
753
1178
  * @param {number} maxTurns - Maximum number of turns to extract
754
1179
  * @returns {Array} Recent messages
755
1180
  */
756
- function pickRecentMessages(messages, maxTurns = 10) {
757
- if (!messages || messages.length === 0) return [];
1181
+ function pickRecentMessages(messages, maxTurns = 10) {
1182
+ if (!messages || messages.length === 0) return [];
758
1183
 
759
1184
  const result = [];
760
1185
  let turnCount = 0;
@@ -769,7 +1194,7 @@ function pickRecentMessages(messages, maxTurns = 10) {
769
1194
  if (role === "system") continue;
770
1195
 
771
1196
  // Count a turn when we see a user message after an assistant message
772
- if (role === "user" && lastRole === "assistant") {
1197
+ if (role === "user" && isAssistantLikeRole(lastRole)) {
773
1198
  turnCount++;
774
1199
  }
775
1200
 
@@ -795,9 +1220,230 @@ function pickRecentMessages(messages, maxTurns = 10) {
795
1220
  lastRole = role;
796
1221
  }
797
1222
 
798
- return result;
1223
+ return result;
1224
+ }
1225
+
1226
+ function buildV2ConversationMessage(msg) {
1227
+ if (!msg) return null;
1228
+
1229
+ const role = normalizeCacheRole(msg.role);
1230
+ if (role === "system") return null;
1231
+ if (role !== "user" && role !== "assistant" && role !== "tool") return null;
1232
+
1233
+ const rawSource = msg.rawContent !== undefined ? msg.rawContent : msg.content;
1234
+ const rawContent = typeof rawSource === "string" ? rawSource : extractText(rawSource);
1235
+ const content =
1236
+ typeof msg.content === "string"
1237
+ ? msg.content
1238
+ : extractText(msg.content);
1239
+ const toolCalls = role === "assistant" ? getMessageToolCalls(msg) : [];
1240
+ const toolCallId = role === "tool" ? getToolResultCallId(msg) : null;
1241
+ const toolName = role === "tool" ? getToolResultName(msg) : undefined;
1242
+ const hasToolContext =
1243
+ (role === "assistant" && toolCalls.length > 0) ||
1244
+ (role === "tool" && (!!toolCallId || !!toolName));
1245
+
1246
+ if (!content && !hasToolContext) return null;
1247
+
1248
+ const result = {
1249
+ role,
1250
+ content: content || "",
1251
+ rawContent: rawContent || undefined,
1252
+ };
1253
+
1254
+ if (toolCalls.length > 0) result.tool_calls = toolCalls;
1255
+ if (toolCallId) result.tool_call_id = toolCallId;
1256
+ if (toolName) result.name = toolName;
1257
+
1258
+ return result;
1259
+ }
1260
+
1261
+ function pickRecentContextMessagesV2(messages, maxTurns = 10) {
1262
+ if (!messages || messages.length === 0) return [];
1263
+
1264
+ const result = [];
1265
+ let turnCount = 0;
1266
+ let lastRole = null;
1267
+
1268
+ for (let i = messages.length - 1; i >= 0 && turnCount < maxTurns; i--) {
1269
+ const msg = messages[i];
1270
+ const role = normalizeCacheRole(msg?.role);
1271
+
1272
+ if (role === "system") continue;
1273
+
1274
+ if (role === "user" && isAssistantLikeRole(lastRole)) {
1275
+ turnCount++;
1276
+ }
1277
+
1278
+ if (turnCount >= maxTurns) break;
1279
+
1280
+ const contextMessage = buildV2ConversationMessage(msg);
1281
+ if (contextMessage) {
1282
+ result.unshift(contextMessage);
1283
+ }
1284
+
1285
+ lastRole = role;
1286
+ }
1287
+
1288
+ return result;
1289
+ }
1290
+
1291
+ function stripToolMessagesForV1(messages) {
1292
+ if (!Array.isArray(messages)) return [];
1293
+
1294
+ return messages
1295
+ .filter((msg) => msg && (msg.role === "user" || msg.role === "assistant"))
1296
+ .filter((msg) => String(msg.content || "").trim())
1297
+ .map((msg) => ({
1298
+ role: msg.role,
1299
+ content: msg.content,
1300
+ rawContent: msg.rawContent,
1301
+ }));
1302
+ }
1303
+
1304
+ /**
1305
+ * Extract tool calls from cached messages for the v2 context protocol.
1306
+ * @param {Array} messages - All cached messages
1307
+ * @returns {Array} Tool call records
1308
+ */
1309
+ function extractToolCalls(messages) {
1310
+ if (!messages || messages.length === 0) return [];
1311
+
1312
+ const calls = [];
1313
+ for (const msg of messages) {
1314
+ if (msg.role === "assistant") {
1315
+ const toolCalls = getMessageToolCalls(msg);
1316
+ for (const tc of toolCalls) {
1317
+ calls.push({
1318
+ tool_name: tc.function?.name || tc.name || "unknown",
1319
+ arguments: tc.function?.arguments || tc.arguments || {},
1320
+ call_id: tc.id || null,
1321
+ result: null,
1322
+ success: null,
1323
+ duration_ms: null,
1324
+ });
1325
+ }
1326
+ }
1327
+
1328
+ if (msg.role === "tool") {
1329
+ const toolCallId = getToolResultCallId(msg);
1330
+ const toolName = getToolResultName(msg);
1331
+ const match = calls.find((call) =>
1332
+ (toolCallId && call.call_id === toolCallId) ||
1333
+ (!toolCallId && toolName && call.tool_name === toolName && call.result == null)
1334
+ );
1335
+ if (match) {
1336
+ const resultText = extractText(msg.rawContent !== undefined ? msg.rawContent : msg.content);
1337
+ match.result = truncate(resultText, 2000);
1338
+ match.success = !isErrorResult(resultText);
1339
+ }
1340
+ }
1341
+ }
1342
+
1343
+ return calls;
799
1344
  }
800
1345
 
1346
+ /**
1347
+ * Check if text looks like an error result
1348
+ * @param {string} text - Result text
1349
+ * @returns {boolean}
1350
+ */
1351
+ function isErrorResult(text) {
1352
+ if (!text) return false;
1353
+ const lower = String(text).toLowerCase();
1354
+ return lower.includes("error") || lower.includes("exception") ||
1355
+ lower.includes("failed") || lower.includes("traceback") ||
1356
+ lower.includes("enoent") || lower.includes("permission denied");
1357
+ }
1358
+
1359
+ /**
1360
+ * Collect context blocks from messages (v2 protocol)
1361
+ * @param {Array} messages - All cached messages
1362
+ * @param {Object} cfg - Configuration
1363
+ * @returns {Array} Context blocks
1364
+ */
1365
+ function collectContextBlocks(messages, cfg) {
1366
+ const blocks = [];
1367
+
1368
+ const conversationMsgs =
1369
+ cfg.captureToolCalls === false
1370
+ ? pickRecentMessages(messages, cfg.maxTurnsToStore || 10)
1371
+ : pickRecentContextMessagesV2(messages, cfg.maxTurnsToStore || 10);
1372
+ if (conversationMsgs.length > 0) {
1373
+ blocks.push({
1374
+ type: "conversation",
1375
+ data: { messages: conversationMsgs },
1376
+ });
1377
+ }
1378
+
1379
+ return blocks;
1380
+ }
1381
+
1382
+ /**
1383
+ * Add context via v2 protocol
1384
+ */
1385
+ async function addContextV2(contextBlocks, cfg, ctx, log, sessionId) {
1386
+ const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
1387
+ const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
1388
+
1389
+ if (!apiKey) return;
1390
+
1391
+ const url = `${baseUrl}/api/plugin/v2/add/context`;
1392
+ const latestUserText = getLatestUserMessageText(
1393
+ contextBlocks.find((block) => block.type === "conversation")?.data?.messages || []
1394
+ );
1395
+ const identity = resolveRequestIdentity(latestUserText, cfg);
1396
+ const userId = sanitizeUserId(identity.userId);
1397
+ const agentId = cfg.agentId || ctx?.agentId || "main";
1398
+
1399
+ const payload = {
1400
+ user_id: userId,
1401
+ conversation_id: sessionId,
1402
+ agent_id: agentId,
1403
+ tags: cfg.tags || ["openclaw"],
1404
+ async_mode: true,
1405
+ protocol_version: "2.0",
1406
+ context_blocks: contextBlocks,
1407
+ };
1408
+
1409
+ if (log?.debug) {
1410
+ log.debug(
1411
+ `[Memory Plugin] v2 add/context: blocks=${contextBlocks.length}, types=${contextBlocks.map((block) => block.type).join(",")}`
1412
+ );
1413
+ }
1414
+
1415
+ try {
1416
+ const result = await httpRequest(url, {
1417
+ method: "POST",
1418
+ headers: {
1419
+ "Content-Type": "application/json",
1420
+ "x-api-key": apiKey,
1421
+ "x-request-id": ctx?.requestId || `openclaw-${Date.now()}`,
1422
+ "x-plugin-version": PLUGIN_VERSION,
1423
+ "x-client-type": "plugin",
1424
+ },
1425
+ body: JSON.stringify(payload),
1426
+ }, cfg, log);
1427
+
1428
+ const memoryCount = result?.memories_count || 0;
1429
+ if (log?.info) {
1430
+ log.info(
1431
+ `[Memory Plugin] v2 add/context success: ${memoryCount} memories from ${result?.blocks_processed || 0} blocks`
1432
+ );
1433
+ }
1434
+ return result;
1435
+ } catch (error) {
1436
+ if (log?.warn) {
1437
+ log.warn(`[Memory Plugin] v2 add/context failed, falling back to v1: ${error.message}`);
1438
+ }
1439
+ const conversationBlock = contextBlocks.find((block) => block.type === "conversation");
1440
+ if (conversationBlock) {
1441
+ return await addMemory(stripToolMessagesForV1(conversationBlock.data.messages), cfg, ctx, log, sessionId);
1442
+ }
1443
+ throw error;
1444
+ }
1445
+ }
1446
+
801
1447
  /**
802
1448
  * Check if conversation has enough turns to be worth storing
803
1449
  * @param {Array} messages - Messages to check
@@ -817,7 +1463,7 @@ function isConversationWorthStoring(messages, cfg) {
817
1463
  if (msg.role === "system") continue;
818
1464
 
819
1465
  if (msg.role === "user") {
820
- if (lastRole === "assistant") {
1466
+ if (isAssistantLikeRole(lastRole)) {
821
1467
  turns++;
822
1468
  }
823
1469
  }
@@ -852,13 +1498,39 @@ function getSessionCache(sessionId) {
852
1498
  */
853
1499
  function addToSessionCache(sessionId, message) {
854
1500
  const cache = getSessionCache(sessionId);
855
- cache.messages.push(message);
1501
+ const now = Date.now();
1502
+ const incoming = {
1503
+ ...message,
1504
+ cachedAt: now,
1505
+ };
1506
+
1507
+ const prev = cache.messages.length > 0 ? cache.messages[cache.messages.length - 1] : null;
1508
+ if (prev) {
1509
+ const sameRole = prev.role === incoming.role;
1510
+ const sameMessageId =
1511
+ incoming.messageId &&
1512
+ prev.messageId &&
1513
+ String(incoming.messageId) === String(prev.messageId);
1514
+ const sameContent =
1515
+ sameRole &&
1516
+ String(prev.content || "") === String(incoming.content || "") &&
1517
+ String(prev.rawContent || "") === String(incoming.rawContent || "");
1518
+ const withinWindow = now - (prev.cachedAt || 0) <= CACHE_DEDUP_WINDOW_MS;
1519
+
1520
+ // Dedup duplicate hook triggers for the same relay message.
1521
+ if ((sameRole && sameMessageId) || (sameContent && withinWindow)) {
1522
+ cache.lastActivity = now;
1523
+ return cache;
1524
+ }
1525
+ }
1526
+
1527
+ cache.messages.push(incoming);
856
1528
  cache.lastActivity = Date.now();
857
1529
 
858
1530
  // Count turns (user message after assistant = new turn)
859
- if (message.role === "user" && cache.messages.length > 1) {
1531
+ if (incoming.role === "user" && cache.messages.length > 1) {
860
1532
  const prevMsg = cache.messages[cache.messages.length - 2];
861
- if (prevMsg && prevMsg.role === "assistant") {
1533
+ if (prevMsg && isAssistantLikeRole(prevMsg.role)) {
862
1534
  cache.turnCount++;
863
1535
  }
864
1536
  }
@@ -970,6 +1642,15 @@ async function flushSession(sessionId, cfg, ctx, log) {
970
1642
  if (log?.info) {
971
1643
  log.info(`[Memory Plugin] Flushing session ${sessionId}: ${messagesToSave.length} messages, ${cache.turnCount} turns`);
972
1644
  }
1645
+
1646
+ if (cfg.useV2Protocol !== false) {
1647
+ const contextBlocks = collectContextBlocks(cache.messages, cfg);
1648
+ if (contextBlocks.length > 0) {
1649
+ await addContextV2(contextBlocks, cfg, ctx, log, sessionId);
1650
+ return;
1651
+ }
1652
+ }
1653
+
973
1654
  await addMemory(messagesToSave, cfg, ctx, log, sessionId);
974
1655
  } catch (error) {
975
1656
  if (log?.warn) {
@@ -1021,8 +1702,14 @@ function registerPlugin(api) {
1021
1702
  const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1022
1703
  const userContent = normalizeUserMessageContent(prompt);
1023
1704
  const rawUserContent = stripPrependedPrompt(prompt);
1705
+ const messageId = extractRelayMessageId(rawUserContent || prompt);
1024
1706
  if (userContent) {
1025
- addToSessionCache(sessionId, { role: "user", content: userContent, rawContent: rawUserContent });
1707
+ addToSessionCache(sessionId, {
1708
+ role: "user",
1709
+ content: userContent,
1710
+ rawContent: rawUserContent,
1711
+ messageId: messageId || undefined,
1712
+ });
1026
1713
  }
1027
1714
 
1028
1715
  try {
@@ -1070,22 +1757,28 @@ function registerPlugin(api) {
1070
1757
  return;
1071
1758
  }
1072
1759
 
1073
- const assistantContent = event?.response || event?.result;
1074
- if (assistantContent) {
1075
- const content = normalizeAssistantMessageContent(assistantContent);
1076
- const rawContent = extractText(assistantContent);
1077
- if (content) {
1078
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1079
- }
1080
- } else if (event?.messages?.length) {
1760
+ if (event?.messages?.length) {
1761
+ const pending = [];
1081
1762
  for (let i = event.messages.length - 1; i >= 0; i--) {
1082
- if (event.messages[i].role === "assistant") {
1083
- const content = normalizeAssistantMessageContent(event.messages[i].content);
1084
- const rawContent = extractText(event.messages[i].content);
1085
- if (content) {
1086
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1087
- }
1088
- break;
1763
+ const role = event.messages[i].role;
1764
+ if (role === "system") continue;
1765
+ if (role === "user") break;
1766
+
1767
+ const cacheMessage = buildCacheMessageFromTranscript(event.messages[i]);
1768
+ if (cacheMessage) pending.unshift(cacheMessage);
1769
+ }
1770
+ for (const cacheMessage of pending) {
1771
+ addToSessionCache(sessionId, cacheMessage);
1772
+ }
1773
+ } else {
1774
+ const assistantContent = event?.response || event?.result;
1775
+ if (assistantContent) {
1776
+ const cacheMessage = buildCacheMessageFromTranscript({
1777
+ role: "assistant",
1778
+ content: assistantContent,
1779
+ });
1780
+ if (cacheMessage) {
1781
+ addToSessionCache(sessionId, cacheMessage);
1089
1782
  }
1090
1783
  }
1091
1784
  }
@@ -1153,8 +1846,14 @@ function createHooksPlugin(config) {
1153
1846
  const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1154
1847
  const userContent = normalizeUserMessageContent(prompt);
1155
1848
  const rawUserContent = stripPrependedPrompt(prompt);
1849
+ const messageId = extractRelayMessageId(rawUserContent || prompt);
1156
1850
  if (userContent) {
1157
- addToSessionCache(sessionId, { role: "user", content: userContent, rawContent: rawUserContent });
1851
+ addToSessionCache(sessionId, {
1852
+ role: "user",
1853
+ content: userContent,
1854
+ rawContent: rawUserContent,
1855
+ messageId: messageId || undefined,
1856
+ });
1158
1857
  }
1159
1858
 
1160
1859
  try {
@@ -1219,37 +1918,37 @@ function createHooksPlugin(config) {
1219
1918
  const cache = getSessionCache(sessionId);
1220
1919
  if (cache.messages.length === 0) {
1221
1920
  for (const msg of event.messages) {
1222
- if (msg.role === "system") continue;
1223
- const content = msg.role === "user"
1224
- ? normalizeUserMessageContent(msg.content)
1225
- : normalizeAssistantMessageContent(msg.content);
1226
- const rawSource = msg.role === "user"
1227
- ? stripPrependedPrompt(msg.content)
1228
- : extractText(msg.content);
1229
- if (content) {
1230
- addToSessionCache(sessionId, { role: msg.role, content, rawContent: rawSource || undefined });
1921
+ const cacheMessage = buildCacheMessageFromTranscript(msg);
1922
+ if (cacheMessage) {
1923
+ addToSessionCache(sessionId, cacheMessage);
1231
1924
  }
1232
1925
  }
1233
1926
  } else {
1234
- // Only add last assistant message
1927
+ // Add trailing assistant/tool messages from the latest completion.
1928
+ const pending = [];
1235
1929
  for (let i = event.messages.length - 1; i >= 0; i--) {
1236
- if (event.messages[i].role === "assistant") {
1237
- const content = normalizeAssistantMessageContent(event.messages[i].content);
1238
- const rawContent = extractText(event.messages[i].content);
1239
- if (content) {
1240
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1241
- }
1242
- break;
1930
+ const msg = event.messages[i];
1931
+ if (msg.role === "system") continue;
1932
+ if (msg.role === "user") break;
1933
+
1934
+ const cacheMessage = buildCacheMessageFromTranscript(msg);
1935
+ if (cacheMessage) {
1936
+ pending.unshift(cacheMessage);
1243
1937
  }
1244
1938
  }
1939
+ for (const cacheMessage of pending) {
1940
+ addToSessionCache(sessionId, cacheMessage);
1941
+ }
1245
1942
  }
1246
1943
  } else {
1247
1944
  const assistantContent = event?.response || event?.result;
1248
1945
  if (assistantContent) {
1249
- const content = normalizeAssistantMessageContent(assistantContent);
1250
- const rawContent = extractText(assistantContent);
1251
- if (content) {
1252
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1946
+ const cacheMessage = buildCacheMessageFromTranscript({
1947
+ role: "assistant",
1948
+ content: assistantContent,
1949
+ });
1950
+ if (cacheMessage) {
1951
+ addToSessionCache(sessionId, cacheMessage);
1253
1952
  }
1254
1953
  }
1255
1954
  }
@@ -3,7 +3,7 @@
3
3
  "id": "human-like-mem",
4
4
  "name": "Human-Like Memory Plugin",
5
5
  "description": "Long-term memory plugin with automatic recall and storage.",
6
- "version": "0.3.11",
6
+ "version": "0.3.13",
7
7
  "kind": "lifecycle",
8
8
  "main": "./index.js",
9
9
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanlikememory/human-like-mem",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Long-term memory plugin for OpenClaw - AI Social Memory Integration",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -50,7 +50,7 @@
50
50
  "jest": "^29.7.0",
51
51
  "prettier": "^3.2.0"
52
52
  },
53
- "publishConfig": {
54
- "access": "public"
55
- }
56
- }
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }