Files
OpenViking/examples/openclaw-plugin/text-utils.ts
T
agent 00f3738edb feat(usage): emit resource-scoped experience usage records (#3921)
* feat(usage): expand experience tracking and log schema

* fix(usage): preserve experience count event names

* refactor(agent-evolution): use generic OpenViking tools

* fix(usage): capture generic OpenViking tool events

* feat(skills): guide cross-agent experience retrieval

* fix(usage): address generic tool migration review
2026-08-11 22:20:18 +08:00

484 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CaptureMode } from "./client.js";
export const MEMORY_TRIGGERS = [
/remember|preference|prefer|important|decision|decided|always|never/i,
/记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i,
/[\w.-]+@[\w.-]+\.\w+/,
/\+\d{10,}/,
/(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i,
/(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i,
/(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i,
];
const CJK_CHAR_REGEX = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
const RELEVANT_MEMORIES_BLOCK_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>/gi;
const OPENVIKING_CONTEXT_BLOCK_RE = /<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi;
const CONVERSATION_METADATA_BLOCK_RE =
/(?:^|\n)\s*(?:Conversation info|Conversation metadata|会话信息|对话信息)\s*(?:\([^)]+\))?\s*:\s*```[\s\S]*?```/gi;
/** Strips "Sender (untrusted metadata): ```json ... ```" so capture sends clean text to OpenViking extract. */
const SENDER_METADATA_BLOCK_RE = /Sender\s*\([^)]*\)\s*:\s*```[\s\S]*?```/gi;
const FENCED_JSON_BLOCK_RE = /```json\s*([\s\S]*?)```/gi;
const METADATA_JSON_KEY_RE =
/"(session|sessionid|sessionkey|conversationid|channel|sender|userid|agentid|timestamp|timezone)"\s*:/gi;
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;
const COMPACTED_SYSTEM_MSG_RE = /^System:\s*\[.*?\]\s*Compacted\s*(.+)$/i;
const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i;
const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u;
const SUBAGENT_CONTEXT_RE = /^\s*\[Subagent Context\]/i;
const MEMORY_INTENT_RE = /记住|记下|remember|save|store|偏好|preference|规则|rule|事实|fact/i;
const QUESTION_CUE_RE =
/[??]|\b(?:what|when|where|who|why|how|which|can|could|would|did|does|is|are)\b|^(?:请问|能否|可否|怎么|如何|什么时候|谁|什么|哪|是否)/i;
function resolveCaptureMinLength(text: string): number {
return CJK_CHAR_REGEX.test(text) ? 4 : 10;
}
function looksLikeMetadataJsonBlock(content: string): boolean {
const matchedKeys = new Set<string>();
const matches = content.matchAll(METADATA_JSON_KEY_RE);
for (const match of matches) {
const key = (match[1] ?? "").toLowerCase();
if (key) {
matchedKeys.add(key);
}
}
return matchedKeys.size >= 3;
}
const TOOL_PLACEHOLDER_RE = /^\s*\[tool(?::\s*|Use:\s*)[^\]]+\]\s*$/i;
export function sanitizeUserTextForCapture(text: string): string {
// Drop legacy synthetic tool placeholders before they reach memory extraction.
if (TOOL_PLACEHOLDER_RE.test(text)) {
return "";
}
// 处理 Compactor 系统消息,提取实际用户输入
// 格式: "System: [时间] Compacted ... Context ... [时间] 实际内容"
if (COMPACTED_SYSTEM_MSG_RE.test(text)) {
const match = text.match(COMPACTED_SYSTEM_MSG_RE);
if (match) {
return match[1].replace(/\s+/g, " ").trim();
}
return "";
}
return text
.replace(OPENVIKING_CONTEXT_BLOCK_RE, " ")
.replace(RELEVANT_MEMORIES_BLOCK_RE, " ")
.replace(CONVERSATION_METADATA_BLOCK_RE, " ")
.replace(SENDER_METADATA_BLOCK_RE, " ")
.replace(FENCED_JSON_BLOCK_RE, (full, inner) =>
looksLikeMetadataJsonBlock(String(inner ?? "")) ? " " : full,
)
.replace(LEADING_TIMESTAMP_PREFIX_RE, "")
.replace(SUBAGENT_CONTEXT_RE, "")
.replace(/\u0000/g, "")
.replace(/\s+/g, " ")
.trim();
}
export function stripOpenVikingContextInjection(text: string): string {
return text
.replace(OPENVIKING_CONTEXT_BLOCK_RE, " ")
.replace(RELEVANT_MEMORIES_BLOCK_RE, " ")
.replace(/\s+/g, " ")
.trim();
}
export function looksLikeQuestionOnlyText(text: string): boolean {
if (!QUESTION_CUE_RE.test(text) || MEMORY_INTENT_RE.test(text)) {
return false;
}
// Multi-speaker transcripts often contain many "?" but should still be captured.
const speakerTags = text.match(/[A-Za-z\u4e00-\u9fa5]{2,20}:\s/g) ?? [];
if (speakerTags.length >= 2 || text.length > 280) {
return false;
}
return true;
}
export function compileSessionPattern(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "\u0000")
.replace(/\*/g, "[^:]*")
.replace(/\u0000/g, ".*");
return new RegExp(`^${escaped}$`);
}
export function compileSessionPatterns(patterns: string[]): RegExp[] {
return patterns.map((pattern) => compileSessionPattern(pattern));
}
export function matchesSessionPattern(sessionRef: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(sessionRef));
}
export function resolveSessionPatternCandidate(params: {
sessionId?: string;
sessionKey?: string;
}): string | undefined {
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
if (sessionKey) {
return sessionKey;
}
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
return sessionId || undefined;
}
export function shouldBypassSession(
params: {
sessionId?: string;
sessionKey?: string;
},
patterns: RegExp[],
): boolean {
if (patterns.length === 0) {
return false;
}
const candidate = resolveSessionPatternCandidate(params);
if (!candidate) {
return false;
}
return matchesSessionPattern(candidate, patterns);
}
function normalizeDedupeText(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
}
function normalizeCaptureDedupeText(text: string): string {
return normalizeDedupeText(text).replace(/[\p{P}\p{S}]+/gu, " ").replace(/\s+/g, " ").trim();
}
export function pickRecentUniqueTexts(texts: string[], limit: number): string[] {
if (limit <= 0 || texts.length === 0) {
return [];
}
const seen = new Set<string>();
const picked: string[] = [];
for (let i = texts.length - 1; i >= 0; i -= 1) {
const text = texts[i];
const key = normalizeCaptureDedupeText(text);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
picked.push(text);
if (picked.length >= limit) {
break;
}
}
return picked.reverse();
}
export function getCaptureDecision(text: string, mode: CaptureMode, captureMaxLength: number): {
shouldCapture: boolean;
reason: string;
normalizedText: string;
} {
const trimmed = text.trim();
const normalizedText = sanitizeUserTextForCapture(trimmed);
const hadSanitization = normalizedText !== trimmed;
if (!normalizedText) {
return {
shouldCapture: false,
reason: /<relevant-memories>/i.test(trimmed) ? "injected_memory_context_only" : "empty_text",
normalizedText: "",
};
}
const compactText = normalizedText.replace(/\s+/g, "");
const minLength = resolveCaptureMinLength(compactText);
if (compactText.length < minLength || normalizedText.length > captureMaxLength) {
return {
shouldCapture: false,
reason: "length_out_of_range",
normalizedText,
};
}
if (COMMAND_TEXT_RE.test(normalizedText)) {
return {
shouldCapture: false,
reason: "command_text",
normalizedText,
};
}
if (NON_CONTENT_TEXT_RE.test(normalizedText)) {
return {
shouldCapture: false,
reason: "non_content_text",
normalizedText,
};
}
if (SUBAGENT_CONTEXT_RE.test(normalizedText)) {
return {
shouldCapture: false,
reason: "subagent_context",
normalizedText,
};
}
if (looksLikeQuestionOnlyText(normalizedText)) {
return {
shouldCapture: false,
reason: "question_text",
normalizedText,
};
}
if (mode === "keyword") {
for (const trigger of MEMORY_TRIGGERS) {
if (trigger.test(normalizedText)) {
return {
shouldCapture: true,
reason: hadSanitization
? `matched_trigger_after_sanitize:${trigger.toString()}`
: `matched_trigger:${trigger.toString()}`,
normalizedText,
};
}
}
return {
shouldCapture: false,
reason: hadSanitization ? "no_trigger_matched_after_sanitize" : "no_trigger_matched",
normalizedText,
};
}
return {
shouldCapture: true,
reason: hadSanitization ? "semantic_candidate_after_sanitize" : "semantic_candidate",
normalizedText,
};
}
export function extractTextsFromUserMessages(messages: unknown[]): string[] {
const texts: string[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
continue;
}
const msgObj = msg as Record<string, unknown>;
if (msgObj.role !== "user") {
continue;
}
const content = msgObj.content;
if (typeof content === "string") {
texts.push(content);
continue;
}
if (Array.isArray(content)) {
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const blockObj = block as Record<string, unknown>;
if (blockObj.type === "text" && typeof blockObj.text === "string") {
texts.push(blockObj.text);
}
}
}
}
return texts;
}
function formatToolResultContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
const parts: string[] = [];
for (const block of content) {
const b = block as Record<string, unknown>;
if (b?.type === "text" && typeof b.text === "string") {
parts.push((b.text as string).trim());
}
}
return parts.join("\n");
}
if (content !== undefined && content !== null) {
try {
return JSON.stringify(content);
} catch {
return String(content);
}
}
return "";
}
/**
* 提取消息中的一个 part 的文本内容,并清理时间戳等噪音
*/
function extractPartText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const block of content) {
const b = block as Record<string, unknown>;
if (b?.type === "text" && typeof b.text === "string") {
parts.push((b.text as string).trim());
}
}
return parts.join(" ");
}
return "";
}
/**
* 结构化消息类型 - 用于 afterTurn 发送到 OpenViking
*/
type ExtractedMessage = {
role: "user" | "assistant";
parts: Array<{
type: "text";
text: string;
} | {
type: "tool";
toolCallId?: string;
toolName: string;
toolInput?: Record<string, unknown>;
toolOutput: string;
toolStatus: string;
}>;
};
/**
* 提取从 startIndex 开始的新消息,返回结构化消息。
* - 用户输入 → type: "text"
* - 工具结果 → type: "tool"
* - 跳过 system 消息
* - 清理时间戳前缀(如 [Fri 2026-04-10 17:20 GMT+8])
*/
export function extractNewTurnMessages(
messages: unknown[],
startIndex: number,
): { messages: ExtractedMessage[]; newCount: number } {
const result: ExtractedMessage[] = [];
let count = 0;
// First pass: collect toolUse inputs indexed by toolCallId/toolUseId
// Scan all messages (including after startIndex) to find toolUse before each toolResult
const toolUseInputs: Record<string, Record<string, unknown>> = {};
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as Record<string, unknown>;
if (!msg || typeof msg !== "object") continue;
const role = msg.role as string;
if (role === "assistant") {
const content = msg.content;
if (Array.isArray(content)) {
for (const block of content) {
const b = block as Record<string, unknown>;
// Handle toolCall, toolUse, tool_call types
if (b?.type === "toolCall" || b?.type === "toolUse" || b?.type === "tool_call") {
const id = (b.id as string) || (b.toolUseId as string) || (b.toolCallId as string);
// Try multiple field names for tool input: arguments, input, toolInput
const input = b.arguments ?? b.input ?? b.toolInput;
if (id && input && typeof input === "object") {
toolUseInputs[id] = input as Record<string, unknown>;
}
}
}
}
}
}
for (let i = startIndex; i < messages.length; i++) {
const msg = messages[i] as Record<string, unknown>;
if (!msg || typeof msg !== "object") continue;
const role = msg.role as string;
if (!role || role === "system") continue;
count++;
// toolResult -> type: "tool"
if (role === "toolResult") {
const toolName = typeof msg.toolName === "string" ? msg.toolName : "tool";
const output = formatToolResultContent(msg.content) || "";
// Try multiple field names for tool call ID
const toolCallId = (msg.toolCallId as string) || (msg.toolUseId as string) || (msg.tool_call_id as string);
const toolInput = toolCallId && toolUseInputs[toolCallId]
? toolUseInputs[toolCallId]
: (typeof msg.toolInput === "object" && msg.toolInput !== null
? msg.toolInput as Record<string, unknown>
: undefined);
if (output) {
result.push({
role: "assistant",
parts: [{
type: "tool",
toolCallId: toolCallId || undefined,
toolName,
toolInput,
toolOutput: output,
toolStatus: msg.isError === true ? "error" : "completed",
}],
});
}
continue;
}
// user/assistant -> type: "text"
const content = msg.content;
const text = extractPartText(content);
if (text) {
// 使用 sanitizeUserTextForCapture 清理所有噪音(Sender 元数据、时间戳等)
const cleanedText = sanitizeUserTextForCapture(text);
if (cleanedText) {
// 保持原始 role,assistant 保持 assistant,user 保持 user
const ovRole: "user" | "assistant" = role === "assistant" ? "assistant" : "user";
result.push({
role: ovRole,
parts: [{
type: "text",
text: cleanedText,
}],
});
}
}
}
return { messages: result, newCount: count };
}
export function extractLatestUserText(messages: unknown[] | undefined): string {
if (!messages || messages.length === 0) {
return "";
}
const texts = extractTextsFromUserMessages(messages);
for (let i = texts.length - 1; i >= 0; i -= 1) {
const normalized = sanitizeUserTextForCapture(texts[i] ?? "");
if (normalized) {
return normalized;
}
}
return "";
}
/**
* Backward-compatible wrapper around extractNewTurnMessages.
* Returns flat text strings in the legacy `[role]: text` format.
* @deprecated Use extractNewTurnMessages for structured output.
*/
export function extractNewTurnTexts(
messages: unknown[],
startIndex: number,
): { texts: string[]; newCount: number } {
const { messages: extracted, newCount } = extractNewTurnMessages(messages, startIndex);
const texts: string[] = [];
for (const msg of extracted) {
for (const part of msg.parts) {
if (part.type === "text") {
texts.push(`[${msg.role}]: ${part.text}`);
} else if (part.type === "tool") {
if (part.toolInput && Object.keys(part.toolInput).length > 0) {
texts.push(`[toolUse: ${part.toolName}] ${JSON.stringify(part.toolInput)}`);
}
texts.push(`[${part.toolName} result]: ${part.toolOutput}`);
}
}
}
return { texts, newCount };
}