/** * Session Persistence - Automatic session data persistence for L4 self-evolution * * Provides automatic persistence of conversation sessions: * - Periodic auto-save of session state * - Memory extraction at session end * - Context compaction for long sessions * - Session history and recovery * * Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.4 */ import { getVikingClient, type VikingHttpClient } from './viking-client'; import { getMemoryExtractor } from './memory-extractor'; import { canAutoExecute } from './autonomy-manager'; // === Types === export interface SessionMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; timestamp: string; metadata?: Record; } export interface SessionState { id: string; agentId: string; startedAt: string; lastActivityAt: string; messageCount: number; status: 'active' | 'paused' | 'ended'; messages: SessionMessage[]; metadata: { model?: string; workspaceId?: string; conversationId?: string; [key: string]: unknown; }; } export interface SessionPersistenceConfig { enabled: boolean; autoSaveIntervalMs: number; // Auto-save interval (default: 60s) maxMessagesBeforeCompact: number; // Trigger compaction at this count extractMemoriesOnEnd: boolean; // Extract memories when session ends persistToViking: boolean; // Use OpenViking for persistence fallbackToLocal: boolean; // Fall back to localStorage maxSessionHistory: number; // Max sessions to keep in history sessionTimeoutMs: number; // Session timeout (default: 30min) } export interface SessionSummary { id: string; agentId: string; startedAt: string; endedAt: string; messageCount: number; topicsDiscussed: string[]; memoriesExtracted: number; compacted: boolean; } export interface PersistenceResult { saved: boolean; sessionId: string; messageCount: number; extractedMemories: number; compacted: boolean; error?: string; } // === Default Config === export const DEFAULT_SESSION_CONFIG: SessionPersistenceConfig = { enabled: true, autoSaveIntervalMs: 60000, // 1 minute maxMessagesBeforeCompact: 100, // Compact after 100 messages extractMemoriesOnEnd: true, persistToViking: true, fallbackToLocal: true, maxSessionHistory: 50, sessionTimeoutMs: 1800000, // 30 minutes }; // === Storage Keys === const SESSION_STORAGE_KEY = 'zclaw-sessions'; const CURRENT_SESSION_KEY = 'zclaw-current-session'; // === Session Persistence Service === export class SessionPersistenceService { private config: SessionPersistenceConfig; private currentSession: SessionState | null = null; private vikingClient: VikingHttpClient | null = null; private autoSaveTimer: ReturnType | null = null; private sessionHistory: SessionSummary[] = []; constructor(config?: Partial) { this.config = { ...DEFAULT_SESSION_CONFIG, ...config }; this.loadSessionHistory(); this.initializeVikingClient(); } private async initializeVikingClient(): Promise { try { this.vikingClient = getVikingClient(); } catch (error) { console.warn('[SessionPersistence] Viking client initialization failed:', error); } } // === Session Lifecycle === /** * Start a new session. */ startSession(agentId: string, metadata?: Record): SessionState { // End any existing session first if (this.currentSession && this.currentSession.status === 'active') { this.endSession(); } const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; this.currentSession = { id: sessionId, agentId, startedAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), messageCount: 0, status: 'active', messages: [], metadata: metadata || {}, }; this.saveCurrentSession(); this.startAutoSave(); console.log(`[SessionPersistence] Started session: ${sessionId}`); return this.currentSession; } /** * Add a message to the current session. */ addMessage(message: Omit): SessionMessage | null { if (!this.currentSession || this.currentSession.status !== 'active') { console.warn('[SessionPersistence] No active session'); return null; } const fullMessage: SessionMessage = { id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, ...message, timestamp: new Date().toISOString(), }; this.currentSession.messages.push(fullMessage); this.currentSession.messageCount++; this.currentSession.lastActivityAt = fullMessage.timestamp; // Check if compaction is needed if (this.currentSession.messageCount >= this.config.maxMessagesBeforeCompact) { this.compactSession(); } return fullMessage; } /** * Pause the current session. */ pauseSession(): void { if (!this.currentSession) return; this.currentSession.status = 'paused'; this.stopAutoSave(); this.saveCurrentSession(); console.log(`[SessionPersistence] Paused session: ${this.currentSession.id}`); } /** * Resume a paused session. */ resumeSession(): SessionState | null { if (!this.currentSession || this.currentSession.status !== 'paused') { return this.currentSession; } this.currentSession.status = 'active'; this.currentSession.lastActivityAt = new Date().toISOString(); this.startAutoSave(); this.saveCurrentSession(); console.log(`[SessionPersistence] Resumed session: ${this.currentSession.id}`); return this.currentSession; } /** * End the current session and extract memories. */ async endSession(): Promise { if (!this.currentSession) { return { saved: false, sessionId: '', messageCount: 0, extractedMemories: 0, compacted: false, error: 'No active session', }; } const session = this.currentSession; session.status = 'ended'; this.stopAutoSave(); let extractedMemories = 0; let compacted = false; try { // Extract memories from the session if (this.config.extractMemoriesOnEnd && session.messageCount >= 4) { extractedMemories = await this.extractMemories(session); } // Persist to OpenViking if available if (this.config.persistToViking && this.vikingClient) { await this.persistToViking(session); } // Save to local storage this.saveToLocalStorage(session); // Add to history this.addToHistory(session, extractedMemories, compacted); console.log(`[SessionPersistence] Ended session: ${session.id}, extracted ${extractedMemories} memories`); return { saved: true, sessionId: session.id, messageCount: session.messageCount, extractedMemories, compacted, }; } catch (error) { console.error('[SessionPersistence] Error ending session:', error); return { saved: false, sessionId: session.id, messageCount: session.messageCount, extractedMemories: 0, compacted: false, error: String(error), }; } finally { this.clearCurrentSession(); } } // === Memory Extraction === private async extractMemories(session: SessionState): Promise { const extractor = getMemoryExtractor(); // Check if we can auto-extract const { canProceed } = canAutoExecute('memory_save', 5); if (!canProceed) { console.log('[SessionPersistence] Memory extraction requires approval'); return 0; } try { const messages = session.messages.map(m => ({ role: m.role, content: m.content, })); const result = await extractor.extractFromConversation( messages, session.agentId, session.id ); return result.saved; } catch (error) { console.error('[SessionPersistence] Memory extraction failed:', error); return 0; } } // === Session Compaction === private async compactSession(): Promise { if (!this.currentSession || !this.vikingClient) return; try { const messages = this.currentSession.messages.map(m => ({ role: m.role, content: m.content, })); // Use OpenViking to compact the session const summary = await this.vikingClient.compactSession(messages); // Keep recent messages, replace older ones with summary const recentMessages = this.currentSession.messages.slice(-20); // Create a summary message const summaryMessage: SessionMessage = { id: `summary_${Date.now()}`, role: 'system', content: `[会话摘要]\n${summary}`, timestamp: new Date().toISOString(), metadata: { type: 'compaction-summary' }, }; this.currentSession.messages = [summaryMessage, ...recentMessages]; this.currentSession.messageCount = this.currentSession.messages.length; console.log(`[SessionPersistence] Compacted session: ${this.currentSession.id}`); } catch (error) { console.error('[SessionPersistence] Compaction failed:', error); } } // === Persistence === private async persistToViking(session: SessionState): Promise { if (!this.vikingClient) return; try { const sessionContent = session.messages .map(m => `[${m.role}]: ${m.content}`) .join('\n\n'); await this.vikingClient.addResource( `viking://sessions/${session.agentId}/${session.id}`, sessionContent, { metadata: { startedAt: session.startedAt, endedAt: new Date().toISOString(), messageCount: String(session.messageCount || 0), agentId: session.agentId || 'default', }, wait: false, } ); } catch (error) { console.error('[SessionPersistence] Viking persistence failed:', error); if (!this.config.fallbackToLocal) { throw error; } } } private saveToLocalStorage(session: SessionState): void { try { localStorage.setItem( `${SESSION_STORAGE_KEY}/${session.id}`, JSON.stringify(session) ); } catch (error) { console.error('[SessionPersistence] Local storage failed:', error); } } private saveCurrentSession(): void { if (!this.currentSession) return; try { localStorage.setItem(CURRENT_SESSION_KEY, JSON.stringify(this.currentSession)); } catch (error) { console.error('[SessionPersistence] Failed to save current session:', error); } } private loadCurrentSession(): SessionState | null { try { const raw = localStorage.getItem(CURRENT_SESSION_KEY); if (raw) { return JSON.parse(raw); } } catch (error) { console.error('[SessionPersistence] Failed to load current session:', error); } return null; } private clearCurrentSession(): void { this.currentSession = null; try { localStorage.removeItem(CURRENT_SESSION_KEY); } catch { // Ignore } } // === Auto-save === private startAutoSave(): void { if (this.autoSaveTimer) { clearInterval(this.autoSaveTimer); } this.autoSaveTimer = setInterval(() => { if (this.currentSession && this.currentSession.status === 'active') { this.saveCurrentSession(); } }, this.config.autoSaveIntervalMs); } private stopAutoSave(): void { if (this.autoSaveTimer) { clearInterval(this.autoSaveTimer); this.autoSaveTimer = null; } } // === Session History === private loadSessionHistory(): void { try { const raw = localStorage.getItem(SESSION_STORAGE_KEY); if (raw) { this.sessionHistory = JSON.parse(raw); } } catch { this.sessionHistory = []; } } private saveSessionHistory(): void { try { localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.sessionHistory)); } catch (error) { console.error('[SessionPersistence] Failed to save session history:', error); } } private addToHistory(session: SessionState, extractedMemories: number, compacted: boolean): void { const summary: SessionSummary = { id: session.id, agentId: session.agentId, startedAt: session.startedAt, endedAt: new Date().toISOString(), messageCount: session.messageCount, topicsDiscussed: this.extractTopics(session), memoriesExtracted: extractedMemories, compacted, }; this.sessionHistory.unshift(summary); // Trim to max size if (this.sessionHistory.length > this.config.maxSessionHistory) { this.sessionHistory = this.sessionHistory.slice(0, this.config.maxSessionHistory); } this.saveSessionHistory(); } private extractTopics(session: SessionState): string[] { // Simple topic extraction from user messages const userMessages = session.messages .filter(m => m.role === 'user') .map(m => m.content); // Look for common patterns const topics: string[] = []; const patterns = [ /(?:帮我|请|能否)(.{2,10})/g, /(?:问题|bug|错误|报错)(.{2,20})/g, /(?:实现|添加|开发)(.{2,15})/g, ]; for (const msg of userMessages) { for (const pattern of patterns) { const matches = msg.matchAll(pattern); for (const match of matches) { if (match[1] && match[1].length > 2) { topics.push(match[1].trim()); } } } } return [...new Set(topics)].slice(0, 10); } // === Public API === /** * Get the current session. */ getCurrentSession(): SessionState | null { return this.currentSession; } /** * Get session history. */ getSessionHistory(limit: number = 20): SessionSummary[] { return this.sessionHistory.slice(0, limit); } /** * Restore a previous session. */ restoreSession(sessionId: string): SessionState | null { try { const raw = localStorage.getItem(`${SESSION_STORAGE_KEY}/${sessionId}`); if (raw) { const session = JSON.parse(raw) as SessionState; session.status = 'active'; session.lastActivityAt = new Date().toISOString(); this.currentSession = session; this.startAutoSave(); this.saveCurrentSession(); return session; } } catch (error) { console.error('[SessionPersistence] Failed to restore session:', error); } return null; } /** * Delete a session from history. */ deleteSession(sessionId: string): boolean { try { localStorage.removeItem(`${SESSION_STORAGE_KEY}/${sessionId}`); this.sessionHistory = this.sessionHistory.filter(s => s.id !== sessionId); this.saveSessionHistory(); return true; } catch { return false; } } /** * Get configuration. */ getConfig(): SessionPersistenceConfig { return { ...this.config }; } /** * Update configuration. */ updateConfig(updates: Partial): void { this.config = { ...this.config, ...updates }; // Restart auto-save if interval changed if (updates.autoSaveIntervalMs && this.currentSession?.status === 'active') { this.stopAutoSave(); this.startAutoSave(); } } /** * Check if session persistence is available. */ async isAvailable(): Promise { if (!this.config.enabled) return false; if (this.config.persistToViking && this.vikingClient) { return this.vikingClient.isAvailable(); } return this.config.fallbackToLocal; } /** * Recover from crash - restore last session if valid. */ recoverFromCrash(): SessionState | null { const lastSession = this.loadCurrentSession(); if (!lastSession) return null; // Check if session timed out const lastActivity = new Date(lastSession.lastActivityAt).getTime(); const now = Date.now(); if (now - lastActivity > this.config.sessionTimeoutMs) { console.log('[SessionPersistence] Last session timed out, not recovering'); this.clearCurrentSession(); return null; } // Recover the session lastSession.status = 'active'; lastSession.lastActivityAt = new Date().toISOString(); this.currentSession = lastSession; this.startAutoSave(); this.saveCurrentSession(); console.log(`[SessionPersistence] Recovered session: ${lastSession.id}`); return lastSession; } } // === Singleton === let _instance: SessionPersistenceService | null = null; export function getSessionPersistence(config?: Partial): SessionPersistenceService { if (!_instance || config) { _instance = new SessionPersistenceService(config); } return _instance; } export function resetSessionPersistence(): void { _instance = null; } // === Helper Functions === /** * Quick start a session. */ export function startSession(agentId: string, metadata?: Record): SessionState { return getSessionPersistence().startSession(agentId, metadata); } /** * Quick add a message. */ export function addSessionMessage(message: Omit): SessionMessage | null { return getSessionPersistence().addMessage(message); } /** * Quick end session. */ export async function endCurrentSession(): Promise { return getSessionPersistence().endSession(); } /** * Get current session. */ export function getCurrentSession(): SessionState | null { return getSessionPersistence().getCurrentSession(); }