@humanlikememory/human-like-mem 0.3.10

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 ADDED
@@ -0,0 +1,1335 @@
1
+ /**
2
+ * Human-Like Memory Plugin for OpenClaw
3
+ *
4
+ * Long-term memory plugin for OpenClaw.
5
+ *
6
+ * @license Apache-2.0
7
+ */
8
+
9
+ const PLUGIN_VERSION = "0.3.4";
10
+ const USER_QUERY_MARKER = "--- User Query ---";
11
+
12
+ /**
13
+ * Session cache for tracking conversation history
14
+ * Key: conversationId, Value: { messages: [], lastActivity: timestamp }
15
+ */
16
+ const sessionCache = new Map();
17
+
18
+ /**
19
+ * Timeout handles for session flush
20
+ */
21
+ const sessionTimers = new Map();
22
+
23
+ /**
24
+ * Upgrade notification from server
25
+ * Contains: { required: boolean, version: string, message: string, url: string }
26
+ */
27
+ let upgradeNotification = null;
28
+
29
+ /**
30
+ * Display warning when API Key is not configured
31
+ */
32
+ function warnMissingApiKey(log) {
33
+ const msg = `
34
+ [Memory Plugin] HUMAN_LIKE_MEM_API_KEY is not configured.
35
+ Get your API key from: https://human-like.me
36
+ Then set:
37
+ export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
38
+ export HUMAN_LIKE_MEM_BASE_URL="https://human-like.me"
39
+ `;
40
+ if (log?.warn) {
41
+ log.warn(msg);
42
+ } else {
43
+ console.warn(msg);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Extract text from content (string or array format)
49
+ */
50
+ function extractText(content) {
51
+ if (!content) return "";
52
+ if (typeof content === "string") return content;
53
+ if (Array.isArray(content)) {
54
+ return content
55
+ .filter((block) => block && typeof block === "object" && block.type === "text")
56
+ .map((block) => block.text)
57
+ .join(" ");
58
+ }
59
+ return "";
60
+ }
61
+
62
+ /**
63
+ * Strip prepended prompt markers from message content
64
+ */
65
+ function stripPrependedPrompt(content) {
66
+ const text = extractText(content);
67
+ if (!text) return text;
68
+ const markerIndex = text.indexOf(USER_QUERY_MARKER);
69
+ if (markerIndex !== -1) {
70
+ return text.substring(markerIndex + USER_QUERY_MARKER.length).trim();
71
+ }
72
+ return text;
73
+ }
74
+
75
+ /**
76
+ * Extract latest user utterance from channel "System: ...: <message>" transcript lines.
77
+ */
78
+ function extractLatestSystemTranscriptMessage(text) {
79
+ if (!text || typeof text !== "string") return "";
80
+ const normalized = text.replace(/\r\n/g, "\n");
81
+ const lines = normalized.split("\n");
82
+ let latest = "";
83
+
84
+ for (const rawLine of lines) {
85
+ const line = rawLine.trim();
86
+ // Example:
87
+ // System: [2026-03-12 18:15:07 GMT+8] Feishu[...] message in group ...: 你好
88
+ const match = line.match(/^System:\s*\[[^\]]+\]\s*.+?:\s*(.+)$/i);
89
+ if (match && match[1]) {
90
+ const candidate = match[1].trim();
91
+ if (candidate) latest = candidate;
92
+ }
93
+ }
94
+
95
+ return latest;
96
+ }
97
+
98
+ /**
99
+ * Extract the final Feishu channel tail block and keep traceability fields.
100
+ * Example kept block:
101
+ * [Feishu ...:ou_xxx ...] username: message
102
+ * [message_id: om_xxx]
103
+ */
104
+ function extractFeishuTailBlock(text) {
105
+ if (!text || typeof text !== "string") return "";
106
+ const normalized = text.replace(/\r\n/g, "\n");
107
+ const match = normalized.match(
108
+ /(\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*[^:\n]+:\s*[\s\S]*?(?:\n\[message_id:[^\]]+\]\s*)?)$/i
109
+ );
110
+ if (!match || !match[1]) return "";
111
+ return match[1].trim();
112
+ }
113
+
114
+ /**
115
+ * Whether text is only transport metadata rather than a real utterance.
116
+ */
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
+ }
125
+
126
+ /**
127
+ * Normalize user message text before caching/storing.
128
+ * For channel-formatted payloads (e.g. Feishu), keep only the actual user utterance
129
+ * and drop prepended system transcript lines.
130
+ */
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;
142
+ }
143
+
144
+ // Generic channel relays: fallback to latest "System: ...: <message>" line.
145
+ const latestSystemMessage = extractLatestSystemTranscriptMessage(normalized);
146
+ if (latestSystemMessage && !isMetadataOnlyText(latestSystemMessage)) {
147
+ return latestSystemMessage;
148
+ }
149
+
150
+ // Feishu channel-formatted payload:
151
+ // [Feishu ...] username: actual message
152
+ // [message_id: ...]
153
+ const feishuTail = normalized.match(
154
+ /\[Feishu[^\]]*\]\s*[^:\n]+:\s*([\s\S]*?)(?:\n\[message_id:[^\]]+\]\s*)?$/i
155
+ );
156
+ if (feishuTail && feishuTail[1]) {
157
+ const candidate = feishuTail[1].trim();
158
+ if (!isMetadataOnlyText(candidate)) return candidate;
159
+ }
160
+
161
+ // Discord-style channel-formatted payload:
162
+ // [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
+ }
173
+
174
+ /**
175
+ * Normalize assistant message text before caching/storing.
176
+ * Ignore transport acks like "NO_REPLY" to avoid poisoning memory.
177
+ */
178
+ function normalizeAssistantMessageContent(content) {
179
+ const text = extractText(content);
180
+ if (!text) return "";
181
+ const normalized = String(text).trim();
182
+ if (!normalized || normalized === "NO_REPLY") return "";
183
+ return normalized;
184
+ }
185
+
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;
192
+
193
+ // Discord example:
194
+ // [from: huang yongqing (1470374017541079042)]
195
+ const discordFrom = text.match(/\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/i);
196
+ if (discordFrom) {
197
+ return {
198
+ platform: "discord",
199
+ userId: discordFrom[2],
200
+ userName: discordFrom[1].trim(),
201
+ source: "discord-from-line",
202
+ };
203
+ }
204
+
205
+ // Feishu example:
206
+ // [Feishu ...:ou_17b624... Wed ...] username: message
207
+ const feishuUser = text.match(/\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*([^:\n]+):/i);
208
+ if (feishuUser) {
209
+ return {
210
+ platform: "feishu",
211
+ userId: feishuUser[1],
212
+ userName: feishuUser[2].trim(),
213
+ source: "feishu-header-line",
214
+ };
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Parse all platform user ids from a text blob.
222
+ */
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
+ }
235
+
236
+ // Feishu example:
237
+ // [Feishu ...:ou_17b624... Wed ...] username: message
238
+ const feishuRegex = /\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]/gi;
239
+ while ((match = feishuRegex.exec(text)) !== null) {
240
+ if (match[1]) ids.push(match[1]);
241
+ }
242
+
243
+ return ids;
244
+ }
245
+
246
+ /**
247
+ * Collect distinct user ids parsed from all messages.
248
+ */
249
+ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
250
+ const unique = new Set();
251
+
252
+ if (Array.isArray(messages)) {
253
+ for (const msg of messages) {
254
+ if (!msg) continue;
255
+ const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
256
+ const text = typeof sourceText === "string" ? sourceText : extractText(sourceText);
257
+ const ids = parseAllPlatformUserIds(text);
258
+ for (const id of ids) unique.add(id);
259
+ }
260
+ }
261
+
262
+ if (unique.size === 0 && fallbackUserId) {
263
+ unique.add(fallbackUserId);
264
+ }
265
+
266
+ return Array.from(unique);
267
+ }
268
+
269
+ /**
270
+ * Get latest user message text from cached messages.
271
+ */
272
+ function getLatestUserMessageText(messages) {
273
+ if (!Array.isArray(messages)) return "";
274
+ for (let i = messages.length - 1; i >= 0; i--) {
275
+ const msg = messages[i];
276
+ if (msg?.role !== "user") continue;
277
+ const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
278
+ return stripPrependedPrompt(sourceText);
279
+ }
280
+ return "";
281
+ }
282
+
283
+ /**
284
+ * Resolve request identity with fallback:
285
+ * platform user id -> configured user id -> "openclaw-user"
286
+ */
287
+ function resolveRequestIdentity(promptText, cfg, ctx) {
288
+ const parsed = parsePlatformIdentity(promptText);
289
+ if (parsed?.userId) {
290
+ return {
291
+ userId: parsed.userId,
292
+ userName: parsed.userName || null,
293
+ platform: parsed.platform || null,
294
+ source: parsed.source || "platform-parser",
295
+ };
296
+ }
297
+
298
+ if (cfg?.configuredUserId) {
299
+ return {
300
+ userId: cfg.configuredUserId,
301
+ userName: null,
302
+ platform: null,
303
+ source: "configured-user-id",
304
+ };
305
+ }
306
+
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",
318
+ userName: null,
319
+ platform: null,
320
+ source: "default-user-id",
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Strip accidental api-key fragments from user id strings.
326
+ * Example: "tenant:mem_xxx:116041..." -> "116041..."
327
+ */
328
+ function sanitizeUserId(rawUserId) {
329
+ if (!rawUserId) return "openclaw-user";
330
+ const value = String(rawUserId).trim();
331
+ if (!value) return "openclaw-user";
332
+
333
+ if (value.includes(":") && /(^|:)mem_[^:]+(:|$)/.test(value)) {
334
+ const parts = value.split(":").filter(Boolean);
335
+ const memIdx = parts.findIndex((p) => p.startsWith("mem_"));
336
+ if (memIdx >= 0 && memIdx < parts.length - 1) {
337
+ return parts[parts.length - 1];
338
+ }
339
+ }
340
+
341
+ return value;
342
+ }
343
+
344
+ /**
345
+ * Truncate text to specified maximum length
346
+ */
347
+ function truncate(text, maxLen) {
348
+ if (!text || text.length <= maxLen) return text;
349
+ return text.substring(0, maxLen - 3) + "...";
350
+ }
351
+
352
+ /**
353
+ * Make HTTP request with retry logic
354
+ */
355
+ async function httpRequest(url, options, cfg, log) {
356
+ const timeout = cfg.timeoutMs || 5000;
357
+ const retries = cfg.retries || 1;
358
+
359
+ let lastError;
360
+
361
+ for (let attempt = 0; attempt <= retries; attempt++) {
362
+ try {
363
+ const controller = new AbortController();
364
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
365
+
366
+ const response = await fetch(url, {
367
+ ...options,
368
+ signal: controller.signal,
369
+ });
370
+
371
+ clearTimeout(timeoutId);
372
+
373
+ // Check for upgrade notification in response headers
374
+ checkUpgradeHeaders(response, log);
375
+
376
+ if (!response.ok) {
377
+ const errorText = await response.text();
378
+ if (log?.warn) {
379
+ log.warn(`[Memory Plugin] HTTP ${response.status}: ${errorText.substring(0, 200)}`);
380
+ }
381
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
382
+ }
383
+
384
+ const jsonResult = await response.json();
385
+
386
+ // Also check for upgrade notification in response body
387
+ checkUpgradeBody(jsonResult, log);
388
+
389
+ return jsonResult;
390
+ } catch (error) {
391
+ lastError = error;
392
+ if (attempt < retries && error.name !== 'AbortError') {
393
+ if (log?.debug) {
394
+ log.debug(`[Memory Plugin] Request attempt ${attempt + 1} failed, retrying...`);
395
+ }
396
+ continue;
397
+ }
398
+ if (error.name === 'AbortError') {
399
+ if (log?.warn) log.warn(`[Memory Plugin] Request timeout after ${timeout}ms`);
400
+ break;
401
+ }
402
+ if (log?.warn) {
403
+ log.warn(`[Memory Plugin] Request failed: ${error.message}`);
404
+ }
405
+ }
406
+ }
407
+
408
+ throw lastError;
409
+ }
410
+
411
+ /**
412
+ * Check response headers for upgrade notification
413
+ */
414
+ function checkUpgradeHeaders(response, log) {
415
+ const upgradeRequired = response.headers.get('X-Upgrade-Required');
416
+ const upgradeVersion = response.headers.get('X-Upgrade-Version');
417
+ const upgradeMessage = response.headers.get('X-Upgrade-Message');
418
+ const upgradeUrl = response.headers.get('X-Upgrade-Url');
419
+
420
+ if (upgradeRequired === 'true' || upgradeVersion) {
421
+ upgradeNotification = {
422
+ required: upgradeRequired === 'true',
423
+ version: upgradeVersion || 'latest',
424
+ message: upgradeMessage || `Please upgrade to version ${upgradeVersion || 'latest'}`,
425
+ url: upgradeUrl || 'https://www.npmjs.com/package/@humanlikememory/human-like-mem',
426
+ currentVersion: PLUGIN_VERSION,
427
+ };
428
+ if (log?.warn) {
429
+ log.warn(`[Memory Plugin] Upgrade ${upgradeRequired === 'true' ? 'REQUIRED' : 'available'}: ${upgradeNotification.message}`);
430
+ }
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Check response body for upgrade notification
436
+ */
437
+ function checkUpgradeBody(result, log) {
438
+ if (result && result._upgrade) {
439
+ const upgrade = result._upgrade;
440
+ upgradeNotification = {
441
+ required: upgrade.required === true,
442
+ version: upgrade.version || 'latest',
443
+ message: upgrade.message || `Please upgrade to version ${upgrade.version || 'latest'}`,
444
+ url: upgrade.url || 'https://www.npmjs.com/package/@humanlikememory/human-like-mem',
445
+ currentVersion: PLUGIN_VERSION,
446
+ };
447
+ if (log?.warn) {
448
+ log.warn(`[Memory Plugin] Upgrade ${upgrade.required ? 'REQUIRED' : 'available'}: ${upgradeNotification.message}`);
449
+ }
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Format upgrade notification for user display
455
+ */
456
+ function formatUpgradeNotification() {
457
+ if (!upgradeNotification) return null;
458
+
459
+ const { required, version, message, url, currentVersion } = upgradeNotification;
460
+
461
+ if (required) {
462
+ return `
463
+ **[Human-Like Memory Plugin] Upgrade Required**
464
+
465
+ Current version: ${currentVersion}
466
+ Latest version: ${version}
467
+
468
+ ${message}
469
+
470
+ Please upgrade to continue using memory features:
471
+ \`\`\`bash
472
+ npm update @humanlikememory/human-like-mem
473
+ \`\`\`
474
+
475
+ More info: ${url}
476
+ `;
477
+ }
478
+
479
+ return `
480
+ **[Human-Like Memory Plugin] Upgrade Available**
481
+
482
+ Current version: ${currentVersion}
483
+ Latest version: ${version}
484
+
485
+ ${message}
486
+
487
+ Upgrade command:
488
+ \`\`\`bash
489
+ npm update @humanlikememory/human-like-mem
490
+ \`\`\`
491
+ `;
492
+ }
493
+
494
+ /**
495
+ * Retrieve memories from the API
496
+ */
497
+ async function retrieveMemory(prompt, cfg, ctx, log) {
498
+ const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
499
+ const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
500
+
501
+ if (!apiKey) {
502
+ warnMissingApiKey(log);
503
+ return [];
504
+ }
505
+
506
+ const url = `${baseUrl}/api/plugin/v1/search/memory`;
507
+ if (log?.info) {
508
+ log.info(`[Memory Plugin] Recall request URL: ${url}`);
509
+ }
510
+ const identity = resolveRequestIdentity(prompt, cfg, ctx);
511
+ const userId = sanitizeUserId(identity.userId);
512
+ const payload = {
513
+ query: prompt,
514
+ user_id: userId,
515
+ agent_id: cfg.agentId || ctx?.agentId,
516
+ conversation_id: cfg.recallGlobal !== false ? null : (ctx?.sessionId || ctx?.conversationId),
517
+ memory_limit_number: cfg.memoryLimitNumber || 6,
518
+ min_score: cfg.minScore || 0.1,
519
+ tags: cfg.tags || null,
520
+ };
521
+
522
+ try {
523
+ const result = await httpRequest(url, {
524
+ method: "POST",
525
+ headers: {
526
+ "Content-Type": "application/json",
527
+ "x-api-key": apiKey,
528
+ "x-request-id": ctx?.requestId || `openclaw-${Date.now()}`,
529
+ "x-plugin-version": PLUGIN_VERSION,
530
+ "x-client-type": "plugin",
531
+ },
532
+ body: JSON.stringify(payload),
533
+ }, cfg, log);
534
+
535
+ if (!result.success) {
536
+ if (log?.warn) log.warn(`[Memory Plugin] Memory retrieval failed: ${result.error}`);
537
+ return [];
538
+ }
539
+
540
+ const memoryCount = result.memories?.length || 0;
541
+ if (memoryCount > 0 && log?.info) {
542
+ log.info(`[Memory Plugin] Retrieved ${memoryCount} memories for query: "${truncate(prompt, 50)}"`);
543
+ }
544
+
545
+ return result.memories || [];
546
+ } catch (error) {
547
+ if (log?.warn) {
548
+ log.warn(`[Memory Plugin] Memory retrieval failed: ${error.message}`);
549
+ }
550
+ return [];
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Add memories to the API
556
+ */
557
+ async function addMemory(messages, cfg, ctx, log) {
558
+ const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
559
+ const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
560
+
561
+ if (!apiKey) return;
562
+
563
+ const url = `${baseUrl}/api/plugin/v1/add/message`;
564
+ if (log?.info) {
565
+ log.info(`[Memory Plugin] Add-memory request URL: ${url}`);
566
+ }
567
+ const sessionId = resolveSessionId(ctx, null) || `session-${Date.now()}`;
568
+ const latestUserText = getLatestUserMessageText(messages);
569
+ const identity = resolveRequestIdentity(latestUserText, cfg, ctx);
570
+ 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";
581
+
582
+ const payload = {
583
+ user_id: userId,
584
+ conversation_id: sessionId,
585
+ messages: messages.map(m => ({
586
+ role: m.role,
587
+ content: truncate(m.content, cfg.maxMessageChars || 20000),
588
+ })),
589
+ agent_id: agentId,
590
+ tags: cfg.tags || ["openclaw"],
591
+ async_mode: true,
592
+ custom_workflows: {
593
+ 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
+ },
602
+ };
603
+
604
+ 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
+ }
609
+
610
+ try {
611
+ const result = await httpRequest(url, {
612
+ method: "POST",
613
+ headers: {
614
+ "Content-Type": "application/json",
615
+ "x-api-key": apiKey,
616
+ "x-request-id": ctx?.requestId || `openclaw-${Date.now()}`,
617
+ "x-plugin-version": PLUGIN_VERSION,
618
+ "x-client-type": "plugin",
619
+ },
620
+ body: JSON.stringify(payload),
621
+ }, cfg, log);
622
+
623
+ const memoryCount = result?.memories_count || 0;
624
+ if (log?.info) {
625
+ log.info(`[Memory Plugin] Successfully added memory: ${memoryCount} streams`);
626
+ }
627
+
628
+ return result;
629
+ } catch (error) {
630
+ if (log?.warn) {
631
+ log.warn(`[Memory Plugin] Memory add failed: ${error.message}`);
632
+ }
633
+ throw error;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Format time for display
639
+ */
640
+ function formatTime(value) {
641
+ if (value === undefined || value === null || value === "") return "";
642
+ if (typeof value === "number") {
643
+ const date = new Date(value);
644
+ if (Number.isNaN(date.getTime())) return "";
645
+ const pad2 = (v) => String(v).padStart(2, "0");
646
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
647
+ }
648
+ if (typeof value === "string") {
649
+ const trimmed = value.trim();
650
+ if (!trimmed) return "";
651
+ if (/^\d+$/.test(trimmed)) return formatTime(Number(trimmed));
652
+ return trimmed;
653
+ }
654
+ return "";
655
+ }
656
+
657
+ /**
658
+ * Format memory line for display
659
+ */
660
+ function formatMemoryLine(memory, options = {}) {
661
+ const date = formatTime(memory?.timestamp);
662
+ const desc = memory?.description || memory?.event || "";
663
+ const score = memory?.score ? ` (${(memory.score * 100).toFixed(0)}%)` : "";
664
+ if (!desc) return "";
665
+ const maxChars = options.maxItemChars || 500;
666
+ const truncated = desc.length > maxChars ? desc.substring(0, maxChars - 3) + "..." : desc;
667
+ if (date) return ` -[${date}] ${truncated}${score}`;
668
+ return ` - ${truncated}${score}`;
669
+ }
670
+
671
+ /**
672
+ * Format memories for injection into context
673
+ */
674
+ function formatMemoriesForContext(memories, options = {}) {
675
+ if (!memories || memories.length === 0) return "";
676
+
677
+ const now = options.currentTime ?? Date.now();
678
+ const nowText = formatTime(now) || formatTime(Date.now()) || "";
679
+
680
+ // Format memory lines (our format is different from MemOS)
681
+ const memoryLines = memories
682
+ .map((m) => formatMemoryLine(m, options))
683
+ .filter(Boolean);
684
+
685
+ if (memoryLines.length === 0) return "";
686
+
687
+ const memoriesBlock = [
688
+ "<memories>",
689
+ ...memoryLines,
690
+ "</memories>",
691
+ ];
692
+
693
+ const lines = [
694
+ "# Role",
695
+ "",
696
+ "You are an intelligent assistant with long-term memory capabilities. Your goal is to combine retrieved memory fragments to provide highly personalized, accurate, and logically rigorous responses.",
697
+ "",
698
+ "# System Context",
699
+ "",
700
+ `* Current Time: ${nowText} (Use this as the baseline for freshness checks)`,
701
+ "",
702
+ "# Memory Data",
703
+ "",
704
+ "Below are **episodic memory summaries** retrieved from long-term memory. These memories are primarily **contextual summaries** of past conversations and interactions, capturing the key information, events, and context from previous exchanges.",
705
+ "",
706
+ "* **Memory Type**: All memories are episodic summaries - they represent contextual information from past conversations, not categorized facts or preferences.",
707
+ "* **Content Nature**: These are summaries of what happened, what was discussed, and the context surrounding those interactions.",
708
+ "* **Special Note**: If content is tagged with '[assistant_opinion]' or '[model_summary]', it represents **past AI inference**, **not** direct user statements.",
709
+ "",
710
+ "```text",
711
+ ...memoriesBlock,
712
+ "```",
713
+ "",
714
+ "# Critical Protocol: Memory Safety",
715
+ "",
716
+ "Retrieved memories may contain **AI speculation**, **irrelevant noise**, or **wrong subject attribution**. You must strictly apply the **Four-Step Verdict**. If any step fails, **discard the memory**:",
717
+ "",
718
+ "1. **Source Verification**:",
719
+ "* **Core**: Distinguish direct user statements from AI inference.",
720
+ "* If a memory has tags like '[assistant_opinion]' or '[model_summary]', treat it as a **hypothesis**, not a user-grounded fact.",
721
+ "* *Counterexample*: If memory says '[assistant_opinion] User loves mangoes' but the user never said that, do not assume it as fact.",
722
+ "* **Principle: AI summaries are reference-only and have much lower authority than direct user statements.**",
723
+ "",
724
+ "2. **Attribution Check**:",
725
+ "* Is the subject in memory definitely the user?",
726
+ "* If the memory describes a **third party** (e.g., candidate, interviewee, fictional character, case data), never attribute it to the user.",
727
+ "",
728
+ "3. **Strong Relevance Check**:",
729
+ "* Does the memory directly help answer the current 'Original Query'?",
730
+ "* If it is only a keyword overlap with different context, ignore it.",
731
+ "",
732
+ "4. **Freshness Check**:",
733
+ "* If memory conflicts with the user's latest intent, prioritize the current 'Original Query' as the highest source of truth.",
734
+ "",
735
+ "# Instructions",
736
+ "",
737
+ "1. **Review**: Read the episodic memory summaries and apply the Four-Step Verdict to remove noise and unreliable AI inference.",
738
+ "2. **Execute**:",
739
+ " - Use only memories that pass filtering as context.",
740
+ " - Extract relevant contextual information from the episodic summaries to inform your response.",
741
+ "3. **Output**: Answer directly. Never mention internal terms such as \"memory store\", \"retrieval\", or \"AI opinions\".",
742
+ "4. **Attention**: Additional memory context is already provided. Do not read from or write to local `MEMORY.md` or `memory/*` files for reference, as they may be outdated or irrelevant to the current query.",
743
+ "",
744
+ USER_QUERY_MARKER,
745
+ ];
746
+
747
+ return lines.join("\n");
748
+ }
749
+
750
+ /**
751
+ * Extract last N turns of user-assistant exchanges
752
+ * @param {Array} messages - All messages
753
+ * @param {number} maxTurns - Maximum number of turns to extract
754
+ * @returns {Array} Recent messages
755
+ */
756
+ function pickRecentMessages(messages, maxTurns = 10) {
757
+ if (!messages || messages.length === 0) return [];
758
+
759
+ const result = [];
760
+ let turnCount = 0;
761
+ let lastRole = null;
762
+
763
+ // Traverse from end to beginning
764
+ for (let i = messages.length - 1; i >= 0 && turnCount < maxTurns; i--) {
765
+ const msg = messages[i];
766
+ const role = msg.role;
767
+
768
+ // Skip system messages
769
+ if (role === "system") continue;
770
+
771
+ // Count a turn when we see a user message after an assistant message
772
+ if (role === "user" && lastRole === "assistant") {
773
+ turnCount++;
774
+ }
775
+
776
+ if (turnCount >= maxTurns) break;
777
+
778
+ let content;
779
+ if (role === "user") {
780
+ content = normalizeUserMessageContent(msg.content);
781
+ } else if (role === "assistant") {
782
+ content = normalizeAssistantMessageContent(msg.content);
783
+ }
784
+
785
+ if (content) {
786
+ const rawSource = msg.rawContent !== undefined ? msg.rawContent : msg.content;
787
+ const rawContent = typeof rawSource === "string" ? rawSource : extractText(rawSource);
788
+ result.unshift({
789
+ role: role,
790
+ content: content,
791
+ rawContent: rawContent || undefined,
792
+ });
793
+ }
794
+
795
+ lastRole = role;
796
+ }
797
+
798
+ return result;
799
+ }
800
+
801
+ /**
802
+ * Check if conversation has enough turns to be worth storing
803
+ * @param {Array} messages - Messages to check
804
+ * @param {Object} cfg - Configuration
805
+ * @returns {boolean} Whether conversation is worth storing
806
+ */
807
+ function isConversationWorthStoring(messages, cfg) {
808
+ if (!messages || messages.length === 0) return false;
809
+
810
+ const minTurns = cfg.minTurnsToStore || 10;
811
+
812
+ // Count turns
813
+ let turns = 0;
814
+ let lastRole = null;
815
+
816
+ for (const msg of messages) {
817
+ if (msg.role === "system") continue;
818
+
819
+ if (msg.role === "user") {
820
+ if (lastRole === "assistant") {
821
+ turns++;
822
+ }
823
+ }
824
+
825
+ lastRole = msg.role;
826
+ }
827
+
828
+ // Add 1 for the initial user message
829
+ if (messages.some(m => m.role === "user")) {
830
+ turns++;
831
+ }
832
+
833
+ return turns >= minTurns;
834
+ }
835
+
836
+ /**
837
+ * Get or create session cache entry
838
+ */
839
+ function getSessionCache(sessionId) {
840
+ if (!sessionCache.has(sessionId)) {
841
+ sessionCache.set(sessionId, {
842
+ messages: [],
843
+ lastActivity: Date.now(),
844
+ turnCount: 0,
845
+ });
846
+ }
847
+ return sessionCache.get(sessionId);
848
+ }
849
+
850
+ /**
851
+ * Add message to session cache
852
+ */
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
+ }
865
+
866
+ return cache;
867
+ }
868
+
869
+ /**
870
+ * Clear session cache
871
+ */
872
+ function clearSessionCache(sessionId) {
873
+ sessionCache.delete(sessionId);
874
+ if (sessionTimers.has(sessionId)) {
875
+ clearTimeout(sessionTimers.get(sessionId));
876
+ sessionTimers.delete(sessionId);
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Resolve session ID from ctx and event objects, trying multiple field names
882
+ * that different OpenClaw versions may use.
883
+ */
884
+ function resolveSessionId(ctx, event) {
885
+ // Direct fields on ctx
886
+ const fromCtx = ctx?.conversationId
887
+ || ctx?.sessionId
888
+ || ctx?.session_id
889
+ || ctx?.conversation_id
890
+ || ctx?.runId
891
+ || ctx?.run_id;
892
+ if (fromCtx) return fromCtx;
893
+
894
+ // Direct fields on event
895
+ const fromEvent = event?.sessionId
896
+ || event?.session_id
897
+ || event?.conversationId
898
+ || event?.conversation_id
899
+ || event?.runId
900
+ || event?.run_id;
901
+ if (fromEvent) return fromEvent;
902
+
903
+ // Try sessionKey (skip "unknown")
904
+ if (ctx?.sessionKey && ctx.sessionKey !== "unknown") {
905
+ return ctx.sessionKey;
906
+ }
907
+
908
+ // Try messageProvider (may contain session info)
909
+ const mp = ctx?.messageProvider;
910
+ if (mp) {
911
+ const fromMp = mp.sessionId || mp.session_id
912
+ || mp.conversationId || mp.conversation_id
913
+ || (typeof mp.getSessionId === 'function' ? mp.getSessionId() : null);
914
+ if (fromMp) return fromMp;
915
+ }
916
+
917
+ // Last resort: use agentId as session identifier
918
+ if (ctx?.agentId) return `agent-${ctx.agentId}`;
919
+
920
+ return null;
921
+ }
922
+
923
+ /**
924
+ * Schedule session flush after timeout
925
+ */
926
+ function scheduleSessionFlush(sessionId, cfg, ctx, log) {
927
+ // Clear existing timer
928
+ if (sessionTimers.has(sessionId)) {
929
+ clearTimeout(sessionTimers.get(sessionId));
930
+ }
931
+
932
+ const timeoutMs = cfg.sessionTimeoutMs || 5 * 60 * 1000; // 5 minutes default
933
+
934
+ const timer = setTimeout(async () => {
935
+ await flushSession(sessionId, cfg, ctx, log);
936
+ }, timeoutMs);
937
+
938
+ sessionTimers.set(sessionId, timer);
939
+ }
940
+
941
+ /**
942
+ * Flush session cache to memory storage
943
+ */
944
+ async function flushSession(sessionId, cfg, ctx, log) {
945
+ const cache = sessionCache.get(sessionId);
946
+ if (!cache || cache.messages.length === 0) {
947
+ clearSessionCache(sessionId);
948
+ return;
949
+ }
950
+
951
+ // Check if conversation is worth storing
952
+ if (!isConversationWorthStoring(cache.messages, cfg)) {
953
+ if (log?.debug) {
954
+ log.debug(`[Memory Plugin] Session ${sessionId} not worth storing (turns: ${cache.turnCount}, messages: ${cache.messages.length})`);
955
+ }
956
+ clearSessionCache(sessionId);
957
+ return;
958
+ }
959
+
960
+ // Get recent messages to store
961
+ const maxTurns = cfg.maxTurnsToStore || 10;
962
+ const messagesToSave = pickRecentMessages(cache.messages, maxTurns);
963
+
964
+ if (messagesToSave.length === 0) {
965
+ clearSessionCache(sessionId);
966
+ return;
967
+ }
968
+
969
+ try {
970
+ if (log?.info) {
971
+ log.info(`[Memory Plugin] Flushing session ${sessionId}: ${messagesToSave.length} messages, ${cache.turnCount} turns`);
972
+ }
973
+ await addMemory(messagesToSave, cfg, ctx, log, sessionId);
974
+ } catch (error) {
975
+ if (log?.warn) {
976
+ log.warn(`[Memory Plugin] Session flush failed: ${error.message}`);
977
+ }
978
+ } finally {
979
+ clearSessionCache(sessionId);
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Main plugin export.
985
+ * Supports both register(api) style and config -> hooks style.
986
+ */
987
+ export default function(configOrApi) {
988
+ // register(api) style
989
+ if (configOrApi && typeof configOrApi === 'object' && typeof configOrApi.on === 'function') {
990
+ return registerPlugin(configOrApi);
991
+ }
992
+
993
+ // config -> hooks style
994
+ return createHooksPlugin(configOrApi);
995
+ }
996
+
997
+ /**
998
+ * Register style plugin (like MemOS)
999
+ */
1000
+ function registerPlugin(api) {
1001
+ const config = api.pluginConfig || {};
1002
+ const log = api.logger || console;
1003
+
1004
+ const cfg = buildConfig(config);
1005
+
1006
+ const recallHandler = async (event, ctx) => {
1007
+ if (!cfg.recallEnabled) return;
1008
+
1009
+ if (log?.debug) {
1010
+ log.debug(`[Memory Plugin] recall hook TRIGGERED (register mode)`);
1011
+ log.debug(`[Memory Plugin] recall ctx keys: ${JSON.stringify(ctx ? Object.keys(ctx) : 'null')}`);
1012
+ log.debug(`[Memory Plugin] recall event keys: ${JSON.stringify(event ? Object.keys(event) : 'null')}`);
1013
+ }
1014
+
1015
+ const prompt = event?.prompt || "";
1016
+ if (!prompt || prompt.trim().length < 3) {
1017
+ if (log?.debug) log.debug('[Memory Plugin] Prompt too short, skipping recall');
1018
+ return;
1019
+ }
1020
+
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
+ }
1027
+
1028
+ try {
1029
+ const memories = await retrieveMemory(prompt, cfg, ctx, log);
1030
+
1031
+ let prependContext = "";
1032
+
1033
+ const upgradeMsg = formatUpgradeNotification();
1034
+ if (upgradeMsg) {
1035
+ prependContext += upgradeMsg + "\n\n";
1036
+ upgradeNotification = null;
1037
+ }
1038
+
1039
+ if (memories && memories.length > 0) {
1040
+ prependContext += formatMemoriesForContext(memories, { currentTime: Date.now() });
1041
+ if (log?.info) log.info(`[Memory Plugin] Injected ${memories.length} memories`);
1042
+ }
1043
+
1044
+ if (prependContext) {
1045
+ return { prependContext };
1046
+ }
1047
+ } catch (error) {
1048
+ if (log?.warn) log.warn(`[Memory Plugin] Memory recall failed: ${error.message}`);
1049
+
1050
+ const upgradeMsg = formatUpgradeNotification();
1051
+ if (upgradeMsg) {
1052
+ upgradeNotification = null;
1053
+ return { prependContext: upgradeMsg };
1054
+ }
1055
+ }
1056
+ };
1057
+
1058
+ const storeHandler = async (event, ctx) => {
1059
+ if (!cfg.addEnabled) return;
1060
+ if (!event?.success) return;
1061
+
1062
+ if (log?.debug) {
1063
+ log.debug(`[Memory Plugin] store hook ctx keys: ${JSON.stringify(ctx ? Object.keys(ctx) : 'null')}`);
1064
+ log.debug(`[Memory Plugin] store hook event keys: ${JSON.stringify(event ? Object.keys(event) : 'null')}`);
1065
+ }
1066
+
1067
+ const sessionId = resolveSessionId(ctx, event);
1068
+ if (!sessionId) {
1069
+ if (log?.debug) log.debug('[Memory Plugin] No session ID found in ctx or event, skipping memory cache');
1070
+ return;
1071
+ }
1072
+
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) {
1081
+ 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;
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ scheduleSessionFlush(sessionId, cfg, ctx, log);
1094
+
1095
+ const cache = sessionCache.get(sessionId);
1096
+ if (cache && cache.turnCount >= (cfg.minTurnsToStore || 10)) {
1097
+ if (isConversationWorthStoring(cache.messages, cfg)) {
1098
+ if (log?.info) {
1099
+ log.info(`[Memory Plugin] Reached ${cache.turnCount} turns, flushing session`);
1100
+ }
1101
+ flushSession(sessionId, cfg, ctx, log).catch(err => {
1102
+ if (log?.warn) log.warn(`[Memory Plugin] Async flush failed: ${err.message}`);
1103
+ });
1104
+ }
1105
+ }
1106
+ };
1107
+
1108
+ const sessionEndHandler = async (event, ctx) => {
1109
+ if (!cfg.addEnabled) return;
1110
+
1111
+ const sessionId = resolveSessionId(ctx, event);
1112
+ if (!sessionId) return;
1113
+
1114
+ if (log?.info) {
1115
+ log.info(`[Memory Plugin] Session ending, flushing cache`);
1116
+ }
1117
+
1118
+ await flushSession(sessionId, cfg, ctx, log);
1119
+ };
1120
+
1121
+ for (const name of ["before_agent_start", "agent_start", "prompt_start", "message_received", "request_pre", "before_recall"]) {
1122
+ api.on(name, recallHandler);
1123
+ }
1124
+ for (const name of ["agent_end", "prompt_end", "request_post", "message_sent"]) {
1125
+ api.on(name, storeHandler);
1126
+ }
1127
+ api.on("session_end", sessionEndHandler);
1128
+ }
1129
+
1130
+ /**
1131
+ * Hooks object style plugin
1132
+ */
1133
+ function createHooksPlugin(config) {
1134
+ const cfg = buildConfig(config);
1135
+
1136
+ const recallHandler = async (event, ctx) => {
1137
+ const log = ctx?.log || console;
1138
+
1139
+ if (log?.debug) {
1140
+ log.debug(`[Memory Plugin] recall hook TRIGGERED`);
1141
+ log.debug(`[Memory Plugin] recall ctx keys: ${JSON.stringify(ctx ? Object.keys(ctx) : 'null')}`);
1142
+ log.debug(`[Memory Plugin] recall event keys: ${JSON.stringify(event ? Object.keys(event) : 'null')}`);
1143
+ }
1144
+
1145
+ if (!cfg.recallEnabled) return;
1146
+
1147
+ const prompt = event?.prompt || "";
1148
+ if (!prompt || prompt.trim().length < 3) {
1149
+ if (log?.debug) log.debug('[Memory Plugin] Prompt too short, skipping recall');
1150
+ return;
1151
+ }
1152
+
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
+ }
1159
+
1160
+ try {
1161
+ const memories = await retrieveMemory(prompt, cfg, ctx, log);
1162
+
1163
+ let prependContext = "";
1164
+
1165
+ const upgradeMsg = formatUpgradeNotification();
1166
+ if (upgradeMsg) {
1167
+ prependContext += upgradeMsg + "\n\n";
1168
+ upgradeNotification = null;
1169
+ }
1170
+
1171
+ if (memories && memories.length > 0) {
1172
+ prependContext += formatMemoriesForContext(memories, { currentTime: Date.now() });
1173
+ if (log?.info) log.info(`[Memory Plugin] Injected ${memories.length} memories`);
1174
+ }
1175
+
1176
+ if (prependContext) {
1177
+ return { prependContext };
1178
+ }
1179
+ } catch (error) {
1180
+ if (log?.warn) log.warn(`[Memory Plugin] Memory recall failed: ${error.message}`);
1181
+
1182
+ const upgradeMsg = formatUpgradeNotification();
1183
+ if (upgradeMsg) {
1184
+ upgradeNotification = null;
1185
+ return { prependContext: upgradeMsg };
1186
+ }
1187
+ }
1188
+ };
1189
+
1190
+ const storeHandler = async (event, ctx) => {
1191
+ const log = ctx?.log || console;
1192
+
1193
+ if (log?.debug) {
1194
+ log.debug(`[Memory Plugin] store hook ctx keys: ${JSON.stringify(ctx ? Object.keys(ctx) : 'null')}`);
1195
+ log.debug(`[Memory Plugin] store hook event keys: ${JSON.stringify(event ? Object.keys(event) : 'null')}`);
1196
+ if (ctx?.messageProvider) {
1197
+ try {
1198
+ const mpKeys = typeof ctx.messageProvider === 'object' ? Object.keys(ctx.messageProvider) : typeof ctx.messageProvider;
1199
+ log.debug(`[Memory Plugin] messageProvider info: ${JSON.stringify(mpKeys)}`);
1200
+ } catch (_) {}
1201
+ }
1202
+ }
1203
+
1204
+ if (!cfg.addEnabled) return;
1205
+ if (!event?.success) return;
1206
+
1207
+ const sessionId = resolveSessionId(ctx, event);
1208
+ if (!sessionId) {
1209
+ if (log?.debug) log.debug('[Memory Plugin] No session ID found in ctx or event, skipping memory cache');
1210
+ return;
1211
+ }
1212
+
1213
+ if (log?.debug) {
1214
+ log.debug(`[Memory Plugin] Resolved sessionId: ${sessionId}`);
1215
+ }
1216
+
1217
+ // If event.messages has full conversation, cache all of them at once
1218
+ if (event?.messages?.length) {
1219
+ const cache = getSessionCache(sessionId);
1220
+ if (cache.messages.length === 0) {
1221
+ 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 });
1231
+ }
1232
+ }
1233
+ } else {
1234
+ // Only add last assistant message
1235
+ 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;
1243
+ }
1244
+ }
1245
+ }
1246
+ } else {
1247
+ const assistantContent = event?.response || event?.result;
1248
+ if (assistantContent) {
1249
+ const content = normalizeAssistantMessageContent(assistantContent);
1250
+ const rawContent = extractText(assistantContent);
1251
+ if (content) {
1252
+ addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ scheduleSessionFlush(sessionId, cfg, ctx, log);
1258
+
1259
+ const cache = sessionCache.get(sessionId);
1260
+ if (cache && cache.turnCount >= (cfg.minTurnsToStore || 10)) {
1261
+ if (isConversationWorthStoring(cache.messages, cfg)) {
1262
+ if (log?.info) {
1263
+ log.info(`[Memory Plugin] Reached ${cache.turnCount} turns, flushing session`);
1264
+ }
1265
+ flushSession(sessionId, cfg, ctx, log).catch(err => {
1266
+ if (log?.warn) log.warn(`[Memory Plugin] Async flush failed: ${err.message}`);
1267
+ });
1268
+ }
1269
+ }
1270
+ };
1271
+
1272
+ const sessionEndHandler = async (event, ctx) => {
1273
+ const log = ctx?.log || console;
1274
+
1275
+ if (!cfg.addEnabled) return;
1276
+
1277
+ const sessionId = resolveSessionId(ctx, event);
1278
+ if (!sessionId) return;
1279
+
1280
+ if (log?.info) {
1281
+ log.info(`[Memory Plugin] Session ending, flushing cache`);
1282
+ }
1283
+
1284
+ await flushSession(sessionId, cfg, ctx, log);
1285
+ };
1286
+
1287
+ return {
1288
+ hooks: {
1289
+ "before_agent_start": recallHandler,
1290
+ "agent_start": recallHandler,
1291
+ "prompt_start": recallHandler,
1292
+ "message_received": recallHandler,
1293
+ "request_pre": recallHandler,
1294
+ "before_recall": recallHandler,
1295
+
1296
+ "agent_end": storeHandler,
1297
+ "prompt_end": storeHandler,
1298
+ "request_post": storeHandler,
1299
+ "message_sent": storeHandler,
1300
+
1301
+ "session_end": sessionEndHandler,
1302
+ },
1303
+ };
1304
+ }
1305
+
1306
+ /**
1307
+ * Build config from various sources
1308
+ */
1309
+ function buildConfig(config) {
1310
+ const minTurnsToStore = config?.minTurnsToStore || parseInt(process.env.HUMAN_LIKE_MEM_MIN_TURNS) || 5;
1311
+ const configuredUserId = config?.userId || process.env.HUMAN_LIKE_MEM_USER_ID;
1312
+
1313
+ return {
1314
+ baseUrl: config?.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL || "https://human-like.me",
1315
+ apiKey: config?.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY,
1316
+ configuredUserId: configuredUserId,
1317
+ userId: configuredUserId || "openclaw-user",
1318
+ agentId: config?.agentId || process.env.HUMAN_LIKE_MEM_AGENT_ID,
1319
+ recallEnabled: config?.recallEnabled !== false,
1320
+ addEnabled: config?.addEnabled !== false,
1321
+ recallGlobal: config?.recallGlobal !== false,
1322
+ memoryLimitNumber: config?.memoryLimitNumber || parseInt(process.env.HUMAN_LIKE_MEM_LIMIT_NUMBER) || 6,
1323
+ minScore: config?.minScore || parseFloat(process.env.HUMAN_LIKE_MEM_MIN_SCORE) || 0.1,
1324
+ tags: config?.tags || null,
1325
+ maxMessageChars: config?.maxMessageChars || 20000,
1326
+ asyncMode: config?.asyncMode !== false,
1327
+ timeoutMs: config?.timeoutMs || 5000,
1328
+ retries: config?.retries ?? 1,
1329
+ scenario: config?.scenario || process.env.HUMAN_LIKE_MEM_SCENARIO || "openclaw-plugin",
1330
+ // Session-based storage settings
1331
+ minTurnsToStore: minTurnsToStore,
1332
+ maxTurnsToStore: minTurnsToStore * 2, // Always 2x of minTurnsToStore
1333
+ sessionTimeoutMs: config?.sessionTimeoutMs || parseInt(process.env.HUMAN_LIKE_MEM_SESSION_TIMEOUT) || 5 * 60 * 1000,
1334
+ };
1335
+ }