@humanlikememory/human-like-mem 0.3.12 → 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/index.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * @license Apache-2.0
7
7
  */
8
8
 
9
- const PLUGIN_VERSION = "0.3.4";
10
- const USER_QUERY_MARKER = "--- User Query ---";
11
- const CACHE_DEDUP_WINDOW_MS = 4000;
9
+ const PLUGIN_VERSION = "0.3.4";
10
+ const USER_QUERY_MARKER = "--- User Query ---";
11
+ const CACHE_DEDUP_WINDOW_MS = 4000;
12
12
 
13
13
  /**
14
14
  * Session cache for tracking conversation history
@@ -60,6 +60,127 @@ function extractText(content) {
60
60
  return "";
61
61
  }
62
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
+
63
184
  /**
64
185
  * Strip prepended prompt markers from message content
65
186
  */
@@ -115,101 +236,101 @@ function extractFeishuTailBlock(text) {
115
236
  /**
116
237
  * Whether text is only transport metadata rather than a real utterance.
117
238
  */
118
- function isMetadataOnlyText(text) {
119
- if (!text) return true;
120
- const value = String(text).trim();
121
- if (!value) return true;
122
- if (/^\[message_id:\s*[^\]]+\]$/i.test(value)) return true;
123
- if (/^\[\[reply_to[^\]]*\]\]$/i.test(value)) return true;
124
- return false;
125
- }
126
-
127
- /**
128
- * Remove injected relay metadata blocks/lines from user content.
129
- */
130
- function stripInjectedMetadata(text) {
131
- if (!text || typeof text !== "string") return "";
132
-
133
- let result = text.replace(/\r\n/g, "\n");
134
-
135
- // Remove labeled untrusted metadata JSON blocks.
136
- // Examples:
137
- // Conversation info (untrusted metadata): ```json ... ```
138
- // Sender (untrusted metadata): ```json ... ```
139
- result = result.replace(
140
- /(^|\n)[^\n]*\(untrusted metadata(?:,\s*for context)?\)\s*:\s*```json[\s\S]*?```(?=\n|$)/gi,
141
- "\n"
142
- );
143
-
144
- // Remove transport/system hint lines that are not user utterances.
145
- result = result.replace(/^\[System:[^\n]*\]\s*$/gim, "");
146
- result = result.replace(/^\[message_id:\s*[^\]]+\]\s*$/gim, "");
147
-
148
- // Collapse extra blank lines.
149
- result = result.replace(/\n{3,}/g, "\n\n").trim();
150
- return result;
151
- }
152
-
153
- /**
154
- * Keep the latest meaningful line from cleaned metadata-injected payload.
155
- */
156
- function extractLatestUtteranceFromCleanText(text) {
157
- if (!text || typeof text !== "string") return "";
158
- const lines = text
159
- .split("\n")
160
- .map((line) => line.trim())
161
- .filter(Boolean)
162
- .filter((line) => !isMetadataOnlyText(line));
163
- if (lines.length === 0) return "";
164
- return lines[lines.length - 1];
165
- }
166
-
167
- function escapeRegex(text) {
168
- return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
169
- }
170
-
171
- /**
172
- * Prefix utterance with parsed sender name when available.
173
- * This preserves who said it in multi-user group contexts.
174
- */
175
- function attachSenderPrefix(utterance, senderName) {
176
- const content = String(utterance || "").trim();
177
- if (!content) return "";
178
- const sender = String(senderName || "").trim();
179
- if (!sender) return content;
180
-
181
- const senderRegex = new RegExp(`^${escapeRegex(sender)}\\s*[::]`);
182
- if (senderRegex.test(content)) return content;
183
- return `${sender}: ${content}`;
184
- }
239
+ function isMetadataOnlyText(text) {
240
+ if (!text) return true;
241
+ const value = String(text).trim();
242
+ if (!value) return true;
243
+ if (/^\[message_id:\s*[^\]]+\]$/i.test(value)) return true;
244
+ if (/^\[\[reply_to[^\]]*\]\]$/i.test(value)) return true;
245
+ return false;
246
+ }
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
+ }
185
306
 
186
307
  /**
187
308
  * Normalize user message text before caching/storing.
188
309
  * For channel-formatted payloads (e.g. Feishu), keep only the actual user utterance
189
310
  * and drop prepended system transcript lines.
190
311
  */
191
- function normalizeUserMessageContent(content) {
192
- const text = stripPrependedPrompt(content);
193
- if (!text) return "";
194
-
195
- const normalized = String(text).replace(/\r\n/g, "\n").trim();
196
- if (!normalized) return "";
197
-
198
- const parsedIdentity = parseIdentityFromUntrustedMetadata(normalized);
199
-
200
- // New relay payload format: strip injected metadata blocks first.
201
- const strippedMetadata = stripInjectedMetadata(normalized);
202
- if (strippedMetadata && strippedMetadata !== normalized) {
203
- const latestUtterance = extractLatestUtteranceFromCleanText(strippedMetadata);
204
- if (latestUtterance && !isMetadataOnlyText(latestUtterance)) {
205
- return attachSenderPrefix(latestUtterance, parsedIdentity?.userName);
206
- }
207
- }
208
-
209
- // Feishu: preserve the final traceable tail block (contains platform user id/message_id).
210
- const feishuTailBlock = extractFeishuTailBlock(normalized);
211
- if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
212
- return feishuTailBlock;
312
+ function normalizeUserMessageContent(content) {
313
+ const text = stripPrependedPrompt(content);
314
+ if (!text) return "";
315
+
316
+ const normalized = String(text).replace(/\r\n/g, "\n").trim();
317
+ if (!normalized) return "";
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
+
330
+ // Feishu: preserve the final traceable tail block (contains platform user id/message_id).
331
+ const feishuTailBlock = extractFeishuTailBlock(normalized);
332
+ if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
333
+ return feishuTailBlock;
213
334
  }
214
335
 
215
336
  // Generic channel relays: fallback to latest "System: ...: <message>" line.
@@ -231,18 +352,18 @@ function normalizeUserMessageContent(content) {
231
352
 
232
353
  // Discord-style channel-formatted payload:
233
354
  // [from: username (id)] actual message
234
- const discordTail = normalized.match(
235
- /\[from:\s*[^\(\]\n]+?\s*\(\d{6,}\)\]\s*([\s\S]*?)$/i
236
- );
237
- if (discordTail && discordTail[1]) {
238
- const candidate = discordTail[1].trim();
239
- if (!isMetadataOnlyText(candidate)) return candidate;
240
- }
241
-
242
- return isMetadataOnlyText(normalized)
243
- ? ""
244
- : attachSenderPrefix(normalized, parsedIdentity?.userName);
245
- }
355
+ const discordTail = normalized.match(
356
+ /\[from:\s*[^\(\]\n]+?\s*\(\d{6,}\)\]\s*([\s\S]*?)$/i
357
+ );
358
+ if (discordTail && discordTail[1]) {
359
+ const candidate = discordTail[1].trim();
360
+ if (!isMetadataOnlyText(candidate)) return candidate;
361
+ }
362
+
363
+ return isMetadataOnlyText(normalized)
364
+ ? ""
365
+ : attachSenderPrefix(normalized, parsedIdentity?.userName);
366
+ }
246
367
 
