@openviking/openclaw-plugin 2026.5.8

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.
@@ -0,0 +1,453 @@
1
+ export const MEMORY_TRIGGERS = [
2
+ /remember|preference|prefer|important|decision|decided|always|never/i,
3
+ /记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i,
4
+ /[\w.-]+@[\w.-]+\.\w+/,
5
+ /\+\d{10,}/,
6
+ /(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i,
7
+ /(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i,
8
+ /(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i,
9
+ ];
10
+ const CJK_CHAR_REGEX = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
11
+ const RELEVANT_MEMORIES_BLOCK_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>/gi;
12
+ const CONVERSATION_METADATA_BLOCK_RE = /(?:^|\n)\s*(?:Conversation info|Conversation metadata|会话信息|对话信息)\s*(?:\([^)]+\))?\s*:\s*```[\s\S]*?```/gi;
13
+ /** Strips "Sender (untrusted metadata): ```json ... ```" so capture sends clean text to OpenViking extract. */
14
+ const SENDER_METADATA_BLOCK_RE = /Sender\s*\([^)]*\)\s*:\s*```[\s\S]*?```/gi;
15
+ const FENCED_JSON_BLOCK_RE = /```json\s*([\s\S]*?)```/gi;
16
+ const METADATA_JSON_KEY_RE = /"(session|sessionid|sessionkey|conversationid|channel|sender|userid|agentid|timestamp|timezone)"\s*:/gi;
17
+ const LEADING_TIMESTAMP_PREFIX_RE = /^\s*(?!\[\[)\[(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[a-z]*\s+)?(?:\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{2,4})(?:[T\s]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{1,2}(?::\d{2})?)?(?:\s*[A-Z]{1,5}(?:[+-]\d{1,2})?)?)?\s*\]\s*/i;
18
+ const COMPACTED_SYSTEM_MSG_RE = /^System:\s*\[.*?\]\s*Compacted\s*(.+)$/i;
19
+ const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i;
20
+ const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u;
21
+ const SUBAGENT_CONTEXT_RE = /^\s*\[Subagent Context\]/i;
22
+ const MEMORY_INTENT_RE = /记住|记下|remember|save|store|偏好|preference|规则|rule|事实|fact/i;
23
+ const QUESTION_CUE_RE = /[??]|\b(?:what|when|where|who|why|how|which|can|could|would|did|does|is|are)\b|^(?:请问|能否|可否|怎么|如何|什么时候|谁|什么|哪|是否)/i;
24
+ export const CAPTURE_LIMIT = 3;
25
+ function resolveCaptureMinLength(text) {
26
+ return CJK_CHAR_REGEX.test(text) ? 4 : 10;
27
+ }
28
+ function looksLikeMetadataJsonBlock(content) {
29
+ const matchedKeys = new Set();
30
+ const matches = content.matchAll(METADATA_JSON_KEY_RE);
31
+ for (const match of matches) {
32
+ const key = (match[1] ?? "").toLowerCase();
33
+ if (key) {
34
+ matchedKeys.add(key);
35
+ }
36
+ }
37
+ return matchedKeys.size >= 3;
38
+ }
39
+ const HEARTBEAT_RE = /\bHEARTBEAT(?:\.md|_OK)\b/;
40
+ export function sanitizeUserTextForCapture(text) {
41
+ // 过滤 HEARTBEAT 健康检查消息
42
+ if (HEARTBEAT_RE.test(text)) {
43
+ return "";
44
+ }
45
+ // 处理 Compactor 系统消息,提取实际用户输入
46
+ // 格式: "System: [时间] Compacted ... Context ... [时间] 实际内容"
47
+ if (COMPACTED_SYSTEM_MSG_RE.test(text)) {
48
+ const match = text.match(COMPACTED_SYSTEM_MSG_RE);
49
+ if (match) {
50
+ return match[1].replace(/\s+/g, " ").trim();
51
+ }
52
+ return "";
53
+ }
54
+ return text
55
+ .replace(RELEVANT_MEMORIES_BLOCK_RE, " ")
56
+ .replace(CONVERSATION_METADATA_BLOCK_RE, " ")
57
+ .replace(SENDER_METADATA_BLOCK_RE, " ")
58
+ .replace(FENCED_JSON_BLOCK_RE, (full, inner) => looksLikeMetadataJsonBlock(String(inner ?? "")) ? " " : full)
59
+ .replace(LEADING_TIMESTAMP_PREFIX_RE, "")
60
+ .replace(SUBAGENT_CONTEXT_RE, "")
61
+ .replace(/\u0000/g, "")
62
+ .replace(/\s+/g, " ")
63
+ .trim();
64
+ }
65
+ export function looksLikeQuestionOnlyText(text) {
66
+ if (!QUESTION_CUE_RE.test(text) || MEMORY_INTENT_RE.test(text)) {
67
+ return false;
68
+ }
69
+ // Multi-speaker transcripts often contain many "?" but should still be captured.
70
+ const speakerTags = text.match(/[A-Za-z\u4e00-\u9fa5]{2,20}:\s/g) ?? [];
71
+ if (speakerTags.length >= 2 || text.length > 280) {
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+ export function compileSessionPattern(pattern) {
77
+ const escaped = pattern
78
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
79
+ .replace(/\*\*/g, "\u0000")
80
+ .replace(/\*/g, "[^:]*")
81
+ .replace(/\u0000/g, ".*");
82
+ return new RegExp(`^${escaped}$`);
83
+ }
84
+ export function compileSessionPatterns(patterns) {
85
+ return patterns.map((pattern) => compileSessionPattern(pattern));
86
+ }
87
+ export function matchesSessionPattern(sessionRef, patterns) {
88
+ return patterns.some((pattern) => pattern.test(sessionRef));
89
+ }
90
+ export function resolveSessionPatternCandidate(params) {
91
+ const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
92
+ if (sessionKey) {
93
+ return sessionKey;
94
+ }
95
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
96
+ return sessionId || undefined;
97
+ }
98
+ export function shouldBypassSession(params, patterns) {
99
+ if (patterns.length === 0) {
100
+ return false;
101
+ }
102
+ const candidate = resolveSessionPatternCandidate(params);
103
+ if (!candidate) {
104
+ return false;
105
+ }
106
+ return matchesSessionPattern(candidate, patterns);
107
+ }
108
+ function normalizeDedupeText(text) {
109
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
110
+ }
111
+ function normalizeCaptureDedupeText(text) {
112
+ return normalizeDedupeText(text).replace(/[\p{P}\p{S}]+/gu, " ").replace(/\s+/g, " ").trim();
113
+ }
114
+ export function pickRecentUniqueTexts(texts, limit) {
115
+ if (limit <= 0 || texts.length === 0) {
116
+ return [];
117
+ }
118
+ const seen = new Set();
119
+ const picked = [];
120
+ for (let i = texts.length - 1; i >= 0; i -= 1) {
121
+ const text = texts[i];
122
+ const key = normalizeCaptureDedupeText(text);
123
+ if (!key || seen.has(key)) {
124
+ continue;
125
+ }
126
+ seen.add(key);
127
+ picked.push(text);
128
+ if (picked.length >= limit) {
129
+ break;
130
+ }
131
+ }
132
+ return picked.reverse();
133
+ }
134
+ export function getCaptureDecision(text, mode, captureMaxLength) {
135
+ const trimmed = text.trim();
136
+ const normalizedText = sanitizeUserTextForCapture(trimmed);
137
+ const hadSanitization = normalizedText !== trimmed;
138
+ if (!normalizedText) {
139
+ return {
140
+ shouldCapture: false,
141
+ reason: /<relevant-memories>/i.test(trimmed) ? "injected_memory_context_only" : "empty_text",
142
+ normalizedText: "",
143
+ };
144
+ }
145
+ const compactText = normalizedText.replace(/\s+/g, "");
146
+ const minLength = resolveCaptureMinLength(compactText);
147
+ if (compactText.length < minLength || normalizedText.length > captureMaxLength) {
148
+ return {
149
+ shouldCapture: false,
150
+ reason: "length_out_of_range",
151
+ normalizedText,
152
+ };
153
+ }
154
+ if (COMMAND_TEXT_RE.test(normalizedText)) {
155
+ return {
156
+ shouldCapture: false,
157
+ reason: "command_text",
158
+ normalizedText,
159
+ };
160
+ }
161
+ if (NON_CONTENT_TEXT_RE.test(normalizedText)) {
162
+ return {
163
+ shouldCapture: false,
164
+ reason: "non_content_text",
165
+ normalizedText,
166
+ };
167
+ }
168
+ if (SUBAGENT_CONTEXT_RE.test(normalizedText)) {
169
+ return {
170
+ shouldCapture: false,
171
+ reason: "subagent_context",
172
+ normalizedText,
173
+ };
174
+ }
175
+ if (looksLikeQuestionOnlyText(normalizedText)) {
176
+ return {
177
+ shouldCapture: false,
178
+ reason: "question_text",
179
+ normalizedText,
180
+ };
181
+ }
182
+ if (mode === "keyword") {
183
+ for (const trigger of MEMORY_TRIGGERS) {
184
+ if (trigger.test(normalizedText)) {
185
+ return {
186
+ shouldCapture: true,
187
+ reason: hadSanitization
188
+ ? `matched_trigger_after_sanitize:${trigger.toString()}`
189
+ : `matched_trigger:${trigger.toString()}`,
190
+ normalizedText,
191
+ };
192
+ }
193
+ }
194
+ return {
195
+ shouldCapture: false,
196
+ reason: hadSanitization ? "no_trigger_matched_after_sanitize" : "no_trigger_matched",
197
+ normalizedText,
198
+ };
199
+ }
200
+ return {
201
+ shouldCapture: true,
202
+ reason: hadSanitization ? "semantic_candidate_after_sanitize" : "semantic_candidate",
203
+ normalizedText,
204
+ };
205
+ }
206
+ export function extractTextsFromUserMessages(messages) {
207
+ const texts = [];
208
+ for (const msg of messages) {
209
+ if (!msg || typeof msg !== "object") {
210
+ continue;
211
+ }
212
+ const msgObj = msg;
213
+ if (msgObj.role !== "user") {
214
+ continue;
215
+ }
216
+ const content = msgObj.content;
217
+ if (typeof content === "string") {
218
+ texts.push(content);
219
+ continue;
220
+ }
221
+ if (Array.isArray(content)) {
222
+ for (const block of content) {
223
+ if (!block || typeof block !== "object") {
224
+ continue;
225
+ }
226
+ const blockObj = block;
227
+ if (blockObj.type === "text" && typeof blockObj.text === "string") {
228
+ texts.push(blockObj.text);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ return texts;
234
+ }
235
+ function formatToolUseBlock(b) {
236
+ const name = typeof b.name === "string" ? b.name : "unknown";
237
+ let inputStr = "";
238
+ if (b.input !== undefined && b.input !== null) {
239
+ try {
240
+ inputStr = typeof b.input === "string" ? b.input : JSON.stringify(b.input);
241
+ }
242
+ catch {
243
+ inputStr = String(b.input);
244
+ }
245
+ }
246
+ return inputStr
247
+ ? `[toolUse: ${name}]\n${inputStr}`
248
+ : `[toolUse: ${name}]`;
249
+ }
250
+ function formatToolResultContent(content) {
251
+ if (typeof content === "string")
252
+ return content.trim();
253
+ if (Array.isArray(content)) {
254
+ const parts = [];
255
+ for (const block of content) {
256
+ const b = block;
257
+ if (b?.type === "text" && typeof b.text === "string") {
258
+ parts.push(b.text.trim());
259
+ }
260
+ }
261
+ return parts.join("\n");
262
+ }
263
+ if (content !== undefined && content !== null) {
264
+ try {
265
+ return JSON.stringify(content);
266
+ }
267
+ catch {
268
+ return String(content);
269
+ }
270
+ }
271
+ return "";
272
+ }
273
+ /**
274
+ * Extract text from a single message without a `[role]:` prefix.
275
+ * Used by afterTurn to send messages with their actual role.
276
+ */
277
+ export function extractSingleMessageText(msg) {
278
+ if (!msg || typeof msg !== "object")
279
+ return "";
280
+ const m = msg;
281
+ const role = m.role;
282
+ if (!role || role === "system")
283
+ return "";
284
+ if (role === "toolResult") {
285
+ const toolName = typeof m.toolName === "string" ? m.toolName : "tool";
286
+ const resultText = formatToolResultContent(m.content);
287
+ return resultText ? `[${toolName} result]: ${resultText}` : "";
288
+ }
289
+ const content = m.content;
290
+ if (typeof content === "string")
291
+ return content.trim();
292
+ if (Array.isArray(content)) {
293
+ const parts = [];
294
+ for (const block of content) {
295
+ const b = block;
296
+ if (b?.type === "text" && typeof b.text === "string") {
297
+ parts.push(b.text.trim());
298
+ }
299
+ else if (b?.type === "toolUse") {
300
+ parts.push(formatToolUseBlock(b));
301
+ }
302
+ }
303
+ return parts.join("\n");
304
+ }
305
+ return "";
306
+ }
307
+ /**
308
+ * 提取消息中的一个 part 的文本内容,并清理时间戳等噪音
309
+ */
310
+ function extractPartText(content) {
311
+ if (typeof content === "string") {
312
+ return content.trim();
313
+ }
314
+ if (Array.isArray(content)) {
315
+ const parts = [];
316
+ for (const block of content) {
317
+ const b = block;
318
+ if (b?.type === "text" && typeof b.text === "string") {
319
+ parts.push(b.text.trim());
320
+ }
321
+ }
322
+ return parts.join(" ");
323
+ }
324
+ return "";
325
+ }
326
+ /**
327
+ * 提取从 startIndex 开始的新消息,返回结构化消息。
328
+ * - 用户输入 → type: "text"
329
+ * - 工具结果 → type: "tool"
330
+ * - 跳过 system 消息
331
+ * - 清理时间戳前缀(如 [Fri 2026-04-10 17:20 GMT+8])
332
+ */
333
+ export function extractNewTurnMessages(messages, startIndex) {
334
+ const result = [];
335
+ let count = 0;
336
+ // First pass: collect toolUse inputs indexed by toolCallId/toolUseId
337
+ // Scan all messages (including after startIndex) to find toolUse before each toolResult
338
+ const toolUseInputs = {};
339
+ for (let i = 0; i < messages.length; i++) {
340
+ const msg = messages[i];
341
+ if (!msg || typeof msg !== "object")
342
+ continue;
343
+ const role = msg.role;
344
+ if (role === "assistant") {
345
+ const content = msg.content;
346
+ if (Array.isArray(content)) {
347
+ for (const block of content) {
348
+ const b = block;
349
+ // Handle toolCall, toolUse, tool_call types
350
+ if (b?.type === "toolCall" || b?.type === "toolUse" || b?.type === "tool_call") {
351
+ const id = b.id || b.toolUseId || b.toolCallId;
352
+ // Try multiple field names for tool input: arguments, input, toolInput
353
+ const input = b.arguments ?? b.input ?? b.toolInput;
354
+ if (id && input && typeof input === "object") {
355
+ toolUseInputs[id] = input;
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+ for (let i = startIndex; i < messages.length; i++) {
363
+ const msg = messages[i];
364
+ if (!msg || typeof msg !== "object")
365
+ continue;
366
+ const role = msg.role;
367
+ if (!role || role === "system")
368
+ continue;
369
+ count++;
370
+ // toolResult -> type: "tool"
371
+ if (role === "toolResult") {
372
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : "tool";
373
+ const output = formatToolResultContent(msg.content) || "";
374
+ // Try multiple field names for tool call ID
375
+ const toolCallId = msg.toolCallId || msg.toolUseId || msg.tool_call_id;
376
+ const toolInput = toolCallId && toolUseInputs[toolCallId]
377
+ ? toolUseInputs[toolCallId]
378
+ : (typeof msg.toolInput === "object" && msg.toolInput !== null
379
+ ? msg.toolInput
380
+ : undefined);
381
+ if (output) {
382
+ result.push({
383
+ role: "user",
384
+ parts: [{
385
+ type: "tool",
386
+ toolCallId: toolCallId || undefined,
387
+ toolName,
388
+ toolInput,
389
+ toolOutput: output,
390
+ toolStatus: "completed",
391
+ }],
392
+ });
393
+ }
394
+ continue;
395
+ }
396
+ // user/assistant -> type: "text"
397
+ // 统一 role 为 user
398
+ const content = msg.content;
399
+ const text = extractPartText(content);
400
+ if (text) {
401
+ // 使用 sanitizeUserTextForCapture 清理所有噪音(Sender 元数据、时间戳等)
402
+ const cleanedText = sanitizeUserTextForCapture(text);
403
+ if (cleanedText) {
404
+ // 保持原始 role,assistant 保持 assistant,user 保持 user
405
+ const ovRole = role === "assistant" ? "assistant" : "user";
406
+ result.push({
407
+ role: ovRole,
408
+ parts: [{
409
+ type: "text",
410
+ text: cleanedText,
411
+ }],
412
+ });
413
+ }
414
+ }
415
+ }
416
+ return { messages: result, newCount: count };
417
+ }
418
+ export function extractLatestUserText(messages) {
419
+ if (!messages || messages.length === 0) {
420
+ return "";
421
+ }
422
+ const texts = extractTextsFromUserMessages(messages);
423
+ for (let i = texts.length - 1; i >= 0; i -= 1) {
424
+ const normalized = sanitizeUserTextForCapture(texts[i] ?? "");
425
+ if (normalized) {
426
+ return normalized;
427
+ }
428
+ }
429
+ return "";
430
+ }
431
+ /**
432
+ * Backward-compatible wrapper around extractNewTurnMessages.
433
+ * Returns flat text strings in the legacy `[role]: text` format.
434
+ * @deprecated Use extractNewTurnMessages for structured output.
435
+ */
436
+ export function extractNewTurnTexts(messages, startIndex) {
437
+ const { messages: extracted, newCount } = extractNewTurnMessages(messages, startIndex);
438
+ const texts = [];
439
+ for (const msg of extracted) {
440
+ for (const part of msg.parts) {
441
+ if (part.type === "text") {
442
+ texts.push(`[${msg.role}]: ${part.text}`);
443
+ }
444
+ else if (part.type === "tool") {
445
+ if (part.toolInput && Object.keys(part.toolInput).length > 0) {
446
+ texts.push(`[toolUse: ${part.toolName}] ${JSON.stringify(part.toolInput)}`);
447
+ }
448
+ texts.push(`[${part.toolName} result]: ${part.toolOutput}`);
449
+ }
450
+ }
451
+ }
452
+ return { texts, newCount };
453
+ }