@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/LICENSE +190 -0
- package/README.md +70 -0
- package/README_ZH.md +70 -0
- package/index.js +1335 -0
- package/openclaw.plugin.json +151 -0
- package/package.json +57 -0
- package/plugin.js +2 -0
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
|
+
}
|