Files
zclaw_openfang/desktop/src/lib/session-persistence.ts
iven adfd7024df docs(claude): restructure documentation management and add feedback system
- Restructure §8 from "文档沉淀规则" to "文档管理规则" with 4 subsections
  - Add docs/ structure with features/ and knowledge-base/ directories
  - Add feature documentation template with 7 sections (概述/设计初衷/技术设计/预期作用/实际效果/演化路线/头脑风暴)
  - Add feature update trigger matrix (新增/修改/完成/问题/反馈)
  - Add documentation quality checklist
- Add §16
2026-03-16 13:54:03 +08:00

657 lines
18 KiB
TypeScript

/**
* 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 { getMemoryManager, type MemoryType } from './agent-memory';
import { getMemoryExtractor } from './memory-extractor';
import { canAutoExecute, executeWithAutonomy } from './autonomy-manager';
// === Types ===
export interface SessionMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
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<typeof setInterval> | null = null;
private sessionHistory: SessionSummary[] = [];
constructor(config?: Partial<SessionPersistenceConfig>) {
this.config = { ...DEFAULT_SESSION_CONFIG, ...config };
this.loadSessionHistory();
this.initializeVikingClient();
}
private async initializeVikingClient(): Promise<void> {
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<string, unknown>): 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, 'id' | 'timestamp'>): 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<PersistenceResult> {
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<number> {
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<void> {
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<void> {
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: session.messageCount,
agentId: session.agentId,
},
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<SessionPersistenceConfig>): 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<boolean> {
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<SessionPersistenceConfig>): 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<string, unknown>): SessionState {
return getSessionPersistence().startSession(agentId, metadata);
}
/**
* Quick add a message.
*/
export function addSessionMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
return getSessionPersistence().addMessage(message);
}
/**
* Quick end session.
*/
export async function endCurrentSession(): Promise<PersistenceResult> {
return getSessionPersistence().endSession();
}
/**
* Get current session.
*/
export function getCurrentSession(): SessionState | null {
return getSessionPersistence().getCurrentSession();
}