@humanlikememory/human-like-mem 0.3.10 → 0.3.12

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,8 +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 ---";
9
+ const PLUGIN_VERSION = "0.3.4";
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
@@ -114,31 +115,101 @@ function extractFeishuTailBlock(text) {
114
115
  /**
115
116
  * Whether text is only transport metadata rather than a real utterance.
116
117
  */
117
- function isMetadataOnlyText(text) {
118
- if (!text) return true;
119
- const value = String(text).trim();
120
- if (!value) return true;
121
- if (/^\[message_id:\s*[^\]]+\]$/i.test(value)) return true;
122
- if (/^\[\[reply_to[^\]]*\]\]$/i.test(value)) return true;
123
- return false;
124
- }
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
+ }
125
185
 
126
186
  /**
127
187
  * Normalize user message text before caching/storing.
128
188
  * For channel-formatted payloads (e.g. Feishu), keep only the actual user utterance
129
189
  * and drop prepended system transcript lines.
130
190
  */
131
- function normalizeUserMessageContent(content) {
132
- const text = stripPrependedPrompt(content);
133
- if (!text) return "";
134
-
135
- const normalized = String(text).replace(/\r\n/g, "\n").trim();
136
- if (!normalized) return "";
137
-
138
- // Feishu: preserve the final traceable tail block (contains platform user id/message_id).
139
- const feishuTailBlock = extractFeishuTailBlock(normalized);
140
- if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
141
- return feishuTailBlock;
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;
142
213
  }
143
214
 
144
215
  // Generic channel relays: fallback to latest "System: ...: <message>" line.
@@ -160,16 +231,18 @@ function normalizeUserMessageContent(content) {
160
231
 
161
232
  // Discord-style channel-formatted payload:
162
233
  // [from: username (id)] actual message
163
- const discordTail = normalized.match(
164
- /\[from:\s*[^\(\]\n]+?\s*\(\d{6,}\)\]\s*([\s\S]*?)$/i
165
- );
166
- if (discordTail && discordTail[1]) {
167
- const candidate = discordTail[1].trim();
168
- if (!isMetadataOnlyText(candidate)) return candidate;
169
- }
170
-
171
- return isMetadataOnlyText(normalized) ? "" : normalized;
172
- }
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
+ }
173
246
 
174
247
  /**
175
248
  * Normalize assistant message text before caching/storing.
@@ -183,12 +256,139 @@ function normalizeAssistantMessageContent(content) {
183
256
  return normalized;
184
257
  }
185
258
 
186
- /**
187
- * Parse platform identity hints from channel-formatted message text.
188
- * Returns null when no platform-specific id can be extracted.
189
- */
190
- function parsePlatformIdentity(text) {
191
- if (!text || typeof text !== "string") return null;
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
+ }
192
392
 
193
393
  // Discord example:
194
394
  // [from: huang yongqing (1470374017541079042)]
