@humanlikememory/human-like-mem 0.3.12 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -70
- package/README_ZH.md +70 -70
- package/index.js +864 -513
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -5
package/index.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* @license Apache-2.0
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const PLUGIN_VERSION = "0.3.4";
|
|
10
|
-
const USER_QUERY_MARKER = "--- User Query ---";
|
|
11
|
-
const CACHE_DEDUP_WINDOW_MS = 4000;
|
|
9
|
+
const PLUGIN_VERSION = "0.3.4";
|
|
10
|
+
const USER_QUERY_MARKER = "--- User Query ---";
|
|
11
|
+
const CACHE_DEDUP_WINDOW_MS = 4000;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Session cache for tracking conversation history
|
|
@@ -60,6 +60,127 @@ function extractText(content) {
|
|
|
60
60
|
return "";
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function isToolCallBlock(block) {
|
|
64
|
+
if (!block || typeof block !== "object") return false;
|
|
65
|
+
const type = String(block.type || "").trim().toLowerCase();
|
|
66
|
+
return type === "toolcall" || type === "tool_call";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeToolCallBlock(block) {
|
|
70
|
+
if (!isToolCallBlock(block)) return null;
|
|
71
|
+
|
|
72
|
+
const name =
|
|
73
|
+
block.function?.name ||
|
|
74
|
+
block.toolName ||
|
|
75
|
+
block.name ||
|
|
76
|
+
"unknown";
|
|
77
|
+
const args =
|
|
78
|
+
block.function?.arguments ??
|
|
79
|
+
block.arguments ??
|
|
80
|
+
block.args ??
|
|
81
|
+
block.input ??
|
|
82
|
+
{};
|
|
83
|
+
const callId =
|
|
84
|
+
block.id ||
|
|
85
|
+
block.callId ||
|
|
86
|
+
block.toolCallId ||
|
|
87
|
+
block.tool_call_id ||
|
|
88
|
+
null;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: callId,
|
|
92
|
+
name,
|
|
93
|
+
arguments: args,
|
|
94
|
+
function: {
|
|
95
|
+
name,
|
|
96
|
+
arguments: args,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractToolCallsFromContent(content) {
|
|
102
|
+
if (!Array.isArray(content)) return [];
|
|
103
|
+
|
|
104
|
+
return content
|
|
105
|
+
.map((block) => normalizeToolCallBlock(block))
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getMessageToolCalls(msg) {
|
|
110
|
+
if (!msg || typeof msg !== "object") return [];
|
|
111
|
+
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
112
|
+
return msg.tool_calls;
|
|
113
|
+
}
|
|
114
|
+
return extractToolCallsFromContent(msg.content);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getToolResultCallId(msg) {
|
|
118
|
+
if (!msg || typeof msg !== "object") return null;
|
|
119
|
+
return (
|
|
120
|
+
msg.tool_call_id ||
|
|
121
|
+
msg.toolCallId ||
|
|
122
|
+
msg.call_id ||
|
|
123
|
+
msg.callId ||
|
|
124
|
+
null
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getToolResultName(msg) {
|
|
129
|
+
if (!msg || typeof msg !== "object") return undefined;
|
|
130
|
+
return msg.name || msg.toolName || msg.tool_name || undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeCacheRole(role) {
|
|
134
|
+
if (role === "toolResult") return "tool";
|
|
135
|
+
return role;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isAssistantLikeRole(role) {
|
|
139
|
+
return role === "assistant" || role === "tool" || role === "toolResult";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildCacheMessageFromTranscript(msg) {
|
|
143
|
+
if (!msg || msg.role === "system") return null;
|
|
144
|
+
|
|
145
|
+
const normalizedRole = normalizeCacheRole(msg.role);
|
|
146
|
+
let content = "";
|
|
147
|
+
let rawSource = msg.content;
|
|
148
|
+
let messageId = "";
|
|
149
|
+
|
|
150
|
+
if (msg.role === "user") {
|
|
151
|
+
content = normalizeUserMessageContent(msg.content);
|
|
152
|
+
rawSource = stripPrependedPrompt(msg.content);
|
|
153
|
+
messageId = extractRelayMessageId(rawSource || "");
|
|
154
|
+
} else if (msg.role === "assistant") {
|
|
155
|
+
content = normalizeAssistantMessageContent(msg.content);
|
|
156
|
+
rawSource = extractText(msg.content);
|
|
157
|
+
} else if (normalizedRole === "tool") {
|
|
158
|
+
content = extractText(msg.content);
|
|
159
|
+
rawSource = content;
|
|
160
|
+
} else {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const toolCalls = getMessageToolCalls(msg);
|
|
165
|
+
const toolCallId = getToolResultCallId(msg);
|
|
166
|
+
const toolName = getToolResultName(msg);
|
|
167
|
+
const hasToolContext =
|
|
168
|
+
(normalizedRole === "assistant" && toolCalls.length > 0) ||
|
|
169
|
+
(normalizedRole === "tool" && (!!toolCallId || !!toolName));
|
|
170
|
+
|
|
171
|
+
if (!content && !hasToolContext) return null;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
role: normalizedRole,
|
|
175
|
+
content: content || "",
|
|
176
|
+
rawContent: rawSource || undefined,
|
|
177
|
+
messageId: messageId || undefined,
|
|
178
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
179
|
+
tool_call_id: toolCallId || undefined,
|
|
180
|
+
name: toolName,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
63
184
|
/**
|
|
64
185
|
* Strip prepended prompt markers from message content
|
|
65
186
|
*/
|
|
@@ -115,101 +236,101 @@ function extractFeishuTailBlock(text) {
|
|
|
115
236
|
/**
|
|
116
237
|
* Whether text is only transport metadata rather than a real utterance.
|
|
117
238
|
*/
|
|
118
|
-
function isMetadataOnlyText(text) {
|
|
119
|
-
if (!text) return true;
|
|
120
|
-
const value = String(text).trim();
|
|
121
|
-
if (!value) return true;
|
|
122
|
-
if (/^\[message_id:\s*[^\]]+\]$/i.test(value)) return true;
|
|
123
|
-
if (/^\[\[reply_to[^\]]*\]\]$/i.test(value)) return true;
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Remove injected relay metadata blocks/lines from user content.
|
|
129
|
-
*/
|
|
130
|
-
function stripInjectedMetadata(text) {
|
|
131
|
-
if (!text || typeof text !== "string") return "";
|
|
132
|
-
|
|
133
|
-
let result = text.replace(/\r\n/g, "\n");
|
|
134
|
-
|
|
135
|
-
// Remove labeled untrusted metadata JSON blocks.
|
|
136
|
-
// Examples:
|
|
137
|
-
// Conversation info (untrusted metadata): ```json ... ```
|
|
138
|
-
// Sender (untrusted metadata): ```json ... ```
|
|
139
|
-
result = result.replace(
|
|
140
|
-
/(^|\n)[^\n]*\(untrusted metadata(?:,\s*for context)?\)\s*:\s*```json[\s\S]*?```(?=\n|$)/gi,
|
|
141
|
-
"\n"
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
// Remove transport/system hint lines that are not user utterances.
|
|
145
|
-
result = result.replace(/^\[System:[^\n]*\]\s*$/gim, "");
|
|
146
|
-
result = result.replace(/^\[message_id:\s*[^\]]+\]\s*$/gim, "");
|
|
147
|
-
|
|
148
|
-
// Collapse extra blank lines.
|
|
149
|
-
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Keep the latest meaningful line from cleaned metadata-injected payload.
|
|
155
|
-
*/
|
|
156
|
-
function extractLatestUtteranceFromCleanText(text) {
|
|
157
|
-
if (!text || typeof text !== "string") return "";
|
|
158
|
-
const lines = text
|
|
159
|
-
.split("\n")
|
|
160
|
-
.map((line) => line.trim())
|
|
161
|
-
.filter(Boolean)
|
|
162
|
-
.filter((line) => !isMetadataOnlyText(line));
|
|
163
|
-
if (lines.length === 0) return "";
|
|
164
|
-
return lines[lines.length - 1];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function escapeRegex(text) {
|
|
168
|
-
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Prefix utterance with parsed sender name when available.
|
|
173
|
-
* This preserves who said it in multi-user group contexts.
|
|
174
|
-
*/
|
|
175
|
-
function attachSenderPrefix(utterance, senderName) {
|
|
176
|
-
const content = String(utterance || "").trim();
|
|
177
|
-
if (!content) return "";
|
|
178
|
-
const sender = String(senderName || "").trim();
|
|
179
|
-
if (!sender) return content;
|
|
180
|
-
|
|
181
|
-
const senderRegex = new RegExp(`^${escapeRegex(sender)}\\s*[::]`);
|
|
182
|
-
if (senderRegex.test(content)) return content;
|
|
183
|
-
return `${sender}: ${content}`;
|
|
184
|
-
}
|
|
239
|
+
function isMetadataOnlyText(text) {
|
|
240
|
+
if (!text) return true;
|
|
241
|
+
const value = String(text).trim();
|
|
242
|
+
if (!value) return true;
|
|
243
|
+
if (/^\[message_id:\s*[^\]]+\]$/i.test(value)) return true;
|
|
244
|
+
if (/^\[\[reply_to[^\]]*\]\]$/i.test(value)) return true;
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Remove injected relay metadata blocks/lines from user content.
|
|
250
|
+
*/
|
|
251
|
+
function stripInjectedMetadata(text) {
|
|
252
|
+
if (!text || typeof text !== "string") return "";
|
|
253
|
+
|
|
254
|
+
let result = text.replace(/\r\n/g, "\n");
|
|
255
|
+
|
|
256
|
+
// Remove labeled untrusted metadata JSON blocks.
|
|
257
|
+
// Examples:
|
|
258
|
+
// Conversation info (untrusted metadata): ```json ... ```
|
|
259
|
+
// Sender (untrusted metadata): ```json ... ```
|
|
260
|
+
result = result.replace(
|
|
261
|
+
/(^|\n)[^\n]*\(untrusted metadata(?:,\s*for context)?\)\s*:\s*```json[\s\S]*?```(?=\n|$)/gi,
|
|
262
|
+
"\n"
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Remove transport/system hint lines that are not user utterances.
|
|
266
|
+
result = result.replace(/^\[System:[^\n]*\]\s*$/gim, "");
|
|
267
|
+
result = result.replace(/^\[message_id:\s*[^\]]+\]\s*$/gim, "");
|
|
268
|
+
|
|
269
|
+
// Collapse extra blank lines.
|
|
270
|
+
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Keep the latest meaningful line from cleaned metadata-injected payload.
|
|
276
|
+
*/
|
|
277
|
+
function extractLatestUtteranceFromCleanText(text) {
|
|
278
|
+
if (!text || typeof text !== "string") return "";
|
|
279
|
+
const lines = text
|
|
280
|
+
.split("\n")
|
|
281
|
+
.map((line) => line.trim())
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.filter((line) => !isMetadataOnlyText(line));
|
|
284
|
+
if (lines.length === 0) return "";
|
|
285
|
+
return lines[lines.length - 1];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function escapeRegex(text) {
|
|
289
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Prefix utterance with parsed sender name when available.
|
|
294
|
+
* This preserves who said it in multi-user group contexts.
|
|
295
|
+
*/
|
|
296
|
+
function attachSenderPrefix(utterance, senderName) {
|
|
297
|
+
const content = String(utterance || "").trim();
|
|
298
|
+
if (!content) return "";
|
|
299
|
+
const sender = String(senderName || "").trim();
|
|
300
|
+
if (!sender) return content;
|
|
301
|
+
|
|
302
|
+
const senderRegex = new RegExp(`^${escapeRegex(sender)}\\s*[::]`);
|
|
303
|
+
if (senderRegex.test(content)) return content;
|
|
304
|
+
return `${sender}: ${content}`;
|
|
305
|
+
}
|
|
185
306
|
|
|
186
307
|
/**
|
|
187
308
|
* Normalize user message text before caching/storing.
|
|
188
309
|
* For channel-formatted payloads (e.g. Feishu), keep only the actual user utterance
|
|
189
310
|
* and drop prepended system transcript lines.
|
|
190
311
|
*/
|
|
191
|
-
function normalizeUserMessageContent(content) {
|
|
192
|
-
const text = stripPrependedPrompt(content);
|
|
193
|
-
if (!text) return "";
|
|
194
|
-
|
|
195
|
-
const normalized = String(text).replace(/\r\n/g, "\n").trim();
|
|
196
|
-
if (!normalized) return "";
|
|
197
|
-
|
|
198
|
-
const parsedIdentity = parseIdentityFromUntrustedMetadata(normalized);
|
|
199
|
-
|
|
200
|
-
// New relay payload format: strip injected metadata blocks first.
|
|
201
|
-
const strippedMetadata = stripInjectedMetadata(normalized);
|
|
202
|
-
if (strippedMetadata && strippedMetadata !== normalized) {
|
|
203
|
-
const latestUtterance = extractLatestUtteranceFromCleanText(strippedMetadata);
|
|
204
|
-
if (latestUtterance && !isMetadataOnlyText(latestUtterance)) {
|
|
205
|
-
return attachSenderPrefix(latestUtterance, parsedIdentity?.userName);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Feishu: preserve the final traceable tail block (contains platform user id/message_id).
|
|
210
|
-
const feishuTailBlock = extractFeishuTailBlock(normalized);
|
|
211
|
-
if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
|
|
212
|
-
return feishuTailBlock;
|
|
312
|
+
function normalizeUserMessageContent(content) {
|
|
313
|
+
const text = stripPrependedPrompt(content);
|
|
314
|
+
if (!text) return "";
|
|
315
|
+
|
|
316
|
+
const normalized = String(text).replace(/\r\n/g, "\n").trim();
|
|
317
|
+
if (!normalized) return "";
|
|
318
|
+
|
|
319
|
+
const parsedIdentity = parseIdentityFromUntrustedMetadata(normalized);
|
|
320
|
+
|
|
321
|
+
// New relay payload format: strip injected metadata blocks first.
|
|
322
|
+
const strippedMetadata = stripInjectedMetadata(normalized);
|
|
323
|
+
if (strippedMetadata && strippedMetadata !== normalized) {
|
|
324
|
+
const latestUtterance = extractLatestUtteranceFromCleanText(strippedMetadata);
|
|
325
|
+
if (latestUtterance && !isMetadataOnlyText(latestUtterance)) {
|
|
326
|
+
return attachSenderPrefix(latestUtterance, parsedIdentity?.userName);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Feishu: preserve the final traceable tail block (contains platform user id/message_id).
|
|
331
|
+
const feishuTailBlock = extractFeishuTailBlock(normalized);
|
|
332
|
+
if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
|
|
333
|
+
return feishuTailBlock;
|
|
213
334
|
}
|
|
214
335
|
|
|
215
336
|
// Generic channel relays: fallback to latest "System: ...: <message>" line.
|
|
@@ -231,18 +352,18 @@ function normalizeUserMessageContent(content) {
|
|
|
231
352
|
|
|
232
353
|
// Discord-style channel-formatted payload:
|
|
233
354
|
// [from: username (id)] actual message
|
|
234
|
-
const discordTail = normalized.match(
|
|
235
|
-
/\[from:\s*[^\(\]\n]+?\s*\(\d{6,}\)\]\s*([\s\S]*?)$/i
|
|
236
|
-
);
|
|
237
|
-
if (discordTail && discordTail[1]) {
|
|
238
|
-
const candidate = discordTail[1].trim();
|
|
239
|
-
if (!isMetadataOnlyText(candidate)) return candidate;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return isMetadataOnlyText(normalized)
|
|
243
|
-
? ""
|
|
244
|
-
: attachSenderPrefix(normalized, parsedIdentity?.userName);
|
|
245
|
-
}
|
|
355
|
+
const discordTail = normalized.match(
|
|
356
|
+
/\[from:\s*[^\(\]\n]+?\s*\(\d{6,}\)\]\s*([\s\S]*?)$/i
|
|
357
|
+
);
|
|
358
|
+
if (discordTail && discordTail[1]) {
|
|
359
|
+
const candidate = discordTail[1].trim();
|
|
360
|
+
if (!isMetadataOnlyText(candidate)) return candidate;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return isMetadataOnlyText(normalized)
|
|
364
|
+
? ""
|
|
365
|
+
: attachSenderPrefix(normalized, parsedIdentity?.userName);
|
|
366
|
+
}
|
|
246
367
|
|
|
247
368
|
/**
|
|
248
369
|
* Normalize assistant message text before caching/storing.
|
|
@@ -256,139 +377,139 @@ function normalizeAssistantMessageContent(content) {
|
|
|
256
377
|
return normalized;
|
|
257
378
|
}
|
|
258
379
|
|
|
259
|
-
/**
|
|
260
|
-
* Parse JSON objects from markdown code fences after a specific label.
|
|
261
|
-
* Example:
|
|
262
|
-
* Sender (untrusted metadata):
|
|
263
|
-
* ```json
|
|
264
|
-
* { "id": "123" }
|
|
265
|
-
* ```
|
|
266
|
-
*/
|
|
267
|
-
function parseJsonFencesAfterLabel(text, labelPattern) {
|
|
268
|
-
if (!text || typeof text !== "string") return [];
|
|
269
|
-
|
|
270
|
-
const results = [];
|
|
271
|
-
const regex = new RegExp(`${labelPattern}\\s*:\\s*\`\`\`json\\s*([\\s\\S]*?)\\s*\`\`\``, "gi");
|
|
272
|
-
let match;
|
|
273
|
-
while ((match = regex.exec(text)) !== null) {
|
|
274
|
-
if (!match[1]) continue;
|
|
275
|
-
try {
|
|
276
|
-
const parsed = JSON.parse(match[1]);
|
|
277
|
-
if (parsed && typeof parsed === "object") {
|
|
278
|
-
results.push(parsed);
|
|
279
|
-
}
|
|
280
|
-
} catch (_) {
|
|
281
|
-
// Ignore malformed JSON blocks and continue matching others.
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
return results;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Normalize potential user id values into non-empty strings.
|
|
289
|
-
*/
|
|
290
|
-
function normalizeParsedUserId(value) {
|
|
291
|
-
if (value === undefined || value === null) return "";
|
|
292
|
-
const normalized = String(value).trim();
|
|
293
|
-
return normalized || "";
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Extract user id from a display label like:
|
|
298
|
-
* "用户250389 (ou_xxx)" / "name (123456789)".
|
|
299
|
-
*/
|
|
300
|
-
function extractUserIdFromLabel(label) {
|
|
301
|
-
const text = normalizeParsedUserId(label);
|
|
302
|
-
if (!text) return "";
|
|
303
|
-
const match = text.match(/\(\s*((?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)$/i);
|
|
304
|
-
return match?.[1] ? match[1] : "";
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Extract transport message id from current relay payload.
|
|
309
|
-
*/
|
|
310
|
-
function extractRelayMessageId(text) {
|
|
311
|
-
if (!text || typeof text !== "string") return "";
|
|
312
|
-
|
|
313
|
-
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
314
|
-
text,
|
|
315
|
-
"Conversation info \\(untrusted metadata\\)"
|
|
316
|
-
);
|
|
317
|
-
for (let i = conversationMetaList.length - 1; i >= 0; i--) {
|
|
318
|
-
const conversationMeta = conversationMetaList[i];
|
|
319
|
-
const messageId =
|
|
320
|
-
normalizeParsedUserId(conversationMeta?.message_id) ||
|
|
321
|
-
normalizeParsedUserId(conversationMeta?.messageId);
|
|
322
|
-
if (messageId) return messageId;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const messageIdMatch = text.match(/\[message_id:\s*([^\]\n]+)\]/i);
|
|
326
|
-
if (messageIdMatch?.[1]) return messageIdMatch[1].trim();
|
|
327
|
-
|
|
328
|
-
return "";
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Parse identity from new "untrusted metadata" JSON blocks.
|
|
333
|
-
* Priority:
|
|
334
|
-
* 1) Sender (untrusted metadata).id
|
|
335
|
-
* 2) Conversation info (untrusted metadata).sender_id
|
|
336
|
-
*/
|
|
337
|
-
function parseIdentityFromUntrustedMetadata(text) {
|
|
338
|
-
if (!text || typeof text !== "string") return null;
|
|
339
|
-
|
|
340
|
-
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
341
|
-
for (let i = senderMetaList.length - 1; i >= 0; i--) {
|
|
342
|
-
const senderMeta = senderMetaList[i];
|
|
343
|
-
const userId =
|
|
344
|
-
normalizeParsedUserId(senderMeta?.id) ||
|
|
345
|
-
extractUserIdFromLabel(senderMeta?.label);
|
|
346
|
-
if (userId) {
|
|
347
|
-
return {
|
|
348
|
-
platform: null,
|
|
349
|
-
userId,
|
|
350
|
-
userName: senderMeta?.name || senderMeta?.username || senderMeta?.label || null,
|
|
351
|
-
source: "sender-untrusted-metadata-json",
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
357
|
-
text,
|
|
358
|
-
"Conversation info \\(untrusted metadata\\)"
|
|
359
|
-
);
|
|
360
|
-
for (let i = conversationMetaList.length - 1; i >= 0; i--) {
|
|
361
|
-
const conversationMeta = conversationMetaList[i];
|
|
362
|
-
const userId =
|
|
363
|
-
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
364
|
-
normalizeParsedUserId(conversationMeta?.senderId);
|
|
365
|
-
if (userId) {
|
|
366
|
-
return {
|
|
367
|
-
platform: null,
|
|
368
|
-
userId,
|
|
369
|
-
userName: conversationMeta?.sender || null,
|
|
370
|
-
source: "conversation-untrusted-metadata-json",
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return null;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Parse platform identity hints from channel-formatted message text.
|
|
380
|
-
* Returns null when no platform-specific id can be extracted.
|
|
381
|
-
*/
|
|
382
|
-
function parsePlatformIdentity(text) {
|
|
383
|
-
if (!text || typeof text !== "string") return null;
|
|
384
|
-
|
|
385
|
-
// New Discord format first:
|
|
386
|
-
// Sender (untrusted metadata): ```json { "id": "116..." } ```
|
|
387
|
-
// Conversation info (untrusted metadata): ```json { "sender_id": "116..." } ```
|
|
388
|
-
const metadataIdentity = parseIdentityFromUntrustedMetadata(text);
|
|
389
|
-
if (metadataIdentity?.userId) {
|
|
390
|
-
return metadataIdentity;
|
|
391
|
-
}
|
|
380
|
+
/**
|
|
381
|
+
* Parse JSON objects from markdown code fences after a specific label.
|
|
382
|
+
* Example:
|
|
383
|
+
* Sender (untrusted metadata):
|
|
384
|
+
* ```json
|
|
385
|
+
* { "id": "123" }
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
function parseJsonFencesAfterLabel(text, labelPattern) {
|
|
389
|
+
if (!text || typeof text !== "string") return [];
|
|
390
|
+
|
|
391
|
+
const results = [];
|
|
392
|
+
const regex = new RegExp(`${labelPattern}\\s*:\\s*\`\`\`json\\s*([\\s\\S]*?)\\s*\`\`\``, "gi");
|
|
393
|
+
let match;
|
|
394
|
+
while ((match = regex.exec(text)) !== null) {
|
|
395
|
+
if (!match[1]) continue;
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(match[1]);
|
|
398
|
+
if (parsed && typeof parsed === "object") {
|
|
399
|
+
results.push(parsed);
|
|
400
|
+
}
|
|
401
|
+
} catch (_) {
|
|
402
|
+
// Ignore malformed JSON blocks and continue matching others.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return results;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Normalize potential user id values into non-empty strings.
|
|
410
|
+
*/
|
|
411
|
+
function normalizeParsedUserId(value) {
|
|
412
|
+
if (value === undefined || value === null) return "";
|
|
413
|
+
const normalized = String(value).trim();
|
|
414
|
+
return normalized || "";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Extract user id from a display label like:
|
|
419
|
+
* "用户250389 (ou_xxx)" / "name (123456789)".
|
|
420
|
+
*/
|
|
421
|
+
function extractUserIdFromLabel(label) {
|
|
422
|
+
const text = normalizeParsedUserId(label);
|
|
423
|
+
if (!text) return "";
|
|
424
|
+
const match = text.match(/\(\s*((?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)$/i);
|
|
425
|
+
return match?.[1] ? match[1] : "";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Extract transport message id from current relay payload.
|
|
430
|
+
*/
|
|
431
|
+
function extractRelayMessageId(text) {
|
|
432
|
+
if (!text || typeof text !== "string") return "";
|
|
433
|
+
|
|
434
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
435
|
+
text,
|
|
436
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
437
|
+
);
|
|
438
|
+
for (let i = conversationMetaList.length - 1; i >= 0; i--) {
|
|
439
|
+
const conversationMeta = conversationMetaList[i];
|
|
440
|
+
const messageId =
|
|
441
|
+
normalizeParsedUserId(conversationMeta?.message_id) ||
|
|
442
|
+
normalizeParsedUserId(conversationMeta?.messageId);
|
|
443
|
+
if (messageId) return messageId;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const messageIdMatch = text.match(/\[message_id:\s*([^\]\n]+)\]/i);
|
|
447
|
+
if (messageIdMatch?.[1]) return messageIdMatch[1].trim();
|
|
448
|
+
|
|
449
|
+
return "";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Parse identity from new "untrusted metadata" JSON blocks.
|
|
454
|
+
* Priority:
|
|
455
|
+
* 1) Sender (untrusted metadata).id
|
|
456
|
+
* 2) Conversation info (untrusted metadata).sender_id
|
|
457
|
+
*/
|
|
458
|
+
function parseIdentityFromUntrustedMetadata(text) {
|
|
459
|
+
if (!text || typeof text !== "string") return null;
|
|
460
|
+
|
|
461
|
+
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
462
|
+
for (let i = senderMetaList.length - 1; i >= 0; i--) {
|
|
463
|
+
const senderMeta = senderMetaList[i];
|
|
464
|
+
const userId =
|
|
465
|
+
normalizeParsedUserId(senderMeta?.id) ||
|
|
466
|
+
extractUserIdFromLabel(senderMeta?.label);
|
|
467
|
+
if (userId) {
|
|
468
|
+
return {
|
|
469
|
+
platform: null,
|
|
470
|
+
userId,
|
|
471
|
+
userName: senderMeta?.name || senderMeta?.username || senderMeta?.label || null,
|
|
472
|
+
source: "sender-untrusted-metadata-json",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
478
|
+
text,
|
|
479
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
480
|
+
);
|
|
481
|
+
for (let i = conversationMetaList.length - 1; i >= 0; i--) {
|
|
482
|
+
const conversationMeta = conversationMetaList[i];
|
|
483
|
+
const userId =
|
|
484
|
+
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
485
|
+
normalizeParsedUserId(conversationMeta?.senderId);
|
|
486
|
+
if (userId) {
|
|
487
|
+
return {
|
|
488
|
+
platform: null,
|
|
489
|
+
userId,
|
|
490
|
+
userName: conversationMeta?.sender || null,
|
|
491
|
+
source: "conversation-untrusted-metadata-json",
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Parse platform identity hints from channel-formatted message text.
|
|
501
|
+
* Returns null when no platform-specific id can be extracted.
|
|
502
|
+
*/
|
|
503
|
+
function parsePlatformIdentity(text) {
|
|
504
|
+
if (!text || typeof text !== "string") return null;
|
|
505
|
+
|
|
506
|
+
// New Discord format first:
|
|
507
|
+
// Sender (untrusted metadata): ```json { "id": "116..." } ```
|
|
508
|
+
// Conversation info (untrusted metadata): ```json { "sender_id": "116..." } ```
|
|
509
|
+
const metadataIdentity = parseIdentityFromUntrustedMetadata(text);
|
|
510
|
+
if (metadataIdentity?.userId) {
|
|
511
|
+
return metadataIdentity;
|
|
512
|
+
}
|
|
392
513
|
|
|
393
514
|
// Discord example:
|
|
394
515
|
// [from: huang yongqing (1470374017541079042)]
|
|
@@ -420,39 +541,39 @@ function parsePlatformIdentity(text) {
|
|
|
420
541
|
/**
|
|
421
542
|
* Parse all platform user ids from a text blob.
|
|
422
543
|
*/
|
|
423
|
-
function parseAllPlatformUserIds(text) {
|
|
424
|
-
if (!text || typeof text !== "string") return [];
|
|
425
|
-
|
|
426
|
-
const ids = [];
|
|
427
|
-
let match;
|
|
428
|
-
|
|
429
|
-
// New format: "Sender (untrusted metadata)" JSON blocks.
|
|
430
|
-
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
431
|
-
for (const senderMeta of senderMetaList) {
|
|
432
|
-
const parsedId =
|
|
433
|
-
normalizeParsedUserId(senderMeta?.id) ||
|
|
434
|
-
extractUserIdFromLabel(senderMeta?.label);
|
|
435
|
-
if (parsedId) ids.push(parsedId);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// New format: "Conversation info (untrusted metadata)" JSON blocks.
|
|
439
|
-
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
440
|
-
text,
|
|
441
|
-
"Conversation info \\(untrusted metadata\\)"
|
|
442
|
-
);
|
|
443
|
-
for (const conversationMeta of conversationMetaList) {
|
|
444
|
-
const parsedId =
|
|
445
|
-
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
446
|
-
normalizeParsedUserId(conversationMeta?.senderId);
|
|
447
|
-
if (parsedId) ids.push(parsedId);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Discord example:
|
|
451
|
-
// [from: huang yongqing (1470374017541079042)]
|
|
452
|
-
const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
|
|
453
|
-
while ((match = discordRegex.exec(text)) !== null) {
|
|
454
|
-
if (match[1]) ids.push(match[1]);
|
|
455
|
-
}
|
|
544
|
+
function parseAllPlatformUserIds(text) {
|
|
545
|
+
if (!text || typeof text !== "string") return [];
|
|
546
|
+
|
|
547
|
+
const ids = [];
|
|
548
|
+
let match;
|
|
549
|
+
|
|
550
|
+
// New format: "Sender (untrusted metadata)" JSON blocks.
|
|
551
|
+
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
552
|
+
for (const senderMeta of senderMetaList) {
|
|
553
|
+
const parsedId =
|
|
554
|
+
normalizeParsedUserId(senderMeta?.id) ||
|
|
555
|
+
extractUserIdFromLabel(senderMeta?.label);
|
|
556
|
+
if (parsedId) ids.push(parsedId);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// New format: "Conversation info (untrusted metadata)" JSON blocks.
|
|
560
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
561
|
+
text,
|
|
562
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
563
|
+
);
|
|
564
|
+
for (const conversationMeta of conversationMetaList) {
|
|
565
|
+
const parsedId =
|
|
566
|
+
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
567
|
+
normalizeParsedUserId(conversationMeta?.senderId);
|
|
568
|
+
if (parsedId) ids.push(parsedId);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Discord example:
|
|
572
|
+
// [from: huang yongqing (1470374017541079042)]
|
|
573
|
+
const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
|
|
574
|
+
while ((match = discordRegex.exec(text)) !== null) {
|
|
575
|
+
if (match[1]) ids.push(match[1]);
|
|
576
|
+
}
|
|
456
577
|
|
|
457
578
|
// Feishu example:
|
|
458
579
|
// [Feishu ...:ou_17b624... Wed ...] username: message
|
|
@@ -467,8 +588,8 @@ function parseAllPlatformUserIds(text) {
|
|
|
467
588
|
/**
|
|
468
589
|
* Collect distinct user ids parsed from all messages.
|
|
469
590
|
*/
|
|
470
|
-
function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
|
|
471
|
-
const unique = new Set();
|
|
591
|
+
function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
|
|
592
|
+
const unique = new Set();
|
|
472
593
|
|
|
473
594
|
if (Array.isArray(messages)) {
|
|
474
595
|
for (const msg of messages) {
|
|
@@ -484,96 +605,96 @@ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
|
|
|
484
605
|
unique.add(fallbackUserId);
|
|
485
606
|
}
|
|
486
607
|
|
|
487
|
-
return Array.from(unique);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function extractUserNameFromLabel(label) {
|
|
491
|
-
const text = normalizeParsedUserId(label);
|
|
492
|
-
if (!text) return "";
|
|
493
|
-
const match = text.match(
|
|
494
|
-
/^(.*?)\s*\(\s*(?:(?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)\s*$/i
|
|
495
|
-
);
|
|
496
|
-
return match?.[1] ? match[1].trim() : text;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Parse name-id pairs from supported relay payload formats.
|
|
501
|
-
*/
|
|
502
|
-
function parseNameIdPairsFromText(text) {
|
|
503
|
-
if (!text || typeof text !== "string") return [];
|
|
504
|
-
const pairs = [];
|
|
505
|
-
|
|
506
|
-
// New format: Sender (untrusted metadata)
|
|
507
|
-
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
508
|
-
for (const senderMeta of senderMetaList) {
|
|
509
|
-
const id =
|
|
510
|
-
normalizeParsedUserId(senderMeta?.id) ||
|
|
511
|
-
extractUserIdFromLabel(senderMeta?.label);
|
|
512
|
-
const name =
|
|
513
|
-
normalizeParsedUserId(senderMeta?.name) ||
|
|
514
|
-
normalizeParsedUserId(senderMeta?.username) ||
|
|
515
|
-
extractUserNameFromLabel(senderMeta?.label);
|
|
516
|
-
if (name && id) pairs.push([name, id]);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// New format: Conversation info (untrusted metadata)
|
|
520
|
-
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
521
|
-
text,
|
|
522
|
-
"Conversation info \\(untrusted metadata\\)"
|
|
523
|
-
);
|
|
524
|
-
for (const conversationMeta of conversationMetaList) {
|
|
525
|
-
const id =
|
|
526
|
-
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
527
|
-
normalizeParsedUserId(conversationMeta?.senderId);
|
|
528
|
-
const name = normalizeParsedUserId(conversationMeta?.sender);
|
|
529
|
-
if (name && id) pairs.push([name, id]);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Old Discord: [from: name (id)]
|
|
533
|
-
const discordRegex = /\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/gi;
|
|
534
|
-
let match;
|
|
535
|
-
while ((match = discordRegex.exec(text)) !== null) {
|
|
536
|
-
const name = normalizeParsedUserId(match[1]);
|
|
537
|
-
const id = normalizeParsedUserId(match[2]);
|
|
538
|
-
if (name && id) pairs.push([name, id]);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Old Feishu: [Feishu ...:ou_xxx ...] name:
|
|
542
|
-
const feishuRegex = /\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*([^:\n]+):/gi;
|
|
543
|
-
while ((match = feishuRegex.exec(text)) !== null) {
|
|
544
|
-
const id = normalizeParsedUserId(match[1]);
|
|
545
|
-
const name = normalizeParsedUserId(match[2]);
|
|
546
|
-
if (name && id) pairs.push([name, id]);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return pairs;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Collect distinct name->id mapping from all messages.
|
|
554
|
-
*/
|
|
555
|
-
function collectNameIdMapFromMessages(messages) {
|
|
556
|
-
const map = {};
|
|
557
|
-
|
|
558
|
-
if (!Array.isArray(messages)) return map;
|
|
559
|
-
|
|
560
|
-
for (const msg of messages) {
|
|
561
|
-
if (!msg) continue;
|
|
562
|
-
const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
|
|
563
|
-
const text = typeof sourceText === "string" ? sourceText : extractText(sourceText);
|
|
564
|
-
const pairs = parseNameIdPairsFromText(text);
|
|
565
|
-
for (const [name, id] of pairs) {
|
|
566
|
-
if (!name || !id) continue;
|
|
567
|
-
map[name] = id;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return map;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* Get latest user message text from cached messages.
|
|
576
|
-
*/
|
|
608
|
+
return Array.from(unique);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function extractUserNameFromLabel(label) {
|
|
612
|
+
const text = normalizeParsedUserId(label);
|
|
613
|
+
if (!text) return "";
|
|
614
|
+
const match = text.match(
|
|
615
|
+
/^(.*?)\s*\(\s*(?:(?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)\s*$/i
|
|
616
|
+
);
|
|
617
|
+
return match?.[1] ? match[1].trim() : text;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Parse name-id pairs from supported relay payload formats.
|
|
622
|
+
*/
|
|
623
|
+
function parseNameIdPairsFromText(text) {
|
|
624
|
+
if (!text || typeof text !== "string") return [];
|
|
625
|
+
const pairs = [];
|
|
626
|
+
|
|
627
|
+
// New format: Sender (untrusted metadata)
|
|
628
|
+
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
629
|
+
for (const senderMeta of senderMetaList) {
|
|
630
|
+
const id =
|
|
631
|
+
normalizeParsedUserId(senderMeta?.id) ||
|
|
632
|
+
extractUserIdFromLabel(senderMeta?.label);
|
|
633
|
+
const name =
|
|
634
|
+
normalizeParsedUserId(senderMeta?.name) ||
|
|
635
|
+
normalizeParsedUserId(senderMeta?.username) ||
|
|
636
|
+
extractUserNameFromLabel(senderMeta?.label);
|
|
637
|
+
if (name && id) pairs.push([name, id]);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// New format: Conversation info (untrusted metadata)
|
|
641
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
642
|
+
text,
|
|
643
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
644
|
+
);
|
|
645
|
+
for (const conversationMeta of conversationMetaList) {
|
|
646
|
+
const id =
|
|
647
|
+
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
648
|
+
normalizeParsedUserId(conversationMeta?.senderId);
|
|
649
|
+
const name = normalizeParsedUserId(conversationMeta?.sender);
|
|
650
|
+
if (name && id) pairs.push([name, id]);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Old Discord: [from: name (id)]
|
|
654
|
+
const discordRegex = /\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/gi;
|
|
655
|
+
let match;
|
|
656
|
+
while ((match = discordRegex.exec(text)) !== null) {
|
|
657
|
+
const name = normalizeParsedUserId(match[1]);
|
|
658
|
+
const id = normalizeParsedUserId(match[2]);
|
|
659
|
+
if (name && id) pairs.push([name, id]);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Old Feishu: [Feishu ...:ou_xxx ...] name:
|
|
663
|
+
const feishuRegex = /\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*([^:\n]+):/gi;
|
|
664
|
+
while ((match = feishuRegex.exec(text)) !== null) {
|
|
665
|
+
const id = normalizeParsedUserId(match[1]);
|
|
666
|
+
const name = normalizeParsedUserId(match[2]);
|
|
667
|
+
if (name && id) pairs.push([name, id]);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return pairs;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Collect distinct name->id mapping from all messages.
|
|
675
|
+
*/
|
|
676
|
+
function collectNameIdMapFromMessages(messages) {
|
|
677
|
+
const map = {};
|
|
678
|
+
|
|
679
|
+
if (!Array.isArray(messages)) return map;
|
|
680
|
+
|
|
681
|
+
for (const msg of messages) {
|
|
682
|
+
if (!msg) continue;
|
|
683
|
+
const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
|
|
684
|
+
const text = typeof sourceText === "string" ? sourceText : extractText(sourceText);
|
|
685
|
+
const pairs = parseNameIdPairsFromText(text);
|
|
686
|
+
for (const [name, id] of pairs) {
|
|
687
|
+
if (!name || !id) continue;
|
|
688
|
+
map[name] = id;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return map;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Get latest user message text from cached messages.
|
|
697
|
+
*/
|
|
577
698
|
function getLatestUserMessageText(messages) {
|
|
578
699
|
if (!Array.isArray(messages)) return "";
|
|
579
700
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -589,9 +710,9 @@ function getLatestUserMessageText(messages) {
|
|
|
589
710
|
* Resolve request identity with fallback:
|
|
590
711
|
* platform user id -> configured user id -> "openclaw-user"
|
|
591
712
|
*/
|
|
592
|
-
function resolveRequestIdentity(promptText, cfg) {
|
|
593
|
-
const parsed = parsePlatformIdentity(promptText);
|
|
594
|
-
if (parsed?.userId) {
|
|
713
|
+
function resolveRequestIdentity(promptText, cfg) {
|
|
714
|
+
const parsed = parsePlatformIdentity(promptText);
|
|
715
|
+
if (parsed?.userId) {
|
|
595
716
|
return {
|
|
596
717
|
userId: parsed.userId,
|
|
597
718
|
userName: parsed.userName || null,
|
|
@@ -600,7 +721,7 @@ function resolveRequestIdentity(promptText, cfg) {
|
|
|
600
721
|
};
|
|
601
722
|
}
|
|
602
723
|
|
|
603
|
-
if (cfg?.configuredUserId) {
|
|
724
|
+
if (cfg?.configuredUserId) {
|
|
604
725
|
return {
|
|
605
726
|
userId: cfg.configuredUserId,
|
|
606
727
|
userName: null,
|
|
@@ -609,8 +730,8 @@ function resolveRequestIdentity(promptText, cfg) {
|
|
|
609
730
|
};
|
|
610
731
|
}
|
|
611
732
|
|
|
612
|
-
return {
|
|
613
|
-
userId: "openclaw-user",
|
|
733
|
+
return {
|
|
734
|
+
userId: "openclaw-user",
|
|
614
735
|
userName: null,
|
|
615
736
|
platform: null,
|
|
616
737
|
source: "default-user-id",
|
|
@@ -803,7 +924,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
|
|
|
803
924
|
if (log?.info) {
|
|
804
925
|
log.info(`[Memory Plugin] Recall request URL: ${url}`);
|
|
805
926
|
}
|
|
806
|
-
const identity = resolveRequestIdentity(prompt, cfg);
|
|
927
|
+
const identity = resolveRequestIdentity(prompt, cfg);
|
|
807
928
|
const userId = sanitizeUserId(identity.userId);
|
|
808
929
|
const payload = {
|
|
809
930
|
query: prompt,
|
|
@@ -850,7 +971,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
|
|
|
850
971
|
/**
|
|
851
972
|
* Add memories to the API
|
|
852
973
|
*/
|
|
853
|
-
async function addMemory(messages, cfg, ctx, log) {
|
|
974
|
+
async function addMemory(messages, cfg, ctx, log, explicitSessionId) {
|
|
854
975
|
const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
|
|
855
976
|
const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
|
|
856
977
|
|
|
@@ -860,27 +981,27 @@ async function addMemory(messages, cfg, ctx, log) {
|
|
|
860
981
|
if (log?.info) {
|
|
861
982
|
log.info(`[Memory Plugin] Add-memory request URL: ${url}`);
|
|
862
983
|
}
|
|
863
|
-
const sessionId = resolveSessionId(ctx, null) || `session-${Date.now()}`;
|
|
984
|
+
const sessionId = explicitSessionId || resolveSessionId(ctx, null) || `session-${Date.now()}`;
|
|
864
985
|
const latestUserText = getLatestUserMessageText(messages);
|
|
865
|
-
const identity = resolveRequestIdentity(latestUserText, cfg);
|
|
986
|
+
const identity = resolveRequestIdentity(latestUserText, cfg);
|
|
866
987
|
const userId = sanitizeUserId(identity.userId);
|
|
867
|
-
const metadataUserIds = (() => {
|
|
868
|
-
const parsed = collectUniqueUserIdsFromMessages(messages, null)
|
|
869
|
-
.map((id) => sanitizeUserId(id))
|
|
870
|
-
.filter(Boolean);
|
|
871
|
-
if (parsed.length > 0) {
|
|
872
|
-
return Array.from(new Set(parsed));
|
|
873
|
-
}
|
|
874
|
-
return [userId];
|
|
875
|
-
})();
|
|
876
|
-
const nameIdMap = (() => {
|
|
877
|
-
const parsed = collectNameIdMapFromMessages(messages);
|
|
878
|
-
if (identity?.userName && userId && !parsed[identity.userName]) {
|
|
879
|
-
parsed[identity.userName] = userId;
|
|
880
|
-
}
|
|
881
|
-
return parsed;
|
|
882
|
-
})();
|
|
883
|
-
const agentId = cfg.agentId || ctx?.agentId || "main";
|
|
988
|
+
const metadataUserIds = (() => {
|
|
989
|
+
const parsed = collectUniqueUserIdsFromMessages(messages, null)
|
|
990
|
+
.map((id) => sanitizeUserId(id))
|
|
991
|
+
.filter(Boolean);
|
|
992
|
+
if (parsed.length > 0) {
|
|
993
|
+
return Array.from(new Set(parsed));
|
|
994
|
+
}
|
|
995
|
+
return [userId];
|
|
996
|
+
})();
|
|
997
|
+
const nameIdMap = (() => {
|
|
998
|
+
const parsed = collectNameIdMapFromMessages(messages);
|
|
999
|
+
if (identity?.userName && userId && !parsed[identity.userName]) {
|
|
1000
|
+
parsed[identity.userName] = userId;
|
|
1001
|
+
}
|
|
1002
|
+
return parsed;
|
|
1003
|
+
})();
|
|
1004
|
+
const agentId = cfg.agentId || ctx?.agentId || "main";
|
|
884
1005
|
|
|
885
1006
|
const payload = {
|
|
886
1007
|
user_id: userId,
|
|
@@ -894,22 +1015,22 @@ async function addMemory(messages, cfg, ctx, log) {
|
|
|
894
1015
|
async_mode: true,
|
|
895
1016
|
custom_workflows: {
|
|
896
1017
|
stream_params: {
|
|
897
|
-
metadata: JSON.stringify({
|
|
898
|
-
user_ids: metadataUserIds,
|
|
899
|
-
agent_ids: [agentId],
|
|
900
|
-
name_id_map: nameIdMap,
|
|
901
|
-
session_id: sessionId,
|
|
902
|
-
scenario: cfg.scenario || "openclaw-plugin",
|
|
903
|
-
}),
|
|
904
|
-
},
|
|
905
|
-
},
|
|
1018
|
+
metadata: JSON.stringify({
|
|
1019
|
+
user_ids: metadataUserIds,
|
|
1020
|
+
agent_ids: [agentId],
|
|
1021
|
+
name_id_map: nameIdMap,
|
|
1022
|
+
session_id: sessionId,
|
|
1023
|
+
scenario: cfg.scenario || "openclaw-plugin",
|
|
1024
|
+
}),
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
906
1027
|
};
|
|
907
1028
|
|
|
908
1029
|
if (log?.debug) {
|
|
909
|
-
log.debug(
|
|
910
|
-
`[Memory Plugin] add/message payload: user_id=${userId}, agent_id=${agentId}, conversation_id=${sessionId}, metadata.user_ids=${JSON.stringify(metadataUserIds)}, metadata.agent_ids=${JSON.stringify([agentId])}, metadata.name_id_map=${JSON.stringify(nameIdMap)}`
|
|
911
|
-
);
|
|
912
|
-
}
|
|
1030
|
+
log.debug(
|
|
1031
|
+
`[Memory Plugin] add/message payload: user_id=${userId}, agent_id=${agentId}, conversation_id=${sessionId}, metadata.user_ids=${JSON.stringify(metadataUserIds)}, metadata.agent_ids=${JSON.stringify([agentId])}, metadata.name_id_map=${JSON.stringify(nameIdMap)}`
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
913
1034
|
|
|
914
1035
|
try {
|
|
915
1036
|
const result = await httpRequest(url, {
|
|
@@ -1057,8 +1178,8 @@ function formatMemoriesForContext(memories, options = {}) {
|
|
|
1057
1178
|
* @param {number} maxTurns - Maximum number of turns to extract
|
|
1058
1179
|
* @returns {Array} Recent messages
|
|
1059
1180
|
*/
|
|
1060
|
-
function pickRecentMessages(messages, maxTurns = 10) {
|
|
1061
|
-
if (!messages || messages.length === 0) return [];
|
|
1181
|
+
function pickRecentMessages(messages, maxTurns = 10) {
|
|
1182
|
+
if (!messages || messages.length === 0) return [];
|
|
1062
1183
|
|
|
1063
1184
|
const result = [];
|
|
1064
1185
|
let turnCount = 0;
|
|
@@ -1073,7 +1194,7 @@ function pickRecentMessages(messages, maxTurns = 10) {
|
|
|
1073
1194
|
if (role === "system") continue;
|
|
1074
1195
|
|
|
1075
1196
|
// Count a turn when we see a user message after an assistant message
|
|
1076
|
-
if (role === "user" && lastRole
|
|
1197
|
+
if (role === "user" && isAssistantLikeRole(lastRole)) {
|
|
1077
1198
|
turnCount++;
|
|
1078
1199
|
}
|
|
1079
1200
|
|
|
@@ -1099,9 +1220,230 @@ function pickRecentMessages(messages, maxTurns = 10) {
|
|
|
1099
1220
|
lastRole = role;
|
|
1100
1221
|
}
|
|
1101
1222
|
|
|
1102
|
-
return result;
|
|
1223
|
+
return result;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function buildV2ConversationMessage(msg) {
|
|
1227
|
+
if (!msg) return null;
|
|
1228
|
+
|
|
1229
|
+
const role = normalizeCacheRole(msg.role);
|
|
1230
|
+
if (role === "system") return null;
|
|
1231
|
+
if (role !== "user" && role !== "assistant" && role !== "tool") return null;
|
|
1232
|
+
|
|
1233
|
+
const rawSource = msg.rawContent !== undefined ? msg.rawContent : msg.content;
|
|
1234
|
+
const rawContent = typeof rawSource === "string" ? rawSource : extractText(rawSource);
|
|
1235
|
+
const content =
|
|
1236
|
+
typeof msg.content === "string"
|
|
1237
|
+
? msg.content
|
|
1238
|
+
: extractText(msg.content);
|
|
1239
|
+
const toolCalls = role === "assistant" ? getMessageToolCalls(msg) : [];
|
|
1240
|
+
const toolCallId = role === "tool" ? getToolResultCallId(msg) : null;
|
|
1241
|
+
const toolName = role === "tool" ? getToolResultName(msg) : undefined;
|
|
1242
|
+
const hasToolContext =
|
|
1243
|
+
(role === "assistant" && toolCalls.length > 0) ||
|
|
1244
|
+
(role === "tool" && (!!toolCallId || !!toolName));
|
|
1245
|
+
|
|
1246
|
+
if (!content && !hasToolContext) return null;
|
|
1247
|
+
|
|
1248
|
+
const result = {
|
|
1249
|
+
role,
|
|
1250
|
+
content: content || "",
|
|
1251
|
+
rawContent: rawContent || undefined,
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
if (toolCalls.length > 0) result.tool_calls = toolCalls;
|
|
1255
|
+
if (toolCallId) result.tool_call_id = toolCallId;
|
|
1256
|
+
if (toolName) result.name = toolName;
|
|
1257
|
+
|
|
1258
|
+
return result;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function pickRecentContextMessagesV2(messages, maxTurns = 10) {
|
|
1262
|
+
if (!messages || messages.length === 0) return [];
|
|
1263
|
+
|
|
1264
|
+
const result = [];
|
|
1265
|
+
let turnCount = 0;
|
|
1266
|
+
let lastRole = null;
|
|
1267
|
+
|
|
1268
|
+
for (let i = messages.length - 1; i >= 0 && turnCount < maxTurns; i--) {
|
|
1269
|
+
const msg = messages[i];
|
|
1270
|
+
const role = normalizeCacheRole(msg?.role);
|
|
1271
|
+
|
|
1272
|
+
if (role === "system") continue;
|
|
1273
|
+
|
|
1274
|
+
if (role === "user" && isAssistantLikeRole(lastRole)) {
|
|
1275
|
+
turnCount++;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (turnCount >= maxTurns) break;
|
|
1279
|
+
|
|
1280
|
+
const contextMessage = buildV2ConversationMessage(msg);
|
|
1281
|
+
if (contextMessage) {
|
|
1282
|
+
result.unshift(contextMessage);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
lastRole = role;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return result;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function stripToolMessagesForV1(messages) {
|
|
1292
|
+
if (!Array.isArray(messages)) return [];
|
|
1293
|
+
|
|
1294
|
+
return messages
|
|
1295
|
+
.filter((msg) => msg && (msg.role === "user" || msg.role === "assistant"))
|
|
1296
|
+
.filter((msg) => String(msg.content || "").trim())
|
|
1297
|
+
.map((msg) => ({
|
|
1298
|
+
role: msg.role,
|
|
1299
|
+
content: msg.content,
|
|
1300
|
+
rawContent: msg.rawContent,
|
|
1301
|
+
}));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Extract tool calls from cached messages for the v2 context protocol.
|
|
1306
|
+
* @param {Array} messages - All cached messages
|
|
1307
|
+
* @returns {Array} Tool call records
|
|
1308
|
+
*/
|
|
1309
|
+
function extractToolCalls(messages) {
|
|
1310
|
+
if (!messages || messages.length === 0) return [];
|
|
1311
|
+
|
|
1312
|
+
const calls = [];
|
|
1313
|
+
for (const msg of messages) {
|
|
1314
|
+
if (msg.role === "assistant") {
|
|
1315
|
+
const toolCalls = getMessageToolCalls(msg);
|
|
1316
|
+
for (const tc of toolCalls) {
|
|
1317
|
+
calls.push({
|
|
1318
|
+
tool_name: tc.function?.name || tc.name || "unknown",
|
|
1319
|
+
arguments: tc.function?.arguments || tc.arguments || {},
|
|
1320
|
+
call_id: tc.id || null,
|
|
1321
|
+
result: null,
|
|
1322
|
+
success: null,
|
|
1323
|
+
duration_ms: null,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (msg.role === "tool") {
|
|
1329
|
+
const toolCallId = getToolResultCallId(msg);
|
|
1330
|
+
const toolName = getToolResultName(msg);
|
|
1331
|
+
const match = calls.find((call) =>
|
|
1332
|
+
(toolCallId && call.call_id === toolCallId) ||
|
|
1333
|
+
(!toolCallId && toolName && call.tool_name === toolName && call.result == null)
|
|
1334
|
+
);
|
|
1335
|
+
if (match) {
|
|
1336
|
+
const resultText = extractText(msg.rawContent !== undefined ? msg.rawContent : msg.content);
|
|
1337
|
+
match.result = truncate(resultText, 2000);
|
|
1338
|
+
match.success = !isErrorResult(resultText);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
return calls;
|
|
1103
1344
|
}
|
|
1104
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* Check if text looks like an error result
|
|
1348
|
+
* @param {string} text - Result text
|
|
1349
|
+
* @returns {boolean}
|
|
1350
|
+
*/
|
|
1351
|
+
function isErrorResult(text) {
|
|
1352
|
+
if (!text) return false;
|
|
1353
|
+
const lower = String(text).toLowerCase();
|
|
1354
|
+
return lower.includes("error") || lower.includes("exception") ||
|
|
1355
|
+
lower.includes("failed") || lower.includes("traceback") ||
|
|
1356
|
+
lower.includes("enoent") || lower.includes("permission denied");
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Collect context blocks from messages (v2 protocol)
|
|
1361
|
+
* @param {Array} messages - All cached messages
|
|
1362
|
+
* @param {Object} cfg - Configuration
|
|
1363
|
+
* @returns {Array} Context blocks
|
|
1364
|
+
*/
|
|
1365
|
+
function collectContextBlocks(messages, cfg) {
|
|
1366
|
+
const blocks = [];
|
|
1367
|
+
|
|
1368
|
+
const conversationMsgs =
|
|
1369
|
+
cfg.captureToolCalls === false
|
|
1370
|
+
? pickRecentMessages(messages, cfg.maxTurnsToStore || 10)
|
|
1371
|
+
: pickRecentContextMessagesV2(messages, cfg.maxTurnsToStore || 10);
|
|
1372
|
+
if (conversationMsgs.length > 0) {
|
|
1373
|
+
blocks.push({
|
|
1374
|
+
type: "conversation",
|
|
1375
|
+
data: { messages: conversationMsgs },
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return blocks;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Add context via v2 protocol
|
|
1384
|
+
*/
|
|
1385
|
+
async function addContextV2(contextBlocks, cfg, ctx, log, sessionId) {
|
|
1386
|
+
const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
|
|
1387
|
+
const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
|
|
1388
|
+
|
|
1389
|
+
if (!apiKey) return;
|
|
1390
|
+
|
|
1391
|
+
const url = `${baseUrl}/api/plugin/v2/add/context`;
|
|
1392
|
+
const latestUserText = getLatestUserMessageText(
|
|
1393
|
+
contextBlocks.find((block) => block.type === "conversation")?.data?.messages || []
|
|
1394
|
+
);
|
|
1395
|
+
const identity = resolveRequestIdentity(latestUserText, cfg);
|
|
1396
|
+
const userId = sanitizeUserId(identity.userId);
|
|
1397
|
+
const agentId = cfg.agentId || ctx?.agentId || "main";
|
|
1398
|
+
|
|
1399
|
+
const payload = {
|
|
1400
|
+
user_id: userId,
|
|
1401
|
+
conversation_id: sessionId,
|
|
1402
|
+
agent_id: agentId,
|
|
1403
|
+
tags: cfg.tags || ["openclaw"],
|
|
1404
|
+
async_mode: true,
|
|
1405
|
+
protocol_version: "2.0",
|
|
1406
|
+
context_blocks: contextBlocks,
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
if (log?.debug) {
|
|
1410
|
+
log.debug(
|
|
1411
|
+
`[Memory Plugin] v2 add/context: blocks=${contextBlocks.length}, types=${contextBlocks.map((block) => block.type).join(",")}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
const result = await httpRequest(url, {
|
|
1417
|
+
method: "POST",
|
|
1418
|
+
headers: {
|
|
1419
|
+
"Content-Type": "application/json",
|
|
1420
|
+
"x-api-key": apiKey,
|
|
1421
|
+
"x-request-id": ctx?.requestId || `openclaw-${Date.now()}`,
|
|
1422
|
+
"x-plugin-version": PLUGIN_VERSION,
|
|
1423
|
+
"x-client-type": "plugin",
|
|
1424
|
+
},
|
|
1425
|
+
body: JSON.stringify(payload),
|
|
1426
|
+
}, cfg, log);
|
|
1427
|
+
|
|
1428
|
+
const memoryCount = result?.memories_count || 0;
|
|
1429
|
+
if (log?.info) {
|
|
1430
|
+
log.info(
|
|
1431
|
+
`[Memory Plugin] v2 add/context success: ${memoryCount} memories from ${result?.blocks_processed || 0} blocks`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return result;
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
if (log?.warn) {
|
|
1437
|
+
log.warn(`[Memory Plugin] v2 add/context failed, falling back to v1: ${error.message}`);
|
|
1438
|
+
}
|
|
1439
|
+
const conversationBlock = contextBlocks.find((block) => block.type === "conversation");
|
|
1440
|
+
if (conversationBlock) {
|
|
1441
|
+
return await addMemory(stripToolMessagesForV1(conversationBlock.data.messages), cfg, ctx, log, sessionId);
|
|
1442
|
+
}
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1105
1447
|
/**
|
|
1106
1448
|
* Check if conversation has enough turns to be worth storing
|
|
1107
1449
|
* @param {Array} messages - Messages to check
|
|
@@ -1121,7 +1463,7 @@ function isConversationWorthStoring(messages, cfg) {
|
|
|
1121
1463
|
if (msg.role === "system") continue;
|
|
1122
1464
|
|
|
1123
1465
|
if (msg.role === "user") {
|
|
1124
|
-
if (lastRole
|
|
1466
|
+
if (isAssistantLikeRole(lastRole)) {
|
|
1125
1467
|
turns++;
|
|
1126
1468
|
}
|
|
1127
1469
|
}
|
|
@@ -1154,44 +1496,44 @@ function getSessionCache(sessionId) {
|
|
|
1154
1496
|
/**
|
|
1155
1497
|
* Add message to session cache
|
|
1156
1498
|
*/
|
|
1157
|
-
function addToSessionCache(sessionId, message) {
|
|
1158
|
-
const cache = getSessionCache(sessionId);
|
|
1159
|
-
const now = Date.now();
|
|
1160
|
-
const incoming = {
|
|
1161
|
-
...message,
|
|
1162
|
-
cachedAt: now,
|
|
1163
|
-
};
|
|
1164
|
-
|
|
1165
|
-
const prev = cache.messages.length > 0 ? cache.messages[cache.messages.length - 1] : null;
|
|
1166
|
-
if (prev) {
|
|
1167
|
-
const sameRole = prev.role === incoming.role;
|
|
1168
|
-
const sameMessageId =
|
|
1169
|
-
incoming.messageId &&
|
|
1170
|
-
prev.messageId &&
|
|
1171
|
-
String(incoming.messageId) === String(prev.messageId);
|
|
1172
|
-
const sameContent =
|
|
1173
|
-
sameRole &&
|
|
1174
|
-
String(prev.content || "") === String(incoming.content || "") &&
|
|
1175
|
-
String(prev.rawContent || "") === String(incoming.rawContent || "");
|
|
1176
|
-
const withinWindow = now - (prev.cachedAt || 0) <= CACHE_DEDUP_WINDOW_MS;
|
|
1177
|
-
|
|
1178
|
-
// Dedup duplicate hook triggers for the same relay message.
|
|
1179
|
-
if ((sameRole && sameMessageId) || (sameContent && withinWindow)) {
|
|
1180
|
-
cache.lastActivity = now;
|
|
1181
|
-
return cache;
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
cache.messages.push(incoming);
|
|
1186
|
-
cache.lastActivity = Date.now();
|
|
1187
|
-
|
|
1188
|
-
// Count turns (user message after assistant = new turn)
|
|
1189
|
-
if (incoming.role === "user" && cache.messages.length > 1) {
|
|
1190
|
-
const prevMsg = cache.messages[cache.messages.length - 2];
|
|
1191
|
-
if (prevMsg && prevMsg.role
|
|
1192
|
-
cache.turnCount++;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1499
|
+
function addToSessionCache(sessionId, message) {
|
|
1500
|
+
const cache = getSessionCache(sessionId);
|
|
1501
|
+
const now = Date.now();
|
|
1502
|
+
const incoming = {
|
|
1503
|
+
...message,
|
|
1504
|
+
cachedAt: now,
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
const prev = cache.messages.length > 0 ? cache.messages[cache.messages.length - 1] : null;
|
|
1508
|
+
if (prev) {
|
|
1509
|
+
const sameRole = prev.role === incoming.role;
|
|
1510
|
+
const sameMessageId =
|
|
1511
|
+
incoming.messageId &&
|
|
1512
|
+
prev.messageId &&
|
|
1513
|
+
String(incoming.messageId) === String(prev.messageId);
|
|
1514
|
+
const sameContent =
|
|
1515
|
+
sameRole &&
|
|
1516
|
+
String(prev.content || "") === String(incoming.content || "") &&
|
|
1517
|
+
String(prev.rawContent || "") === String(incoming.rawContent || "");
|
|
1518
|
+
const withinWindow = now - (prev.cachedAt || 0) <= CACHE_DEDUP_WINDOW_MS;
|
|
1519
|
+
|
|
1520
|
+
// Dedup duplicate hook triggers for the same relay message.
|
|
1521
|
+
if ((sameRole && sameMessageId) || (sameContent && withinWindow)) {
|
|
1522
|
+
cache.lastActivity = now;
|
|
1523
|
+
return cache;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
cache.messages.push(incoming);
|
|
1528
|
+
cache.lastActivity = Date.now();
|
|
1529
|
+
|
|
1530
|
+
// Count turns (user message after assistant = new turn)
|
|
1531
|
+
if (incoming.role === "user" && cache.messages.length > 1) {
|
|
1532
|
+
const prevMsg = cache.messages[cache.messages.length - 2];
|
|
1533
|
+
if (prevMsg && isAssistantLikeRole(prevMsg.role)) {
|
|
1534
|
+
cache.turnCount++;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1195
1537
|
|
|
1196
1538
|
return cache;
|
|
1197
1539
|
}
|
|
@@ -1300,6 +1642,15 @@ async function flushSession(sessionId, cfg, ctx, log) {
|
|
|
1300
1642
|
if (log?.info) {
|
|
1301
1643
|
log.info(`[Memory Plugin] Flushing session ${sessionId}: ${messagesToSave.length} messages, ${cache.turnCount} turns`);
|
|
1302
1644
|
}
|
|
1645
|
+
|
|
1646
|
+
if (cfg.useV2Protocol !== false) {
|
|
1647
|
+
const contextBlocks = collectContextBlocks(cache.messages, cfg);
|
|
1648
|
+
if (contextBlocks.length > 0) {
|
|
1649
|
+
await addContextV2(contextBlocks, cfg, ctx, log, sessionId);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1303
1654
|
await addMemory(messagesToSave, cfg, ctx, log, sessionId);
|
|
1304
1655
|
} catch (error) {
|
|
1305
1656
|
if (log?.warn) {
|
|
@@ -1348,18 +1699,18 @@ function registerPlugin(api) {
|
|
|
1348
1699
|
return;
|
|
1349
1700
|
}
|
|
1350
1701
|
|
|
1351
|
-
const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
|
|
1352
|
-
const userContent = normalizeUserMessageContent(prompt);
|
|
1353
|
-
const rawUserContent = stripPrependedPrompt(prompt);
|
|
1354
|
-
const messageId = extractRelayMessageId(rawUserContent || prompt);
|
|
1355
|
-
if (userContent) {
|
|
1356
|
-
addToSessionCache(sessionId, {
|
|
1357
|
-
role: "user",
|
|
1358
|
-
content: userContent,
|
|
1359
|
-
rawContent: rawUserContent,
|
|
1360
|
-
messageId: messageId || undefined,
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1702
|
+
const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
|
|
1703
|
+
const userContent = normalizeUserMessageContent(prompt);
|
|
1704
|
+
const rawUserContent = stripPrependedPrompt(prompt);
|
|
1705
|
+
const messageId = extractRelayMessageId(rawUserContent || prompt);
|
|
1706
|
+
if (userContent) {
|
|
1707
|
+
addToSessionCache(sessionId, {
|
|
1708
|
+
role: "user",
|
|
1709
|
+
content: userContent,
|
|
1710
|
+
rawContent: rawUserContent,
|
|
1711
|
+
messageId: messageId || undefined,
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1363
1714
|
|
|
1364
1715
|
try {
|
|
1365
1716
|
const memories = await retrieveMemory(prompt, cfg, ctx, log);
|
|
@@ -1406,22 +1757,28 @@ function registerPlugin(api) {
|
|
|
1406
1757
|
return;
|
|
1407
1758
|
}
|
|
1408
1759
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
const content = normalizeAssistantMessageContent(assistantContent);
|
|
1412
|
-
const rawContent = extractText(assistantContent);
|
|
1413
|
-
if (content) {
|
|
1414
|
-
addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
|
|
1415
|
-
}
|
|
1416
|
-
} else if (event?.messages?.length) {
|
|
1760
|
+
if (event?.messages?.length) {
|
|
1761
|
+
const pending = [];
|
|
1417
1762
|
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1763
|
+
const role = event.messages[i].role;
|
|
1764
|
+
if (role === "system") continue;
|
|
1765
|
+
if (role === "user") break;
|
|
1766
|
+
|
|
1767
|
+
const cacheMessage = buildCacheMessageFromTranscript(event.messages[i]);
|
|
1768
|
+
if (cacheMessage) pending.unshift(cacheMessage);
|
|
1769
|
+
}
|
|
1770
|
+
for (const cacheMessage of pending) {
|
|
1771
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1772
|
+
}
|
|
1773
|
+
} else {
|
|
1774
|
+
const assistantContent = event?.response || event?.result;
|
|
1775
|
+
if (assistantContent) {
|
|
1776
|
+
const cacheMessage = buildCacheMessageFromTranscript({
|
|
1777
|
+
role: "assistant",
|
|
1778
|
+
content: assistantContent,
|
|
1779
|
+
});
|
|
1780
|
+
if (cacheMessage) {
|
|
1781
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1425
1782
|
}
|
|
1426
1783
|
}
|
|
1427
1784
|
}
|
|
@@ -1486,18 +1843,18 @@ function createHooksPlugin(config) {
|
|
|
1486
1843
|
return;
|
|
1487
1844
|
}
|
|
1488
1845
|
|
|
1489
|
-
const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
|
|
1490
|
-
const userContent = normalizeUserMessageContent(prompt);
|
|
1491
|
-
const rawUserContent = stripPrependedPrompt(prompt);
|
|
1492
|
-
const messageId = extractRelayMessageId(rawUserContent || prompt);
|
|
1493
|
-
if (userContent) {
|
|
1494
|
-
addToSessionCache(sessionId, {
|
|
1495
|
-
role: "user",
|
|
1496
|
-
content: userContent,
|
|
1497
|
-
rawContent: rawUserContent,
|
|
1498
|
-
messageId: messageId || undefined,
|
|
1499
|
-
});
|
|
1500
|
-
}
|
|
1846
|
+
const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
|
|
1847
|
+
const userContent = normalizeUserMessageContent(prompt);
|
|
1848
|
+
const rawUserContent = stripPrependedPrompt(prompt);
|
|
1849
|
+
const messageId = extractRelayMessageId(rawUserContent || prompt);
|
|
1850
|
+
if (userContent) {
|
|
1851
|
+
addToSessionCache(sessionId, {
|
|
1852
|
+
role: "user",
|
|
1853
|
+
content: userContent,
|
|
1854
|
+
rawContent: rawUserContent,
|
|
1855
|
+
messageId: messageId || undefined,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1501
1858
|
|
|
1502
1859
|
try {
|
|
1503
1860
|
const memories = await retrieveMemory(prompt, cfg, ctx, log);
|
|
@@ -1561,43 +1918,37 @@ function createHooksPlugin(config) {
|
|
|
1561
1918
|
const cache = getSessionCache(sessionId);
|
|
1562
1919
|
if (cache.messages.length === 0) {
|
|
1563
1920
|
for (const msg of event.messages) {
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
? stripPrependedPrompt(msg.content)
|
|
1570
|
-
: extractText(msg.content);
|
|
1571
|
-
const messageId = msg.role === "user" ? extractRelayMessageId(rawSource || "") : "";
|
|
1572
|
-
if (content) {
|
|
1573
|
-
addToSessionCache(sessionId, {
|
|
1574
|
-
role: msg.role,
|
|
1575
|
-
content,
|
|
1576
|
-
rawContent: rawSource || undefined,
|
|
1577
|
-
messageId: messageId || undefined,
|
|
1578
|
-
});
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1921
|
+
const cacheMessage = buildCacheMessageFromTranscript(msg);
|
|
1922
|
+
if (cacheMessage) {
|
|
1923
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1581
1926
|
} else {
|
|
1582
|
-
//
|
|
1927
|
+
// Add trailing assistant/tool messages from the latest completion.
|
|
1928
|
+
const pending = [];
|
|
1583
1929
|
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1930
|
+
const msg = event.messages[i];
|
|
1931
|
+
if (msg.role === "system") continue;
|
|
1932
|
+
if (msg.role === "user") break;
|
|
1933
|
+
|
|
1934
|
+
const cacheMessage = buildCacheMessageFromTranscript(msg);
|
|
1935
|
+
if (cacheMessage) {
|
|
1936
|
+
pending.unshift(cacheMessage);
|
|
1591
1937
|
}
|
|
1592
1938
|
}
|
|
1939
|
+
for (const cacheMessage of pending) {
|
|
1940
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1941
|
+
}
|
|
1593
1942
|
}
|
|
1594
1943
|
} else {
|
|
1595
1944
|
const assistantContent = event?.response || event?.result;
|
|
1596
1945
|
if (assistantContent) {
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1946
|
+
const cacheMessage = buildCacheMessageFromTranscript({
|
|
1947
|
+
role: "assistant",
|
|
1948
|
+
content: assistantContent,
|
|
1949
|
+
});
|
|
1950
|
+
if (cacheMessage) {
|
|
1951
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1601
1952
|
}
|
|
1602
1953
|
}
|
|
1603
1954
|
}
|