UNPKG

virtue-cli

Version:

Personal character development CLI tool for tracking virtues and philosophical alignment

461 lines (457 loc) 19.2 kB
import SQLite from 'better-sqlite3'; import { readFileSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; // Get current directory in a way that works with both Node.js and Jest const currentDir = process.cwd(); export class Database { db; static migrationCompleted = new Map(); dbPath; constructor(dbPath = './virtue-cli-data.db') { this.dbPath = dbPath; this.db = new SQLite(dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); this.initialize(); } // Expose database for repositories getDatabase() { return this.db; } // Wait for migrations to complete async waitForMigrations() { // Since we're running migrations synchronously in constructor, // this method just checks if migrations are completed if (!Database.migrationCompleted.get(this.dbPath)) { // Force migration completion if not already done this.runMigrationsWithLock(); } } initialize() { // Try multiple paths for schema.sql to handle different execution contexts let schemaPath = join(currentDir, 'src', 'database', 'schema.sql'); try { const schema = readFileSync(schemaPath, 'utf-8'); this.db.exec(schema); } catch { // Fallback for production/built environment try { schemaPath = join(currentDir, 'dist', 'database', 'schema.sql'); const schema = readFileSync(schemaPath, 'utf-8'); this.db.exec(schema); } catch (fallbackError) { throw new Error(`Could not find schema.sql at ${schemaPath}: ${fallbackError}`); } } // Run migrations for existing databases with safety lock this.runMigrationsWithLock(); } runMigrationsWithLock() { // If migrations already completed for this database, skip if (Database.migrationCompleted.get(this.dbPath)) { return; } // Run migrations synchronously for initialization try { const transaction = this.db.transaction(() => { this.runMigrations(); }); transaction(); Database.migrationCompleted.set(this.dbPath, true); } catch (error) { console.error('Migration failed:', error); throw error; } } runMigrations() { // Check if output_directory column exists const tableInfo = this.db.prepare("PRAGMA table_info(profiles)").all(); const hasOutputDirectory = tableInfo.some(col => col.name === 'output_directory'); if (!hasOutputDirectory) { // Add output_directory column to existing profiles table this.db.exec(` ALTER TABLE profiles ADD COLUMN output_directory TEXT; ALTER TABLE profiles ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP; `); } // Migration for virtue versioning this.migrateVirtueVersioning(); // Migration for automatic exports this.migrateAutoExport(); } migrateVirtueVersioning() { // Check if virtue versioning tables exist const virtueVersionsExists = this.db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='virtue_versions' `).get(); if (!virtueVersionsExists) { // Create virtue versioning tables this.db.exec(` -- Add version columns to virtues table ALTER TABLE virtues ADD COLUMN current_version INTEGER DEFAULT 1; ALTER TABLE virtues ADD COLUMN total_versions INTEGER DEFAULT 1; -- Create virtue versions table CREATE TABLE IF NOT EXISTS virtue_versions ( id TEXT PRIMARY KEY, virtue_id TEXT NOT NULL, version_number INTEGER NOT NULL, name TEXT NOT NULL, definition TEXT, sub_dimensions TEXT, behavioral_indicators TEXT, source_type TEXT CHECK(source_type IN ('template', 'ai_discovery', 'manual', 'refinement')), source_context TEXT, is_active BOOLEAN DEFAULT FALSE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_reason TEXT, FOREIGN KEY (virtue_id) REFERENCES virtues (id) ON DELETE CASCADE, UNIQUE(virtue_id, version_number) ); -- Create virtue version comparisons table CREATE TABLE IF NOT EXISTS virtue_version_comparisons ( id TEXT PRIMARY KEY, virtue_id TEXT NOT NULL, from_version INTEGER NOT NULL, to_version INTEGER NOT NULL, changes_summary TEXT, comparison_date DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (virtue_id) REFERENCES virtues (id) ON DELETE CASCADE ); -- Add version tracking to daily entries ALTER TABLE daily_entries ADD COLUMN virtue_versions_used TEXT; -- Create indexes for versioning CREATE INDEX IF NOT EXISTS idx_virtue_versions_virtue_version ON virtue_versions(virtue_id, version_number); CREATE INDEX IF NOT EXISTS idx_virtue_versions_active ON virtue_versions(virtue_id, is_active); CREATE INDEX IF NOT EXISTS idx_virtue_versions_created ON virtue_versions(virtue_id, created_at); `); // Migrate existing virtues to version 1 this.migrateExistingVirtuesToVersions(); } } migrateExistingVirtuesToVersions() { const virtues = this.db.prepare('SELECT * FROM virtues').all(); const insertVersionStmt = this.db.prepare(` INSERT INTO virtue_versions ( id, virtue_id, version_number, name, definition, sub_dimensions, behavioral_indicators, source_type, source_context, is_active, created_reason ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const virtue of virtues) { // Get sub-dimensions and indicators from virtue definition if it's JSON let subDimensions = null; let behavioralIndicators = null; try { // Try to parse definition as JSON to extract sub-dimensions if (virtue.definition && virtue.definition.startsWith('{')) { const parsed = JSON.parse(virtue.definition); if (parsed.subDimensions) { subDimensions = JSON.stringify(parsed.subDimensions); } if (parsed.behavioralIndicators) { behavioralIndicators = JSON.stringify(parsed.behavioralIndicators); } } } catch { // If not JSON, leave as null } // Create version 1 for each existing virtue insertVersionStmt.run(randomUUID(), virtue.id, 1, // version_number virtue.name, virtue.definition, subDimensions, behavioralIndicators, virtue.template_source ? 'template' : 'manual', 'Initial version from existing virtue', 1, // is_active = true 'Migration from non-versioned virtue'); } } migrateAutoExport() { // Check if auto export columns exist const tableInfo = this.db.prepare("PRAGMA table_info(profiles)").all(); const hasAutoExport = tableInfo.some(col => col.name === 'auto_export_enabled'); if (!hasAutoExport) { this.db.exec(` -- Add auto export configuration columns ALTER TABLE profiles ADD COLUMN auto_export_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE profiles ADD COLUMN export_target_path TEXT; ALTER TABLE profiles ADD COLUMN export_format TEXT DEFAULT 'summary'; ALTER TABLE profiles ADD COLUMN last_export_date TEXT; ALTER TABLE profiles ADD COLUMN export_frequency TEXT DEFAULT 'daily'; `); } } // Profile methods async createProfile(profile) { const id = randomUUID(); const stmt = this.db.prepare(` INSERT INTO profiles (id, name, philosophical_template, ascii_theme, color_scheme, output_directory, auto_export_enabled, export_target_path, export_format, export_frequency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run(id, profile.name, profile.philosophical_template, profile.ascii_theme, profile.color_scheme, profile.output_directory, profile.auto_export_enabled ? 1 : 0, profile.export_target_path || null, profile.export_format || 'summary', profile.export_frequency || 'daily'); return this.getProfile(id); } getProfile(id) { const stmt = this.db.prepare('SELECT * FROM profiles WHERE id = ?'); const profile = stmt.get(id); if (profile) { // Convert SQLite boolean profile.auto_export_enabled = !!profile.auto_export_enabled; } return profile; } getAllProfiles() { const stmt = this.db.prepare('SELECT * FROM profiles ORDER BY created_at DESC'); const profiles = stmt.all(); // Convert SQLite booleans return profiles.map(profile => ({ ...profile, auto_export_enabled: !!profile.auto_export_enabled })); } hasProfiles() { const stmt = this.db.prepare('SELECT COUNT(*) as count FROM profiles'); const result = stmt.get(); return result.count > 0; } updateProfile(id, updates) { const allowedFields = ['name', 'philosophical_template', 'ascii_theme', 'color_scheme', 'output_directory', 'auto_export_enabled', 'export_target_path', 'export_format', 'last_export_date', 'export_frequency']; const updateFields = Object.keys(updates) .filter(key => allowedFields.includes(key) && updates[key] !== undefined) .map(key => `${key} = ?`); if (updateFields.length === 0) { return this.getProfile(id); } const values = Object.keys(updates) .filter(key => allowedFields.includes(key) && updates[key] !== undefined) .map(key => { const value = updates[key]; // Convert boolean to integer for SQLite if (key === 'auto_export_enabled' && typeof value === 'boolean') { return value ? 1 : 0; } return value; }); const stmt = this.db.prepare(` UPDATE profiles SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ? `); stmt.run(...values, id); return this.getProfile(id); } deleteProfile(id) { // Delete profile and all related data (cascades due to foreign keys) const stmt = this.db.prepare('DELETE FROM profiles WHERE id = ?'); const result = stmt.run(id); return result.changes > 0; } // Virtue methods async createVirtue(virtue) { const id = randomUUID(); const stmt = this.db.prepare(` INSERT INTO virtues (id, profile_id, name, definition, priority_weight, template_source, emoji, current_version, total_versions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run(id, virtue.profile_id, virtue.name, virtue.definition, virtue.priority_weight, virtue.template_source, virtue.emoji, 1, // current_version 1 // total_versions ); // Create initial version in virtue_versions table this.createInitialVirtueVersion(id, virtue); return this.getVirtue(id); } createInitialVirtueVersion(virtueId, virtue) { const versionId = randomUUID(); // Get sub-dimensions and indicators from virtue definition if it's JSON let subDimensions = null; let behavioralIndicators = null; try { // Try to parse definition as JSON to extract sub-dimensions if (virtue.definition && virtue.definition.startsWith('{')) { const parsed = JSON.parse(virtue.definition); if (parsed.subDimensions) { subDimensions = JSON.stringify(parsed.subDimensions); } if (parsed.behavioralIndicators) { behavioralIndicators = JSON.stringify(parsed.behavioralIndicators); } } } catch { // If not JSON, leave as null } const insertVersionStmt = this.db.prepare(` INSERT INTO virtue_versions ( id, virtue_id, version_number, name, definition, sub_dimensions, behavioral_indicators, source_type, source_context, is_active, created_reason ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); insertVersionStmt.run(versionId, virtueId, 1, // version_number virtue.name, virtue.definition, subDimensions, behavioralIndicators, virtue.template_source ? 'template' : 'manual', 'Initial version created with virtue', 1, // is_active = true 'Initial virtue creation'); } getVirtue(id) { const stmt = this.db.prepare('SELECT * FROM virtues WHERE id = ?'); return stmt.get(id); } getVirtuesForProfile(profileId) { const stmt = this.db.prepare(` SELECT * FROM virtues WHERE profile_id = ? ORDER BY priority_weight DESC, created_at ASC `); return stmt.all(profileId); } // Daily entry methods async createDailyEntry(entry) { const id = randomUUID(); const stmt = this.db.prepare(` INSERT INTO daily_entries (id, profile_id, date, virtue_scores, virtue_versions_used, notes, coherence_score, completion_time_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run(id, entry.profile_id, entry.date, entry.virtue_scores, entry.virtue_versions_used, entry.notes, entry.coherence_score, entry.completion_time_seconds); return this.getDailyEntry(id); } getDailyEntry(id) { const stmt = this.db.prepare('SELECT * FROM daily_entries WHERE id = ?'); return stmt.get(id); } getDailyEntryByDate(profileId, date) { const stmt = this.db.prepare('SELECT * FROM daily_entries WHERE profile_id = ? AND date = ?'); return stmt.get(profileId, date); } getEntriesForProfile(profileId, cutoffDateOrDays) { let query = 'SELECT * FROM daily_entries WHERE profile_id = ?'; const params = [profileId]; if (cutoffDateOrDays instanceof Date) { query += ' AND date >= ?'; params.push(cutoffDateOrDays.toISOString().split('T')[0]); query += ' ORDER BY date DESC'; } else if (typeof cutoffDateOrDays === 'number') { query += ' ORDER BY date DESC LIMIT ?'; params.push(cutoffDateOrDays); } else { query += ' ORDER BY date DESC'; } const stmt = this.db.prepare(query); const entries = stmt.all(...params); // Parse virtue_scores JSON for each entry return entries.map(entry => ({ ...entry, virtue_scores: typeof entry.virtue_scores === 'string' ? JSON.parse(entry.virtue_scores) : entry.virtue_scores })); } getLatestEntry(profileId) { const stmt = this.db.prepare(` SELECT * FROM daily_entries WHERE profile_id = ? ORDER BY date DESC LIMIT 1 `); return stmt.get(profileId); } // Session methods async createSession(session) { const id = randomUUID(); const stmt = this.db.prepare(` INSERT INTO sessions (id, profile_id, session_type, duration_seconds) VALUES (?, ?, ?, ?) `); stmt.run(id, session.profile_id, session.session_type, session.duration_seconds); return this.getSession(id); } getSession(id) { const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?'); return stmt.get(id); } // Monthly deep-dive methods async createMonthlyDeepDive(deepDive) { const id = randomUUID(); const stmt = this.db.prepare(` INSERT INTO monthly_deep_dives (id, profile_id, month, analysis_data, intervention_plan) VALUES (?, ?, ?, ?, ?) `); stmt.run(id, deepDive.profile_id, deepDive.month, deepDive.analysis_data, deepDive.intervention_plan); return this.getMonthlyDeepDiveById(id); } getMonthlyDeepDiveById(id) { const stmt = this.db.prepare('SELECT * FROM monthly_deep_dives WHERE id = ?'); return stmt.get(id); } getMonthlyDeepDiveByMonth(profileId, month) { const stmt = this.db.prepare('SELECT * FROM monthly_deep_dives WHERE profile_id = ? AND month = ?'); return stmt.get(profileId, month); } // Streak calculation calculateCurrentStreak(profileId) { const entries = this.db.prepare(` SELECT date FROM daily_entries WHERE profile_id = ? ORDER BY date DESC `).all(profileId); if (entries.length === 0) return 0; let streak = 1; const today = new Date(); const lastDate = new Date(entries[0].date); // Check if last entry was today or yesterday const dayDiff = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); if (dayDiff > 1) return 0; // Count consecutive days for (let i = 1; i < entries.length; i++) { const currentDate = new Date(entries[i].date); const previousDate = new Date(entries[i - 1].date); const diff = Math.floor((previousDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)); if (diff === 1) { streak++; } else { break; } } return streak; } // Average coherence calculation calculateAverageCoherence(profileId, days = 30) { const stmt = this.db.prepare(` SELECT AVG(coherence_score) as avg_coherence FROM daily_entries WHERE profile_id = ? ORDER BY date DESC LIMIT ? `); const result = stmt.get(profileId, days); return result.avg_coherence || 0; } // Additional monthly deep dive methods createMonthlyDeepDiveSimple(profileId, month, analysisData, interventionPlan) { const id = randomUUID(); const stmt = this.db.prepare(` INSERT INTO monthly_deep_dives (id, profile_id, month, analysis_data, intervention_plan, created_at) VALUES (?, ?, ?, ?, ?, datetime('now')) `); stmt.run(id, profileId, month, analysisData, interventionPlan); return id; } getAllMonthlyDeepDives(profileId) { const stmt = this.db.prepare(` SELECT * FROM monthly_deep_dives WHERE profile_id = ? ORDER BY month DESC `); return stmt.all(profileId); } close() { this.db.close(); // Clear migration tracking for this database path when closing Database.migrationCompleted.delete(this.dbPath); } } //# sourceMappingURL=Database.js.map