@@ -220,18 +420,39 @@ function parsePlatformIdentity(text) {
220
420
  /**
221
421
  * Parse all platform user ids from a text blob.
222
422
  */
223
- function parseAllPlatformUserIds(text) {
224
- if (!text || typeof text !== "string") return [];
225
-
226
- const ids = [];
227
-
228
- // Discord example:
229
- // [from: huang yongqing (1470374017541079042)]
230
- const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
231
- let match;
232
- while ((match = discordRegex.exec(text)) !== null) {
233
- if (match[1]) ids.push(match[1]);
234
- }
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
+ }
235
456
 
236
457
  // Feishu example:
237
458
  // [Feishu ...:ou_17b624... Wed ...] username: message
@@ -246,8 +467,8 @@ function parseAllPlatformUserIds(text) {
246
467
  /**
247
468
  * Collect distinct user ids parsed from all messages.
248
469
  */
249
- function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
250
- const unique = new Set();
470
+ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
471
+ const unique = new Set();
251
472
 
252
473
  if (Array.isArray(messages)) {
253
474
  for (const msg of messages) {
@@ -263,12 +484,96 @@ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
263
484
  unique.add(fallbackUserId);
264
485
  }
265
486
 
266
- return Array.from(unique);
267
- }
268
-
269
- /**
270
- * Get latest user message text from cached messages.
271
- */
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
+ */
272
577
  function getLatestUserMessageText(messages) {
273
578
  if (!Array.isArray(messages)) return "";
274
579
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -284,9 +589,9 @@ function getLatestUserMessageText(messages) {
284
589
  * Resolve request identity with fallback:
285
590
  * platform user id -> configured user id -> "openclaw-user"
286
591
  */
287
- function resolveRequestIdentity(promptText, cfg, ctx) {
288
- const parsed = parsePlatformIdentity(promptText);
289
- if (parsed?.userId) {
592
+ function resolveRequestIdentity(promptText, cfg) {
593
+ const parsed = parsePlatformIdentity(promptText);
594
+ if (parsed?.userId) {
290
595
  return {
291
596
  userId: parsed.userId,
292
597
  userName: parsed.userName || null,
@@ -295,7 +600,7 @@ function resolveRequestIdentity(promptText, cfg, ctx) {
295
600
  };
296
601
  }
297
602
 
298
- if (cfg?.configuredUserId) {
603
+ if (cfg?.configuredUserId) {
299
604
  return {
300
605
  userId: cfg.configuredUserId,
301
606
  userName: null,
@@ -304,17 +609,8 @@ function resolveRequestIdentity(promptText, cfg, ctx) {
304
609
  };
305
610
  }
306
611
 
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
- return {
317
- userId: "openclaw-user",
612
+ return {
613
+ userId: "openclaw-user",
318
614
  userName: null,
319
615
  platform: null,
320
616
  source: "default-user-id",
@@ -507,7 +803,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
507
803
  if (log?.info) {
508
804
  log.info(`[Memory Plugin] Recall request URL: ${url}`);
509
805
  }
510
- const identity = resolveRequestIdentity(prompt, cfg, ctx);
806
+ const identity = resolveRequestIdentity(prompt, cfg);
511
807
  const userId = sanitizeUserId(identity.userId);
512
808
  const payload = {
513
809
  query: prompt,
@@ -566,18 +862,25 @@ async function addMemory(messages, cfg, ctx, log) {
566
862
  }
567
863
  const sessionId = resolveSessionId(ctx, null) || `session-${Date.now()}`;
568
864
  const latestUserText = getLatestUserMessageText(messages);
569
- const identity = resolveRequestIdentity(latestUserText, cfg, ctx);
865
+ const identity = resolveRequestIdentity(latestUserText, cfg);
570
866
  const userId = sanitizeUserId(identity.userId);
571
- const metadataUserIds = (() => {
572
- const parsed = collectUniqueUserIdsFromMessages(messages, null)
573
- .map((id) => sanitizeUserId(id))
574
- .filter(Boolean);
575
- if (parsed.length > 0) {
576
- return Array.from(new Set(parsed));
577
- }
578
- return [userId];
579
- })();
580
- const agentId = cfg.agentId || ctx?.agentId || "main";
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";
581
884
 
582
885
  const payload = {
583
886
  user_id: userId,
@@ -591,21 +894,22 @@ async function addMemory(messages, cfg, ctx, log) {
591
894
  async_mode: true,
592
895
  custom_workflows: {
593
896
  stream_params: {
594
- metadata: JSON.stringify({
595
- user_ids: metadataUserIds,
596
- agent_ids: [agentId],
597
- session_id: sessionId,
598
- scenario: cfg.scenario || "openclaw-plugin",
599
- }),
600
- },
601
- },
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
+ },
602
906
  };
603
907
 
604
908
  if (log?.debug) {
605
- 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])}`
607
- );
608
- }
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
+ }
609
913
 
610
914
  try {
611
915
  const result = await httpRequest(url, {
@@ -850,18 +1154,44 @@ function getSessionCache(sessionId) {
850
1154
  /**
851
1155
  * Add message to session cache
852
1156
  */
853
- function addToSessionCache(sessionId, message) {
854
- const cache = getSessionCache(sessionId);
855
- cache.messages.push(message);
856
- cache.lastActivity = Date.now();
857
-
858
- // Count turns (user message after assistant = new turn)
859
- if (message.role === "user" && cache.messages.length > 1) {
860
- const prevMsg = cache.messages[cache.messages.length - 2];
861
- if (prevMsg && prevMsg.role === "assistant") {
862
- cache.turnCount++;
863
- }
864
- }
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
+ }
865
1195
 
866
1196
  return cache;
867
1197
  }
@@ -1018,12 +1348,18 @@ function registerPlugin(api) {
1018
1348
  return;
1019
1349
  }
1020
1350
 
1021
- const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1022
- const userContent = normalizeUserMessageContent(prompt);
1023
- const rawUserContent = stripPrependedPrompt(prompt);
1024
- if (userContent) {
1025
- addToSessionCache(sessionId, { role: "user", content: userContent, rawContent: rawUserContent });
1026
- }
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
+ }
1027
1363
 
1028
1364
  try {
1029
1365
  const memories = await retrieveMemory(prompt, cfg, ctx, log);
@@ -1150,12 +1486,18 @@ function createHooksPlugin(config) {
1150
1486
  return;
1151
1487
  }
1152
1488
 
1153
- const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
1154
- const userContent = normalizeUserMessageContent(prompt);
1155
- const rawUserContent = stripPrependedPrompt(prompt);
1156
- if (userContent) {
1157
- addToSessionCache(sessionId, { role: "user", content: userContent, rawContent: rawUserContent });
1158
- }
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
+ }
1159
1501
 
1160
1502
  try {
1161
1503
  const memories = await retrieveMemory(prompt, cfg, ctx, log);
@@ -1223,13 +1565,19 @@ function createHooksPlugin(config) {
1223
1565
  const content = msg.role === "user"
1224
1566
  ? normalizeUserMessageContent(msg.content)
1225
1567
  : 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 });
1231
- }
1232
- }
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
+ }
1233
1581
  } else {
1234
1582
  // Only add last assistant message
1235
1583
  for (let i = event.messages.length - 1; i >= 0; i--) {
@@ -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.10",
6
+ "version": "0.3.12",
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.10",
3
+ "version": "0.3.12",
4
4
  "description": "Long-term memory plugin for OpenClaw - AI Social Memory Integration",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -50,8 +50,7 @@
50
50
  "jest": "^29.7.0",
51
51
  "prettier": "^3.2.0"
52
52
  },
53
- "publishConfig": {
54
- "access": "public"
55
- }
56
- }
57
-
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }