- 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
657 lines
18 KiB
TypeScript
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();
|
|
}
|