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
This commit is contained in:
656
desktop/src/lib/session-persistence.ts
Normal file
656
desktop/src/lib/session-persistence.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user