UNPKG

codecrucible-synth

Version:

Production-Ready AI Development Platform with Multi-Voice Synthesis, Smithery MCP Integration, Enterprise Security, and Zero-Timeout Reliability

789 lines (690 loc) 21.7 kB
import * as sqlite3 from 'sqlite3'; import { Database, open } from 'sqlite'; import * as path from 'path'; import * as fs from 'fs/promises'; import { logger } from '../logger.js'; import { SynthesisResult } from '../types.js'; import { ProjectContext } from './project-memory.js'; export interface StoredInteraction { id: string; sessionId: string; timestamp: number; prompt: string; response: string; voicesUsed: string[]; confidence: number; latency: number; userFeedback?: 'positive' | 'negative' | 'neutral'; topics: string[]; contextHash: string; embedding?: number[]; // For semantic search metadata: Record<string, any>; } export interface ConversationSession { id: string; startTime: number; endTime?: number; totalInteractions: number; averageConfidence: number; topics: string[]; workspaceRoot: string; userAgent?: string; } export interface SearchQuery { text?: string; sessionId?: string; timeRange?: { start: number; end: number }; topics?: string[]; voices?: string[]; minConfidence?: number; limit?: number; offset?: number; } export interface SearchResult { interactions: StoredInteraction[]; total: number; relevanceScores?: number[]; } export interface ConversationAnalytics { totalInteractions: number; totalSessions: number; averageConfidence: number; topTopics: Array<{ topic: string; count: number }>; voiceUsage: Array<{ voice: string; count: number }>; dailyActivity: Array<{ date: string; interactions: number }>; averageLatency: number; } /** * Persistent conversation storage with semantic search capabilities * Provides SQL-based storage with embeddings for intelligent retrieval */ export class ConversationStore { private db?: Database; private dbPath: string; private initialized = false; constructor(workspaceRoot: string) { const codecrucibleDir = path.join(workspaceRoot, '.codecrucible'); this.dbPath = path.join(codecrucibleDir, 'conversations.db'); logger.info('Conversation store initialized', { dbPath: this.dbPath }); } /** * Initialize database and create tables */ async initialize(): Promise<void> { if (this.initialized) return; try { // Ensure directory exists await fs.mkdir(path.dirname(this.dbPath), { recursive: true }); // Open database this.db = await open({ filename: this.dbPath, driver: sqlite3.Database, }); // Create tables await this.createTables(); // Create indexes for performance await this.createIndexes(); this.initialized = true; logger.info('Conversation store database initialized'); } catch (error) { logger.error('Failed to initialize conversation store:', error); throw error; } } /** * Store interaction with optional semantic embedding */ async storeInteraction( prompt: string, response: SynthesisResult, context: ProjectContext, sessionId: string, userFeedback?: 'positive' | 'negative' | 'neutral' ): Promise<string> { await this.ensureInitialized(); const id = this.generateId(); const timestamp = Date.now(); const contextHash = this.hashContext(context); const topics = this.extractTopics(prompt, response.synthesis || ''); // Generate embedding for semantic search (simplified for now) const embedding = await this.generateEmbedding(prompt + ' ' + response.synthesis); const interaction: StoredInteraction = { id, sessionId, timestamp, prompt, response: response.synthesis || '', voicesUsed: response.voicesUsed, confidence: response.confidence || 0, latency: response.latency || 0, userFeedback, topics, contextHash, embedding, metadata: { modelUsed: response.modelUsed, reasoning: typeof response.reasoning === 'object' && response.reasoning && 'steps' in response.reasoning && Array.isArray((response.reasoning as any).steps) ? (response.reasoning as any).steps.length : 0, promptTokens: prompt.length, // Simplified token count responseTokens: (response.synthesis || '').length, }, }; try { await this.db!.run( ` INSERT INTO interactions ( id, session_id, timestamp, prompt, response, voices_used, confidence, latency, user_feedback, topics, context_hash, embedding, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ interaction.id, interaction.sessionId, interaction.timestamp, interaction.prompt, interaction.response, JSON.stringify(interaction.voicesUsed), interaction.confidence, interaction.latency, interaction.userFeedback, JSON.stringify(interaction.topics), interaction.contextHash, JSON.stringify(interaction.embedding), JSON.stringify(interaction.metadata), ] ); // Update session statistics await this.updateSessionStats(sessionId, interaction); logger.debug('Stored interaction', { id: interaction.id, sessionId: interaction.sessionId, topics: interaction.topics.length, confidence: interaction.confidence, }); return id; } catch (error) { logger.error('Failed to store interaction:', error); throw error; } } /** * Search interactions with text and semantic similarity */ async searchInteractions(query: SearchQuery): Promise<SearchResult> { await this.ensureInitialized(); let sql = ` SELECT i.*, s.workspace_root FROM interactions i LEFT JOIN sessions s ON i.session_id = s.id WHERE 1=1 `; const params: any[] = []; // Add filters if (query.text) { sql += ` AND (i.prompt LIKE ? OR i.response LIKE ?)`; const searchPattern = `%${query.text}%`; params.push(searchPattern, searchPattern); } if (query.sessionId) { sql += ` AND i.session_id = ?`; params.push(query.sessionId); } if (query.timeRange) { sql += ` AND i.timestamp BETWEEN ? AND ?`; params.push(query.timeRange.start, query.timeRange.end); } if (query.topics && query.topics.length > 0) { sql += ` AND (${query.topics.map(() => 'i.topics LIKE ?').join(' OR ')})`; query.topics.forEach(topic => params.push(`%"${topic}"%`)); } if (query.voices && query.voices.length > 0) { sql += ` AND (${query.voices.map(() => 'i.voices_used LIKE ?').join(' OR ')})`; query.voices.forEach(voice => params.push(`%"${voice}"%`)); } if (query.minConfidence !== undefined) { sql += ` AND i.confidence >= ?`; params.push(query.minConfidence); } // Order by timestamp descending sql += ` ORDER BY i.timestamp DESC`; // Add pagination if (query.limit) { sql += ` LIMIT ?`; params.push(query.limit); if (query.offset) { sql += ` OFFSET ?`; params.push(query.offset); } } try { const rows = await this.db!.all(sql, params); const interactions: StoredInteraction[] = rows.map(row => this.mapRowToInteraction(row)); // Get total count const countSql = sql .replace(/SELECT i\.\*, s\.workspace_root/, 'SELECT COUNT(*)') .replace(/ORDER BY i\.timestamp DESC.*$/, ''); const countResult = await this.db!.get(countSql, params.slice(0, -2)); // Remove LIMIT/OFFSET params const total = countResult['COUNT(*)'] || 0; // Calculate relevance scores if text search let relevanceScores: number[] | undefined; if (query.text) { relevanceScores = await this.calculateRelevanceScores(query.text, interactions); } logger.debug('Search completed', { query: query.text?.substring(0, 50), results: interactions.length, total, }); return { interactions, total, relevanceScores, }; } catch (error) { logger.error('Search failed:', error); throw error; } } /** * Get conversation session by ID */ async getSession(sessionId: string): Promise<ConversationSession | null> { await this.ensureInitialized(); try { const row = await this.db!.get('SELECT * FROM sessions WHERE id = ?', [sessionId]); if (!row) return null; return { id: row.id, startTime: row.start_time, endTime: row.end_time, totalInteractions: row.total_interactions, averageConfidence: row.average_confidence, topics: JSON.parse(row.topics || '[]'), workspaceRoot: row.workspace_root, userAgent: row.user_agent, }; } catch (error) { logger.error(`Failed to get session ${sessionId}:`, error); return null; } } /** * Start a new conversation session */ async startSession(workspaceRoot: string, userAgent?: string): Promise<string> { await this.ensureInitialized(); const sessionId = this.generateId(); const startTime = Date.now(); try { await this.db!.run( ` INSERT INTO sessions (id, start_time, workspace_root, user_agent, total_interactions, average_confidence, topics) VALUES (?, ?, ?, ?, 0, 0, '[]') `, [sessionId, startTime, workspaceRoot, userAgent] ); logger.info('Started conversation session', { sessionId, workspaceRoot }); return sessionId; } catch (error) { logger.error('Failed to start session:', error); throw error; } } /** * End a conversation session */ async endSession(sessionId: string): Promise<void> { await this.ensureInitialized(); try { await this.db!.run( ` UPDATE sessions SET end_time = ? WHERE id = ? `, [Date.now(), sessionId] ); logger.info('Ended conversation session', { sessionId }); } catch (error) { logger.error(`Failed to end session ${sessionId}:`, error); } } /** * Get conversation analytics */ async getAnalytics(timeRange?: { start: number; end: number }): Promise<ConversationAnalytics> { await this.ensureInitialized(); try { let whereClause = ''; const params: any[] = []; if (timeRange) { whereClause = 'WHERE timestamp BETWEEN ? AND ?'; params.push(timeRange.start, timeRange.end); } // Total interactions and sessions const totals = await this.db!.get( ` SELECT COUNT(*) as total_interactions, COUNT(DISTINCT session_id) as total_sessions, AVG(confidence) as average_confidence, AVG(latency) as average_latency FROM interactions ${whereClause} `, params ); // Top topics const topicsQuery = await this.db!.all( ` SELECT topics FROM interactions ${whereClause} ORDER BY timestamp DESC LIMIT 1000 `, params ); const topicCounts = new Map<string, number>(); topicsQuery.forEach(row => { try { const topics = JSON.parse(row.topics || '[]'); topics.forEach((topic: string) => { topicCounts.set(topic, (topicCounts.get(topic) || 0) + 1); }); } catch (error) { // Ignore JSON parse errors } }); const topTopics = Array.from(topicCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([topic, count]) => ({ topic, count })); // Voice usage const voicesQuery = await this.db!.all( ` SELECT voices_used FROM interactions ${whereClause} ORDER BY timestamp DESC LIMIT 1000 `, params ); const voiceCounts = new Map<string, number>(); voicesQuery.forEach(row => { try { const voices = JSON.parse(row.voices_used || '[]'); voices.forEach((voice: string) => { voiceCounts.set(voice, (voiceCounts.get(voice) || 0) + 1); }); } catch (error) { // Ignore JSON parse errors } }); const voiceUsage = Array.from(voiceCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([voice, count]) => ({ voice, count })); // Daily activity (last 30 days) const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; const dailyQuery = await this.db!.all( ` SELECT DATE(timestamp / 1000, 'unixepoch') as date, COUNT(*) as interactions FROM interactions WHERE timestamp > ? GROUP BY DATE(timestamp / 1000, 'unixepoch') ORDER BY date `, [thirtyDaysAgo] ); const dailyActivity = dailyQuery.map(row => ({ date: row.date, interactions: row.interactions, })); return { totalInteractions: totals.total_interactions || 0, totalSessions: totals.total_sessions || 0, averageConfidence: totals.average_confidence || 0, averageLatency: totals.average_latency || 0, topTopics, voiceUsage, dailyActivity, }; } catch (error) { logger.error('Failed to get analytics:', error); throw error; } } /** * Update user feedback for an interaction */ async updateFeedback( interactionId: string, feedback: 'positive' | 'negative' | 'neutral' ): Promise<void> { await this.ensureInitialized(); try { await this.db!.run( ` UPDATE interactions SET user_feedback = ? WHERE id = ? `, [feedback, interactionId] ); logger.debug('Updated interaction feedback', { interactionId, feedback }); } catch (error) { logger.error(`Failed to update feedback for ${interactionId}:`, error); throw error; } } /** * Create database tables */ private async createTables(): Promise<void> { // Sessions table await this.db!.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, start_time INTEGER NOT NULL, end_time INTEGER, total_interactions INTEGER DEFAULT 0, average_confidence REAL DEFAULT 0, topics TEXT DEFAULT '[]', workspace_root TEXT, user_agent TEXT ) `); // Interactions table await this.db!.exec(` CREATE TABLE IF NOT EXISTS interactions ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, timestamp INTEGER NOT NULL, prompt TEXT NOT NULL, response TEXT NOT NULL, voices_used TEXT DEFAULT '[]', confidence REAL DEFAULT 0, latency INTEGER DEFAULT 0, user_feedback TEXT, topics TEXT DEFAULT '[]', context_hash TEXT, embedding TEXT, metadata TEXT DEFAULT '{}', FOREIGN KEY (session_id) REFERENCES sessions (id) ) `); } /** * Create database indexes for performance */ private async createIndexes(): Promise<void> { const indexes = [ 'CREATE INDEX IF NOT EXISTS idx_interactions_session_id ON interactions(session_id)', 'CREATE INDEX IF NOT EXISTS idx_interactions_timestamp ON interactions(timestamp)', 'CREATE INDEX IF NOT EXISTS idx_interactions_confidence ON interactions(confidence)', 'CREATE INDEX IF NOT EXISTS idx_interactions_topics ON interactions(topics)', 'CREATE INDEX IF NOT EXISTS idx_sessions_start_time ON sessions(start_time)', 'CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_root)', ]; for (const indexSql of indexes) { await this.db!.exec(indexSql); } } /** * Map database row to StoredInteraction */ private mapRowToInteraction(row: any): StoredInteraction { return { id: row.id, sessionId: row.session_id, timestamp: row.timestamp, prompt: row.prompt, response: row.response, voicesUsed: JSON.parse(row.voices_used || '[]'), confidence: row.confidence, latency: row.latency, userFeedback: row.user_feedback, topics: JSON.parse(row.topics || '[]'), contextHash: row.context_hash, embedding: JSON.parse(row.embedding || '[]'), metadata: JSON.parse(row.metadata || '{}'), }; } /** * Update session statistics */ private async updateSessionStats( sessionId: string, interaction: StoredInteraction ): Promise<void> { const session = await this.getSession(sessionId); if (!session) return; const newTotal = session.totalInteractions + 1; const newAverage = (session.averageConfidence * session.totalInteractions + interaction.confidence) / newTotal; // Merge topics const existingTopics = new Set(session.topics); interaction.topics.forEach(topic => existingTopics.add(topic)); const mergedTopics = Array.from(existingTopics); await this.db!.run( ` UPDATE sessions SET total_interactions = ?, average_confidence = ?, topics = ? WHERE id = ? `, [newTotal, newAverage, JSON.stringify(mergedTopics), sessionId] ); } /** * Generate simple embedding for semantic search */ private async generateEmbedding(text: string): Promise<number[]> { // This is a simplified embedding generation // In a real implementation, you would use a proper embedding model const words = text.toLowerCase().split(/\s+/); const embedding = new Array(100).fill(0); // Simple hash-based embedding words.forEach((word, index) => { const hash = this.simpleHash(word); embedding[hash % 100] += 1; }); // Normalize const magnitude = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0)); if (magnitude > 0) { return embedding.map(val => val / magnitude); } return embedding; } /** * Calculate relevance scores for search results */ private async calculateRelevanceScores( query: string, interactions: StoredInteraction[] ): Promise<number[]> { const queryEmbedding = await this.generateEmbedding(query); return interactions.map(interaction => { if (!interaction.embedding || interaction.embedding.length === 0) { // Fallback to text similarity return this.calculateTextSimilarity(query, interaction.prompt + ' ' + interaction.response); } // Calculate cosine similarity return this.cosineSimilarity(queryEmbedding, interaction.embedding); }); } /** * Calculate cosine similarity between vectors */ private cosineSimilarity(a: number[], b: number[]): number { if (a.length !== b.length) return 0; const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0); const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0)); const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0)); if (magnitudeA === 0 || magnitudeB === 0) return 0; return dotProduct / (magnitudeA * magnitudeB); } /** * Calculate text similarity (fallback) */ private calculateTextSimilarity(query: string, text: string): number { const queryWords = new Set(query.toLowerCase().split(/\s+/)); const textWords = text.toLowerCase().split(/\s+/); const matches = textWords.filter(word => queryWords.has(word)).length; return matches / Math.max(queryWords.size, textWords.length); } /** * Extract topics from text */ private extractTopics(prompt: string, response: string): string[] { const text = `${prompt} ${response}`.toLowerCase(); const topics = new Set<string>(); // Programming languages const languages = [ 'javascript', 'typescript', 'python', 'java', 'rust', 'go', 'cpp', 'c#', 'php', ]; languages.forEach(lang => { if (text.includes(lang)) topics.add(lang); }); // Technologies const technologies = [ 'react', 'vue', 'angular', 'docker', 'kubernetes', 'git', 'database', 'api', ]; technologies.forEach(tech => { if (text.includes(tech)) topics.add(tech); }); return Array.from(topics); } /** * Generate hash for context */ private hashContext(context: ProjectContext): string { const str = JSON.stringify({ guidance: context.guidance?.substring(0, 100), preferences: context.preferences, patterns: context.patterns.map(p => p.name), }); return this.simpleHash(str).toString(); } /** * Simple hash function */ private simpleHash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash); } /** * Generate unique ID */ private generateId(): string { return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Ensure database is initialized */ private async ensureInitialized(): Promise<void> { if (!this.initialized) { await this.initialize(); } } /** * Close database connection */ async dispose(): Promise<void> { if (this.db) { await this.db.close(); this.db = undefined; this.initialized = false; logger.info('Conversation store disposed'); } } }