247
368
  /**
248
369
  * Normalize assistant message text before caching/storing.
@@ -256,139 +377,139 @@ function normalizeAssistantMessageContent(content) {
256
377
  return normalized;
257
378
  }
258
379
 
259
- /**
260
- * Parse JSON objects from markdown code fences after a specific label.
261
- * Example:
262
- * Sender (untrusted metadata):
263
- * ```json
264
- * { "id": "123" }
265
- * ```
266
- */
267
- function parseJsonFencesAfterLabel(text, labelPattern) {
268
- if (!text || typeof text !== "string") return [];
269
-
270
- const results = [];
271
- const regex = new RegExp(`${labelPattern}\\s*:\\s*\`\`\`json\\s*([\\s\\S]*?)\\s*\`\`\``, "gi");
272
- let match;
273
- while ((match = regex.exec(text)) !== null) {
274
- if (!match[1]) continue;
275
- try {
276
- const parsed = JSON.parse(match[1]);
277
- if (parsed && typeof parsed === "object") {
278
- results.push(parsed);
279
- }
280
- } catch (_) {
281
- // Ignore malformed JSON blocks and continue matching others.
282
- }
283
- }
284
- return results;
285
- }
286
-
287
- /**
288
- * Normalize potential user id values into non-empty strings.
289
- */
290
- function normalizeParsedUserId(value) {
291
- if (value === undefined || value === null) return "";
292
- const normalized = String(value).trim();
293
- return normalized || "";
294
- }
295
-
296
- /**
297
- * Extract user id from a display label like:
298
- * "用户250389 (ou_xxx)" / "name (123456789)".
299
- */
300
- function extractUserIdFromLabel(label) {
301
- const text = normalizeParsedUserId(label);
302
- if (!text) return "";
303
- const match = text.match(/\(\s*((?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)$/i);
304
- return match?.[1] ? match[1] : "";
305
- }
306
-
307
- /**
308
- * Extract transport message id from current relay payload.
309
- */
310
- function extractRelayMessageId(text) {
311
- if (!text || typeof text !== "string") return "";
312
-
313
- const conversationMetaList = parseJsonFencesAfterLabel(
314
- text,
315
- "Conversation info \\(untrusted metadata\\)"
316
- );
317
- for (let i = conversationMetaList.length - 1; i >= 0; i--) {
318
- const conversationMeta = conversationMetaList[i];
319
- const messageId =
320
- normalizeParsedUserId(conversationMeta?.message_id) ||
321
- normalizeParsedUserId(conversationMeta?.messageId);
322
- if (messageId) return messageId;
323
- }
324
-
325
- const messageIdMatch = text.match(/\[message_id:\s*([^\]\n]+)\]/i);
326
- if (messageIdMatch?.[1]) return messageIdMatch[1].trim();
327
-
328
- return "";
329
- }
330
-
331
- /**
332
- * Parse identity from new "untrusted metadata" JSON blocks.
333
- * Priority:
334
- * 1) Sender (untrusted metadata).id
335
- * 2) Conversation info (untrusted metadata).sender_id
336
- */
337
- function parseIdentityFromUntrustedMetadata(text) {
338
- if (!text || typeof text !== "string") return null;
339
-
340
- const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
341
- for (let i = senderMetaList.length - 1; i >= 0; i--) {
342
- const senderMeta = senderMetaList[i];
343
- const userId =
344
- normalizeParsedUserId(senderMeta?.id) ||
345
- extractUserIdFromLabel(senderMeta?.label);
346
- if (userId) {
347
- return {
348
- platform: null,
349
- userId,
350
- userName: senderMeta?.name || senderMeta?.username || senderMeta?.label || null,
351
- source: "sender-untrusted-metadata-json",
352
- };
353
- }
354
- }
355
-
356
- const conversationMetaList = parseJsonFencesAfterLabel(
357
- text,
358
- "Conversation info \\(untrusted metadata\\)"
359
- );
360
- for (let i = conversationMetaList.length - 1; i >= 0; i--) {
361
- const conversationMeta = conversationMetaList[i];
362
- const userId =
363
- normalizeParsedUserId(conversationMeta?.sender_id) ||
364
- normalizeParsedUserId(conversationMeta?.senderId);
365
- if (userId) {
366
- return {
367
- platform: null,
368
- userId,
369
- userName: conversationMeta?.sender || null,
370
- source: "conversation-untrusted-metadata-json",
371
- };
372
- }
373
- }
374
-
375
- return null;
376
- }
377
-
378
- /**
379
- * Parse platform identity hints from channel-formatted message text.
380
- * Returns null when no platform-specific id can be extracted.
381
- */
382
- function parsePlatformIdentity(text) {
383
- if (!text || typeof text !== "string") return null;
384
-
385
- // New Discord format first:
386
- // Sender (untrusted metadata): ```json { "id": "116..." } ```
387
- // Conversation info (untrusted metadata): ```json { "sender_id": "116..." } ```
388
- const metadataIdentity = parseIdentityFromUntrustedMetadata(text);
389
- if (metadataIdentity?.userId) {
390
- return metadataIdentity;
391
- }
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
+
499
+ /**
500
+ * Parse platform identity hints from channel-formatted message text.
501
+ * Returns null when no platform-specific id can be extracted.
502
+ */
503
+ function parsePlatformIdentity(text) {
504
+ if (!text || typeof text !== "string") return null;
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
+ }
392
513
 
393
514
  // Discord example:
394
515
  // [from: huang yongqing (1470374017541079042)]
@@ -420,39 +541,39 @@ function parsePlatformIdentity(text) {
420
541
  /**
421
542
  * Parse all platform user ids from a text blob.
422
543
  */
423
- function parseAllPlatformUserIds(text) {
424
- if (!text || typeof text !== "string") return [];
425
-
426
- const ids = [];
427
- let match;
428
-
429
- // New format: "Sender (untrusted metadata)" JSON blocks.
430
- const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
431
- for (const senderMeta of senderMetaList) {
432
- const parsedId =
433
- normalizeParsedUserId(senderMeta?.id) ||
434
- extractUserIdFromLabel(senderMeta?.label);
435
- if (parsedId) ids.push(parsedId);
436
- }
437
-
438
- // New format: "Conversation info (untrusted metadata)" JSON blocks.
439
- const conversationMetaList = parseJsonFencesAfterLabel(
440
- text,
441
- "Conversation info \\(untrusted metadata\\)"
442
- );
443
- for (const conversationMeta of conversationMetaList) {
444
- const parsedId =
445
- normalizeParsedUserId(conversationMeta?.sender_id) ||
446
- normalizeParsedUserId(conversationMeta?.senderId);
447
- if (parsedId) ids.push(parsedId);
448
- }
449
-
450
- // Discord example:
451
- // [from: huang yongqing (1470374017541079042)]
452
- const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
453
- while ((match = discordRegex.exec(text)) !== null) {
454
- if (match[1]) ids.push(match[1]);
455
- }
544
+ function parseAllPlatformUserIds(text) {
545
+ if (!text || typeof text !== "string") return [];
546
+
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
+ }
570
+
571
+ // Discord example:
572
+ // [from: huang yongqing (1470374017541079042)]
573
+ const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
574
+ while ((match = discordRegex.exec(text)) !== null) {
575
+ if (match[1]) ids.push(match[1]);
576
+ }
456
577
 
457
578
  // Feishu example:
458
579
  // [Feishu ...:ou_17b624... Wed ...] username: message
@@ -467,8 +588,8 @@ function parseAllPlatformUserIds(text) {
467
588
  /**
468
589
  * Collect distinct user ids parsed from all messages.
469
590
  */
470
- function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
471
- const unique = new Set();
591
+ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
592
+ const unique = new Set();
472
593
 
473
594
  if (Array.isArray(messages)) {
474
595
  for (const msg of messages) {
@@ -484,96 +605,96 @@ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
484
605
  unique.add(fallbackUserId);
485
606
  }
486
607
 
487
- return Array.from(unique);
488
- }
489
-
490
- function extractUserNameFromLabel(label) {
491
- const text = normalizeParsedUserId(label);
492
- if (!text) return "";
493
- const match = text.match(
494
- /^(.*?)\s*\(\s*(?:(?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)\s*$/i
495
- );
496
- return match?.[1] ? match[1].trim() : text;
497
- }
498
-
499
- /**
500
- * Parse name-id pairs from supported relay payload formats.
501
- */
502
- function parseNameIdPairsFromText(text) {
503
- if (!text || typeof text !== "string") return [];
504
- const pairs = [];
505
-
506
- // New format: Sender (untrusted metadata)
507
- const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
508
- for (const senderMeta of senderMetaList) {
509
- const id =
510
- normalizeParsedUserId(senderMeta?.id) ||
511
- extractUserIdFromLabel(senderMeta?.label);
512
- const name =
513
- normalizeParsedUserId(senderMeta?.name) ||
514
- normalizeParsedUserId(senderMeta?.username) ||
515
- extractUserNameFromLabel(senderMeta?.label);
516
- if (name && id) pairs.push([name, id]);
517
- }
518
-
519
- // New format: Conversation info (untrusted metadata)
520
- const conversationMetaList = parseJsonFencesAfterLabel(
521
- text,
522
- "Conversation info \\(untrusted metadata\\)"
523
- );
524
- for (const conversationMeta of conversationMetaList) {
525
- const id =
526
- normalizeParsedUserId(conversationMeta?.sender_id) ||
527
- normalizeParsedUserId(conversationMeta?.senderId);
528
- const name = normalizeParsedUserId(conversationMeta?.sender);
529
- if (name && id) pairs.push([name, id]);
530
- }
531
-
532
- // Old Discord: [from: name (id)]
533
- const discordRegex = /\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/gi;
534
- let match;
535
- while ((match = discordRegex.exec(text)) !== null) {
536
- const name = normalizeParsedUserId(match[1]);
537
- const id = normalizeParsedUserId(match[2]);
538
- if (name && id) pairs.push([name, id]);
539
- }
540
-
541
- // Old Feishu: [Feishu ...:ou_xxx ...] name:
542
- const feishuRegex = /\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*([^:\n]+):/gi;
543
- while ((match = feishuRegex.exec(text)) !== null) {
544
- const id = normalizeParsedUserId(match[1]);
545
- const name = normalizeParsedUserId(match[2]);
546
- if (name && id) pairs.push([name, id]);
547
- }
548
-
549
- return pairs;
550
- }
551
-
552
- /**
553
- * Collect distinct name->id mapping from all messages.
554
- */
555
- function collectNameIdMapFromMessages(messages) {
556
- const map = {};
557
-
558
- if (!Array.isArray(messages)) return map;
559
-
560
- for (const msg of messages) {
561
- if (!msg) continue;
562
- const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
563
- const text = typeof sourceText === "string" ? sourceText : extractText(sourceText);
564
- const pairs = parseNameIdPairsFromText(text);
565
- for (const [name, id] of pairs) {
566
- if (!name || !id) continue;
567
- map[name] = id;
568
- }
569
- }
570
-
571
- return map;
572
- }
573
-
574
- /**
575
- * Get latest user message text from cached messages.
576
- */
608
+ return Array.from(unique);
609
+ }
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
+
695
+ /**
696
+ * Get latest user message text from cached messages.
697
+ */
577
698
  function getLatestUserMessageText(messages) {
578
699
  if (!Array.isArray(messages)) return "";
579
700
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -589,9 +710,9 @@ function getLatestUserMessageText(messages) {
589
710
  * Resolve request identity with fallback:
590
711
  * platform user id -> configured user id -> "openclaw-user"
591
712
  */
592
- function resolveRequestIdentity(promptText, cfg) {
593
- const parsed = parsePlatformIdentity(promptText);
594
- if (parsed?.userId) {
713
+ function resolveRequestIdentity(promptText, cfg) {
714
+ const parsed = parsePlatformIdentity(promptText);
715
+ if (parsed?.userId) {
595
716
  return {
596
717
  userId: parsed.userId,
597
718
  userName: parsed.userName || null,
@@ -600,7 +721,7 @@ function resolveRequestIdentity(promptText, cfg) {
600
721
  };
601
722
  }
602
723
 
603
- if (cfg?.configuredUserId) {
724
+ if (cfg?.configuredUserId) {
604
725
  return {
605
726
  userId: cfg.configuredUserId,
606
727
  userName: null,
@@ -609,8 +730,8 @@ function resolveRequestIdentity(promptText, cfg) {
609
730
  };
610
731
  }
611
732
 
612
- return {
613
- userId: "openclaw-user",
733
+ return {
734
+ userId: "openclaw-user",
614
735
  userName: null,
615
736
  platform: null,
616
737
  source: "default-user-id",
@@ -803,7 +924,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
803
924
  if (log?.info) {
804
925
  log.info(`[Memory Plugin] Recall request URL: ${url}`);
805
926
  }
806
- const identity = resolveRequestIdentity(prompt, cfg);
927
+ const identity = resolveRequestIdentity(prompt, cfg);
807
928
  const userId = sanitizeUserId(identity.userId);
808
929
  const payload = {
809
930
  query: prompt,
@@ -850,7 +971,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
850
971
  /**
851
972
  * Add memories to the API
852
973
  */
853
- async function addMemory(messages, cfg, ctx, log) {
974
+ async function addMemory(messages, cfg, ctx, log, explicitSessionId) {
854
975
  const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
855
976
  const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
856
977
 
@@ -860,27 +981,27 @@ async function addMemory(messages, cfg, ctx, log) {
860
981
  if (log?.info) {
861
982
  log.info(`[Memory Plugin] Add-memory request URL: ${url}`);
862
983
  }
863
- const sessionId = resolveSessionId(ctx, null) || `session-${Date.now()}`;
984
+ const sessionId = explicitSessionId || resolveSessionId(ctx, null) || `session-${Date.now()}`;
864
985
  const latestUserText = getLatestUserMessageText(messages);
865
- const identity = resolveRequestIdentity(latestUserText, cfg);
986
+ const identity = resolveRequestIdentity(latestUserText, cfg);
866
987
  const userId = sanitizeUserId(identity.userId);
867
- const metadataUserIds = (() => {
868
- const parsed = collectUniqueUserIdsFromMessages(messages, null)
869
- .map((id) => sanitizeUserId(id))
870
- .filter(Boolean);
871
- if (parsed.length > 0) {
872
- return Array.from(new Set(parsed));
873
- }
874
- return [userId];
875
- })();
876
- const nameIdMap = (() => {
877
- const parsed = collectNameIdMapFromMessages(messages);
878
- if (identity?.userName && userId && !parsed[identity.userName]) {
879
- parsed[identity.userName] = userId;
880
- }
881
- return parsed;
882
- })();
883
- const agentId = cfg.agentId || ctx?.agentId || "main";
988
+ const metadataUserIds = (() => {
989
+ const parsed = collectUniqueUserIdsFromMessages(messages, null)
990
+ .map((id) => sanitizeUserId(id))
991
+ .filter(Boolean);
992
+ if (parsed.length > 0) {
993
+ return Array.from(new Set(parsed));
994
+ }
995
+ return [userId];
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
+ })();
1004
+ const agentId = cfg.agentId || ctx?.agentId || "main";
884
1005
 
885
1006
  const payload = {
886
1007
  user_id: userId,
@@ -894,22 +1015,22 @@ async function addMemory(messages, cfg, ctx, log) {
894
1015
  async_mode: true,
895
1016
  custom_workflows: {
896
1017
  stream_params: {
897
- metadata: JSON.stringify({
898
- user_ids: metadataUserIds,
899
- agent_ids: [agentId],
900
- name_id_map: nameIdMap,
901
- session_id: sessionId,
902
- scenario: cfg.scenario || "openclaw-plugin",
903
- }),
904
- },
905
- },
1018
+ metadata: JSON.stringify({
1019
+ user_ids: metadataUserIds,
1020
+ agent_ids: [agentId],
1021
+ name_id_map: nameIdMap,
1022
+ session_id: sessionId,
1023
+ scenario: cfg.scenario || "openclaw-plugin",
1024
+ }),
1025
+ },
1026
+ },
906
1027
  };
907
1028
 
908
1029
  if (log?.debug) {
909
- log.debug(
910
- `[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)}`
911
- );
912
- }
1030
+ log.debug(
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)}`
1032
+ );
1033
+ }
913
1034
 
914
1035
  try {
915
1036
  const result = await httpRequest(url, {
@@ -1057,8 +1178,8 @@ function formatMemoriesForContext(memories, options = {}) {
1057
1178
  * @param {number} maxTurns - Maximum number of turns to extract
1058
1179
  * @returns {Array} Recent messages
1059
1180
  */
1060
- function pickRecentMessages(messages, maxTurns = 10) {
1061
- if (!messages || messages.length === 0) return [];
1181
+ function pickRecentMessages(messages, maxTurns = 10) {
1182
+ if (!messages || messages.length === 0) return [];
1062
1183
 
1063
1184
  const result = [];
1064
1185
  let turnCount = 0;
@@ -1073,7 +1194,7 @@ function pickRecentMessages(messages, maxTurns = 10) {
1073
1194
  if (role === "system") continue;
1074
1195
 
1075
1196
  // Count a turn when we see a user message after an assistant message
1076
- if (role === "user" && lastRole === "assistant") {
1197
+ if (role === "user" && isAssistantLikeRole(lastRole)) {
1077
1198
  turnCount++;
1078
1199
  }
1079
1200
 
@@ -1099,9 +1220,230 @@ function pickRecentMessages(messages, maxTurns = 10) {
1099
1220
  lastRole = role;
1100
1221
  }
1101
1222
 
1102
- 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;
1103
1344
  }
1104
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
+
1105
1447
  /**
1106
1448
  * Check if conversation has enough turns to be worth storing
1107
1449
  * @param {Array} messages - Messages to check
@@ -1121,7 +1463,7 @@ function isConversationWorthStoring(messages, cfg) {
1121
1463
  if (msg.role === "system") continue;
1122
1464
 
1123
1465
  if (msg.role === "user") {
1124
- if (lastRole === "assistant") {
1466
+ if (isAssistantLikeRole(lastRole)) {
1125
1467
  turns++;
1126
1468
  }
1127
1469
  }
@@ -1154,44 +1496,44 @@ function getSessionCache(sessionId) {
1154
1496
  /**
1155
1497
  * Add message to session cache
1156
1498
  */
1157
- function addToSessionCache(sessionId, message) {
1158
- const cache = getSessionCache(sessionId);
1159
- const now = Date.now();
1160
- const incoming = {
1161
- ...message,
1162
- cachedAt: now,
1163
- };
1164
-
1165
- const prev = cache.messages.length > 0 ? cache.messages[cache.messages.length - 1] : null;
1166
- if (prev) {
1167
- const sameRole = prev.role === incoming.role;
1168
- const sameMessageId =
1169
- incoming.messageId &&
1170
- prev.messageId &&
1171
- String(incoming.messageId) === String(prev.messageId);
1172
- const sameContent =
1173
- sameRole &&
1174
- String(prev.content || "") === String(incoming.content || "") &&
1175
- String(prev.rawContent || "") === String(incoming.rawContent || "");
1176
- const withinWindow = now - (prev.cachedAt || 0) <= CACHE_DEDUP_WINDOW_MS;
1177
-
1178
- // Dedup duplicate hook triggers for the same relay message.
1179
- if ((sameRole && sameMessageId) || (sameContent && withinWindow)) {
1180
- cache.lastActivity = now;
1181
- return cache;
1182
- }
1183
- }
1184
-
1185
- cache.messages.push(incoming);
1186
- cache.lastActivity = Date.now();
1187
-
1188
- // Count turns (user message after assistant = new turn)
1189
- if (incoming.role === "user" && cache.messages.length > 1) {
1190
- const prevMsg = cache.messages[cache.messages.length - 2];
1191
- if (prevMsg && prevMsg.role === "assistant") {
1192
- cache.turnCount++;
1193
- }
1194
- }
1499
+ function addToSessionCache(sessionId, message) {
1500
+ const cache = getSessionCache(sessionId);
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);
1528
+ cache.lastActivity = Date.now();
1529
+
1530
+ // Count turns (user message after assistant = new turn)
1531
+ if (incoming.role === "user" && cache.messages.length > 1) {
1532
+ const prevMsg = cache.messages[cache.messages.length - 2];
1533
+ if (prevMsg && isAssistantLikeRole(prevMsg.role)) {
1534
+ cache.turnCount++;
1535
+ }
1536
+ }
1195
1537
 
1196
1538
  return cache;
1197
1539
  }
@@ -1300,6 +1642,15 @@ async function flushSession(sessionId, cfg, ctx, log) {
1300
1642
  if (log?.info) {
1301
1643
  log.info(`[Memory Plugin] Flushing session ${sessionId}: ${messagesToSave.length} messages, ${cache.turnCount} turns`);
1302
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
+
1303
1654
  await addMemory(messagesToSave, cfg, ctx, log, sessionId);
1304
1655
  } catch (error) {
1305
1656
  if (log?.warn) {
@@ -1348,18 +1699,18 @@ function registerPlugin(api) {
1348
1699
  return;
1349
1700
  }
1350
1701
 
1351
- const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1352
- const userContent = normalizeUserMessageContent(prompt);
1353
- const rawUserContent = stripPrependedPrompt(prompt);
1354
- const messageId = extractRelayMessageId(rawUserContent || prompt);
1355
- if (userContent) {
1356
- addToSessionCache(sessionId, {
1357
- role: "user",
1358
- content: userContent,
1359
- rawContent: rawUserContent,
1360
- messageId: messageId || undefined,
1361
- });
1362
- }
1702
+ const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1703
+ const userContent = normalizeUserMessageContent(prompt);
1704
+ const rawUserContent = stripPrependedPrompt(prompt);
1705
+ const messageId = extractRelayMessageId(rawUserContent || prompt);
1706
+ if (userContent) {
1707
+ addToSessionCache(sessionId, {
1708
+ role: "user",
1709
+ content: userContent,
1710
+ rawContent: rawUserContent,
1711
+ messageId: messageId || undefined,
1712
+ });
1713
+ }
1363
1714
 
1364
1715
  try {
1365
1716
  const memories = await retrieveMemory(prompt, cfg, ctx, log);
@@ -1406,22 +1757,28 @@ function registerPlugin(api) {
1406
1757
  return;
1407
1758
  }
1408
1759
 
1409
- const assistantContent = event?.response || event?.result;
1410
- if (assistantContent) {
1411
- const content = normalizeAssistantMessageContent(assistantContent);
1412
- const rawContent = extractText(assistantContent);
1413
- if (content) {
1414
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1415
- }
1416
- } else if (event?.messages?.length) {
1760
+ if (event?.messages?.length) {
1761
+ const pending = [];
1417
1762
  for (let i = event.messages.length - 1; i >= 0; i--) {
1418
- if (event.messages[i].role === "assistant") {
1419
- const content = normalizeAssistantMessageContent(event.messages[i].content);
1420
- const rawContent = extractText(event.messages[i].content);
1421
- if (content) {
1422
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1423
- }
1424
- 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);
1425
1782
  }
1426
1783
  }
1427
1784
  }
@@ -1486,18 +1843,18 @@ function createHooksPlugin(config) {
1486
1843
  return;
1487
1844
  }
1488
1845
 
1489
- const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1490
- const userContent = normalizeUserMessageContent(prompt);
1491
- const rawUserContent = stripPrependedPrompt(prompt);
1492
- const messageId = extractRelayMessageId(rawUserContent || prompt);
1493
- if (userContent) {
1494
- addToSessionCache(sessionId, {
1495
- role: "user",
1496
- content: userContent,
1497
- rawContent: rawUserContent,
1498
- messageId: messageId || undefined,
1499
- });
1500
- }
1846
+ const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1847
+ const userContent = normalizeUserMessageContent(prompt);
1848
+ const rawUserContent = stripPrependedPrompt(prompt);
1849
+ const messageId = extractRelayMessageId(rawUserContent || prompt);
1850
+ if (userContent) {
1851
+ addToSessionCache(sessionId, {
1852
+ role: "user",
1853
+ content: userContent,
1854
+ rawContent: rawUserContent,
1855
+ messageId: messageId || undefined,
1856
+ });
1857
+ }
1501
1858
 
1502
1859
  try {
1503
1860
  const memories = await retrieveMemory(prompt, cfg, ctx, log);
@@ -1561,43 +1918,37 @@ function createHooksPlugin(config) {
1561
1918
  const cache = getSessionCache(sessionId);
1562
1919
  if (cache.messages.length === 0) {
1563
1920
  for (const msg of event.messages) {
1564
- if (msg.role === "system") continue;
1565
- const content = msg.role === "user"
1566
- ? normalizeUserMessageContent(msg.content)
1567
- : normalizeAssistantMessageContent(msg.content);
1568
- const rawSource = msg.role === "user"
1569
- ? stripPrependedPrompt(msg.content)
1570
- : extractText(msg.content);
1571
- const messageId = msg.role === "user" ? extractRelayMessageId(rawSource || "") : "";
1572
- if (content) {
1573
- addToSessionCache(sessionId, {
1574
- role: msg.role,
1575
- content,
1576
- rawContent: rawSource || undefined,
1577
- messageId: messageId || undefined,
1578
- });
1579
- }
1580
- }
1921
+ const cacheMessage = buildCacheMessageFromTranscript(msg);
1922
+ if (cacheMessage) {
1923
+ addToSessionCache(sessionId, cacheMessage);
1924
+ }
1925
+ }
1581
1926
  } else {
1582
- // Only add last assistant message
1927
+ // Add trailing assistant/tool messages from the latest completion.
1928
+ const pending = [];
1583
1929
  for (let i = event.messages.length - 1; i >= 0; i--) {
1584
- if (event.messages[i].role === "assistant") {
1585
- const content = normalizeAssistantMessageContent(event.messages[i].content);
1586
- const rawContent = extractText(event.messages[i].content);
1587
- if (content) {
1588
- addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1589
- }
1590
- 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);
1591
1937
  }
1592
1938
  }
1939
+ for (const cacheMessage of pending) {
1940
+ addToSessionCache(sessionId, cacheMessage);
1941
+ }
1593
1942
  }
1594
1943
  } else {
1595
1944
  const assistantContent = event?.response || event?.result;
1596
1945
  if (assistantContent) {
1597
- const content = normalizeAssistantMessageContent(assistantContent);
1598
- const rawContent = extractText(assistantContent);
1599
- if (content) {
1600
- 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);
1601
1952
  }
1602
1953
  }
1603
1954
  }