@openviking/openclaw-plugin 2026.5.21 → 2026.5.26

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