/** * ContextBuilder - Integrates OpenViking memories into chat context * * Responsible for: * 1. Building enhanced system prompts with relevant memories (L0/L1/L2) * 2. Extracting and saving memories after conversations end * 3. Managing context compaction with memory flush * 4. Reading and injecting agent identity files * * This module bridges the VikingAdapter with chatStore/gateway-client. */ import { VikingAdapter, getVikingAdapter, type EnhancedContext } from './viking-adapter'; // === Types === export interface AgentIdentity { soul: string; instructions: string; userProfile: string; heartbeat?: string; } export interface ContextBuildResult { systemPrompt: string; memorySummary: string; tokensUsed: number; memoriesInjected: number; } export interface CompactionResult { compactedMessages: ChatMessage[]; summary: string; memoriesFlushed: number; } export interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } export interface ContextBuilderConfig { enabled: boolean; maxMemoryTokens: number; compactionThresholdTokens: number; compactionReserveTokens: number; memoryFlushOnCompact: boolean; autoExtractOnComplete: boolean; minExtractionMessages: number; } const DEFAULT_CONFIG: ContextBuilderConfig = { enabled: true, maxMemoryTokens: 6000, compactionThresholdTokens: 15000, compactionReserveTokens: 4000, memoryFlushOnCompact: true, autoExtractOnComplete: true, minExtractionMessages: 4, }; // === Token Estimation === function estimateTokens(text: string): number { const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; const otherChars = text.length - cjkChars; return Math.ceil(cjkChars * 1.5 + otherChars * 0.4); } function estimateMessagesTokens(messages: ChatMessage[]): number { return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0); } // === ContextBuilder Implementation === export class ContextBuilder { private viking: VikingAdapter; private config: ContextBuilderConfig; private identityCache: Map = new Map(); private static IDENTITY_CACHE_TTL = 5 * 60 * 1000; // 5 min constructor(config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; this.viking = getVikingAdapter(); } // === Core: Build Context for a Chat Message === async buildContext( userMessage: string, agentId: string, _existingMessages: ChatMessage[] = [] ): Promise { if (!this.config.enabled) { return { systemPrompt: '', memorySummary: '', tokensUsed: 0, memoriesInjected: 0, }; } // Check if OpenViking is available const connected = await this.viking.isConnected(); if (!connected) { console.warn('[ContextBuilder] OpenViking not available, skipping memory injection'); return { systemPrompt: '', memorySummary: '', tokensUsed: 0, memoriesInjected: 0, }; } // Step 1: Load agent identity const identity = await this.loadIdentity(agentId); // Step 2: Build enhanced context with memories const enhanced = await this.viking.buildEnhancedContext( userMessage, agentId, { maxTokens: this.config.maxMemoryTokens, includeTrace: true } ); // Step 3: Compose system prompt const systemPrompt = this.composeSystemPrompt(identity, enhanced); // Step 4: Build summary for UI display const memorySummary = this.buildMemorySummary(enhanced); return { systemPrompt, memorySummary, tokensUsed: enhanced.totalTokens + estimateTokens(systemPrompt), memoriesInjected: enhanced.memories.length, }; } // === Identity Loading === async loadIdentity(agentId: string): Promise { // Check cache const cached = this.identityCache.get(agentId); if (cached && Date.now() - cached.cachedAt < ContextBuilder.IDENTITY_CACHE_TTL) { return cached.identity; } // Try loading from OpenViking first, fall back to defaults let soul = ''; let instructions = ''; let userProfile = ''; let heartbeat = ''; try { [soul, instructions, userProfile, heartbeat] = await Promise.all([ this.viking.getIdentityFromViking(agentId, 'soul').catch(() => ''), this.viking.getIdentityFromViking(agentId, 'instructions').catch(() => ''), this.viking.getIdentityFromViking(agentId, 'user_profile').catch(() => ''), this.viking.getIdentityFromViking(agentId, 'heartbeat').catch(() => ''), ]); } catch { // OpenViking not available, use empty defaults } const identity: AgentIdentity = { soul: soul || DEFAULT_SOUL, instructions: instructions || DEFAULT_INSTRUCTIONS, userProfile: userProfile || '', heartbeat: heartbeat || '', }; this.identityCache.set(agentId, { identity, cachedAt: Date.now() }); return identity; } // === Context Compaction === async checkAndCompact( messages: ChatMessage[], agentId: string ): Promise { const totalTokens = estimateMessagesTokens(messages); if (totalTokens < this.config.compactionThresholdTokens) { return null; // No compaction needed } let memoriesFlushed = 0; // Step 1: Memory flush before compaction if (this.config.memoryFlushOnCompact) { const keepCount = 5; const messagesToFlush = messages.slice(0, -keepCount); if (messagesToFlush.length >= this.config.minExtractionMessages) { try { const result = await this.viking.extractAndSaveMemories( messagesToFlush.map(m => ({ role: m.role, content: m.content })), agentId, 'compaction' ); memoriesFlushed = result.saved; console.log(`[ContextBuilder] Memory flush: saved ${memoriesFlushed} memories before compaction`); } catch (err) { console.warn('[ContextBuilder] Memory flush failed:', err); } } } // Step 2: Create summary of older messages const keepCount = 5; const oldMessages = messages.slice(0, -keepCount); const recentMessages = messages.slice(-keepCount); const summary = this.createCompactionSummary(oldMessages); const compactedMessages: ChatMessage[] = [ { role: 'system', content: `[之前的对话摘要]\n${summary}` }, ...recentMessages, ]; return { compactedMessages, summary, memoriesFlushed, }; } // === Post-Conversation Memory Extraction === async extractMemoriesFromConversation( messages: ChatMessage[], agentId: string, conversationId?: string ): Promise<{ saved: number; userMemories: number; agentMemories: number }> { if (!this.config.autoExtractOnComplete) { return { saved: 0, userMemories: 0, agentMemories: 0 }; } if (messages.length < this.config.minExtractionMessages) { return { saved: 0, userMemories: 0, agentMemories: 0 }; } const connected = await this.viking.isConnected(); if (!connected) { return { saved: 0, userMemories: 0, agentMemories: 0 }; } try { const result = await this.viking.extractAndSaveMemories( messages.map(m => ({ role: m.role, content: m.content })), agentId, conversationId ); console.log( `[ContextBuilder] Extracted ${result.saved} memories (user: ${result.userMemories}, agent: ${result.agentMemories})` ); return result; } catch (err) { console.warn('[ContextBuilder] Memory extraction failed:', err); return { saved: 0, userMemories: 0, agentMemories: 0 }; } } // === Identity Sync === async syncIdentityFiles( agentId: string, files: { soul?: string; instructions?: string; userProfile?: string; heartbeat?: string } ): Promise { const connected = await this.viking.isConnected(); if (!connected) return; const syncTasks: Promise[] = []; if (files.soul) { syncTasks.push(this.viking.syncIdentityToViking(agentId, 'SOUL.md', files.soul)); } if (files.instructions) { syncTasks.push(this.viking.syncIdentityToViking(agentId, 'AGENTS.md', files.instructions)); } if (files.userProfile) { syncTasks.push(this.viking.syncIdentityToViking(agentId, 'USER.md', files.userProfile)); } if (files.heartbeat) { syncTasks.push(this.viking.syncIdentityToViking(agentId, 'HEARTBEAT.md', files.heartbeat)); } await Promise.allSettled(syncTasks); // Invalidate cache this.identityCache.delete(agentId); } // === Configuration === updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; } getConfig(): Readonly { return { ...this.config }; } isEnabled(): boolean { return this.config.enabled; } // === Private Helpers === private composeSystemPrompt(identity: AgentIdentity, enhanced: EnhancedContext): string { const sections: string[] = []; if (identity.soul) { sections.push(identity.soul); } if (identity.instructions) { sections.push(identity.instructions); } if (identity.userProfile) { sections.push(`## 用户画像\n${identity.userProfile}`); } if (enhanced.systemPromptAddition) { sections.push(enhanced.systemPromptAddition); } return sections.join('\n\n'); } private buildMemorySummary(enhanced: EnhancedContext): string { if (enhanced.memories.length === 0) { return '无相关记忆'; } const parts: string[] = [ `已注入 ${enhanced.memories.length} 条相关记忆`, `Token 消耗: L0=${enhanced.tokensByLevel.L0} L1=${enhanced.tokensByLevel.L1} L2=${enhanced.tokensByLevel.L2}`, ]; return parts.join(' | '); } private createCompactionSummary(messages: ChatMessage[]): string { // Create a concise summary of compacted messages const userMessages = messages.filter(m => m.role === 'user'); const assistantMessages = messages.filter(m => m.role === 'assistant'); const topics = userMessages .map(m => { const text = m.content.trim(); return text.length > 50 ? text.slice(0, 50) + '...' : text; }) .slice(0, 5); const summary = [ `对话包含 ${messages.length} 条消息(${userMessages.length} 条用户消息,${assistantMessages.length} 条助手回复)`, topics.length > 0 ? `讨论主题:${topics.join(';')}` : '', ].filter(Boolean).join('\n'); return summary; } } // === Default Identity Content === const DEFAULT_SOUL = `# ZCLAW 人格 你是 ZCLAW(小龙虾),一个基于 OpenClaw 定制的中文 AI 助手。 ## 核心特质 - **高效执行**: 你不只是出主意,你会真正动手完成任务 - **中文优先**: 默认使用中文交流,必要时切换英文 - **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明 - **主动服务**: 定期检查任务清单,主动推进未完成的工作 ## 语气 简洁、专业、友好。避免过度客套,直接给出有用信息。`; const DEFAULT_INSTRUCTIONS = `# Agent 指令 ## 操作规范 1. 执行文件操作前,先确认目标路径 2. 执行 Shell 命令前,评估安全风险 3. 长时间任务需定期汇报进度 ## 记忆管理 - 重要的用户偏好自动记录 - 项目上下文保存到工作区 - 对话结束时总结关键信息`; // === Singleton === let _instance: ContextBuilder | null = null; export function getContextBuilder(config?: Partial): ContextBuilder { if (!_instance || config) { _instance = new ContextBuilder(config); } return _instance; } export function resetContextBuilder(): void { _instance = null; }