import Database from 'better-sqlite3'; import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js'; /** * Lightweight database interface for hooks * Provides simple, synchronous operations for hook commands * No complex logic - just basic CRUD operations */ export class HooksDatabase { private db: Database; constructor() { ensureDir(DATA_DIR); this.db = new Database(DB_PATH); // Ensure optimized settings this.db.pragma('journal_mode = WAL'); this.db.pragma('synchronous = NORMAL'); this.db.pragma('foreign_keys = ON'); // Run migrations this.ensureWorkerPortColumn(); this.ensurePromptTrackingColumns(); this.removeSessionSummariesUniqueConstraint(); this.addObservationHierarchicalFields(); } /** * Ensure worker_port column exists (migration) */ private ensureWorkerPortColumn(): void { try { // Check if column exists const tableInfo = this.db.pragma('table_info(sdk_sessions)'); const hasWorkerPort = (tableInfo as any[]).some((col: any) => col.name === 'worker_port'); if (!hasWorkerPort) { this.db.exec('ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER'); console.error('[HooksDatabase] Added worker_port column to sdk_sessions table'); } } catch (error: any) { console.error('[HooksDatabase] Migration error:', error.message); } } /** * Ensure prompt tracking columns exist (migration 006) */ private ensurePromptTrackingColumns(): void { try { // Check sdk_sessions for prompt_counter const sessionsInfo = this.db.pragma('table_info(sdk_sessions)'); const hasPromptCounter = (sessionsInfo as any[]).some((col: any) => col.name === 'prompt_counter'); if (!hasPromptCounter) { this.db.exec('ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0'); console.error('[HooksDatabase] Added prompt_counter column to sdk_sessions table'); } // Check observations for prompt_number const observationsInfo = this.db.pragma('table_info(observations)'); const obsHasPromptNumber = (observationsInfo as any[]).some((col: any) => col.name === 'prompt_number'); if (!obsHasPromptNumber) { this.db.exec('ALTER TABLE observations ADD COLUMN prompt_number INTEGER'); console.error('[HooksDatabase] Added prompt_number column to observations table'); } // Check session_summaries for prompt_number const summariesInfo = this.db.pragma('table_info(session_summaries)'); const sumHasPromptNumber = (summariesInfo as any[]).some((col: any) => col.name === 'prompt_number'); if (!sumHasPromptNumber) { this.db.exec('ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER'); console.error('[HooksDatabase] Added prompt_number column to session_summaries table'); } // Remove UNIQUE constraint on session_summaries.sdk_session_id // SQLite doesn't support dropping constraints, so we need to check if it exists first const summariesIndexes = this.db.pragma('index_list(session_summaries)'); const hasUniqueConstraint = (summariesIndexes as any[]).some((idx: any) => idx.unique === 1); } catch (error: any) { console.error('[HooksDatabase] Prompt tracking migration error:', error.message); } } /** * Remove UNIQUE constraint from session_summaries.sdk_session_id (migration 007) */ private removeSessionSummariesUniqueConstraint(): void { try { // Check if UNIQUE constraint exists const summariesIndexes = this.db.pragma('index_list(session_summaries)'); const hasUniqueConstraint = (summariesIndexes as any[]).some((idx: any) => idx.unique === 1); if (!hasUniqueConstraint) { // Already migrated return; } console.error('[HooksDatabase] Removing UNIQUE constraint from session_summaries.sdk_session_id...'); // Begin transaction this.db.exec('BEGIN TRANSACTION'); try { // Create new table without UNIQUE constraint this.db.exec(` CREATE TABLE session_summaries_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, sdk_session_id TEXT NOT NULL, project TEXT NOT NULL, request TEXT, investigated TEXT, learned TEXT, completed TEXT, next_steps TEXT, files_read TEXT, files_edited TEXT, notes TEXT, prompt_number INTEGER, created_at TEXT NOT NULL, created_at_epoch INTEGER NOT NULL, FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE ) `); // Copy data from old table this.db.exec(` INSERT INTO session_summaries_new SELECT id, sdk_session_id, project, request, investigated, learned, completed, next_steps, files_read, files_edited, notes, prompt_number, created_at, created_at_epoch FROM session_summaries `); // Drop old table this.db.exec('DROP TABLE session_summaries'); // Rename new table this.db.exec('ALTER TABLE session_summaries_new RENAME TO session_summaries'); // Recreate indexes this.db.exec(` CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(sdk_session_id); CREATE INDEX idx_session_summaries_project ON session_summaries(project); CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC); `); // Commit transaction this.db.exec('COMMIT'); console.error('[HooksDatabase] Successfully removed UNIQUE constraint from session_summaries.sdk_session_id'); } catch (error: any) { // Rollback on error this.db.exec('ROLLBACK'); throw error; } } catch (error: any) { console.error('[HooksDatabase] Migration error (remove UNIQUE constraint):', error.message); } } /** * Add hierarchical fields to observations table (migration 008) */ private addObservationHierarchicalFields(): void { try { // Check if new fields already exist const tableInfo = this.db.pragma('table_info(observations)'); const hasTitle = (tableInfo as any[]).some((col: any) => col.name === 'title'); if (hasTitle) { // Already migrated return; } console.error('[HooksDatabase] Adding hierarchical fields to observations table...'); // Add new columns this.db.exec(` ALTER TABLE observations ADD COLUMN title TEXT; ALTER TABLE observations ADD COLUMN subtitle TEXT; ALTER TABLE observations ADD COLUMN facts TEXT; ALTER TABLE observations ADD COLUMN narrative TEXT; ALTER TABLE observations ADD COLUMN concepts TEXT; ALTER TABLE observations ADD COLUMN files_read TEXT; ALTER TABLE observations ADD COLUMN files_modified TEXT; `); console.error('[HooksDatabase] Successfully added hierarchical fields to observations table'); } catch (error: any) { console.error('[HooksDatabase] Migration error (add hierarchical fields):', error.message); } } /** * Get recent session summaries for a project */ getRecentSummaries(project: string, limit: number = 10): Array<{ request: string | null; investigated: string | null; learned: string | null; completed: string | null; next_steps: string | null; files_read: string | null; files_edited: string | null; notes: string | null; prompt_number: number | null; created_at: string; }> { const stmt = this.db.prepare(` SELECT request, investigated, learned, completed, next_steps, files_read, files_edited, notes, prompt_number, created_at FROM session_summaries WHERE project = ? ORDER BY created_at_epoch DESC LIMIT ? `); return stmt.all(project, limit) as any[]; } /** * Get recent observations for a project */ getRecentObservations(project: string, limit: number = 20): Array<{ type: string; text: string; prompt_number: number | null; created_at: string; }> { const stmt = this.db.prepare(` SELECT type, text, prompt_number, created_at FROM observations WHERE project = ? ORDER BY created_at_epoch DESC LIMIT ? `); return stmt.all(project, limit) as any[]; } /** * Get session by ID */ getSessionById(id: number): { id: number; sdk_session_id: string | null; project: string; user_prompt: string; } | null { const stmt = this.db.prepare(` SELECT id, sdk_session_id, project, user_prompt FROM sdk_sessions WHERE id = ? LIMIT 1 `); return stmt.get(id) as any || null; } /** * Find active SDK session for a Claude session */ findActiveSDKSession(claudeSessionId: string): { id: number; sdk_session_id: string | null; project: string; worker_port: number | null; } | null { const stmt = this.db.prepare(` SELECT id, sdk_session_id, project, worker_port FROM sdk_sessions WHERE claude_session_id = ? AND status = 'active' LIMIT 1 `); return stmt.get(claudeSessionId) as any || null; } /** * Find any SDK session for a Claude session (active, failed, or completed) */ findAnySDKSession(claudeSessionId: string): { id: number } | null { const stmt = this.db.prepare(` SELECT id FROM sdk_sessions WHERE claude_session_id = ? LIMIT 1 `); return stmt.get(claudeSessionId) as any || null; } /** * Reactivate an existing session */ reactivateSession(id: number, userPrompt: string): void { const stmt = this.db.prepare(` UPDATE sdk_sessions SET status = 'active', user_prompt = ?, worker_port = NULL WHERE id = ? `); stmt.run(userPrompt, id); } /** * Increment prompt counter and return new value */ incrementPromptCounter(id: number): number { const stmt = this.db.prepare(` UPDATE sdk_sessions SET prompt_counter = COALESCE(prompt_counter, 0) + 1 WHERE id = ? `); stmt.run(id); const result = this.db.prepare(` SELECT prompt_counter FROM sdk_sessions WHERE id = ? `).get(id) as { prompt_counter: number } | undefined; return result?.prompt_counter || 1; } /** * Get current prompt counter for a session */ getPromptCounter(id: number): number { const result = this.db.prepare(` SELECT prompt_counter FROM sdk_sessions WHERE id = ? `).get(id) as { prompt_counter: number | null } | undefined; return result?.prompt_counter || 0; } /** * Create a new SDK session */ createSDKSession(claudeSessionId: string, project: string, userPrompt: string): number { const now = new Date(); const nowEpoch = now.getTime(); const stmt = this.db.prepare(` INSERT INTO sdk_sessions (claude_session_id, project, user_prompt, started_at, started_at_epoch, status) VALUES (?, ?, ?, ?, ?, 'active') `); const result = stmt.run(claudeSessionId, project, userPrompt, now.toISOString(), nowEpoch); return result.lastInsertRowid as number; } /** * Update SDK session ID (captured from init message) * Only updates if current sdk_session_id is NULL to avoid breaking foreign keys * Returns true if update succeeded, false if skipped */ updateSDKSessionId(id: number, sdkSessionId: string): boolean { const stmt = this.db.prepare(` UPDATE sdk_sessions SET sdk_session_id = ? WHERE id = ? AND sdk_session_id IS NULL `); const result = stmt.run(sdkSessionId, id); if (result.changes === 0) { console.error(`[HooksDatabase] Skipped updating sdk_session_id for session ${id} - already set (prevents FOREIGN KEY constraint violation)`); return false; } return true; } /** * Set worker port for a session */ setWorkerPort(id: number, port: number): void { const stmt = this.db.prepare(` UPDATE sdk_sessions SET worker_port = ? WHERE id = ? `); stmt.run(port, id); } /** * Get worker port for a session */ getWorkerPort(id: number): number | null { const stmt = this.db.prepare(` SELECT worker_port FROM sdk_sessions WHERE id = ? LIMIT 1 `); const result = stmt.get(id) as { worker_port: number | null } | undefined; return result?.worker_port || null; } /** * Store an observation (from SDK parsing) */ storeObservation( sdkSessionId: string, project: string, observation: { type: string; title: string; subtitle: string; facts: string[]; narrative: string; concepts: string[]; files_read: string[]; files_modified: string[]; }, promptNumber?: number ): void { const now = new Date(); const nowEpoch = now.getTime(); const stmt = this.db.prepare(` INSERT INTO observations (sdk_session_id, project, type, title, subtitle, facts, narrative, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( sdkSessionId, project, observation.type, observation.title, observation.subtitle, JSON.stringify(observation.facts), observation.narrative, JSON.stringify(observation.concepts), JSON.stringify(observation.files_read), JSON.stringify(observation.files_modified), promptNumber || null, now.toISOString(), nowEpoch ); } /** * Store a session summary (from SDK parsing) */ storeSummary( sdkSessionId: string, project: string, summary: { request: string; investigated: string; learned: string; completed: string; next_steps: string; notes: string | null; }, promptNumber?: number ): void { const now = new Date(); const nowEpoch = now.getTime(); const stmt = this.db.prepare(` INSERT INTO session_summaries (sdk_session_id, project, request, investigated, learned, completed, next_steps, notes, prompt_number, created_at, created_at_epoch) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( sdkSessionId, project, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, summary.notes, promptNumber || null, now.toISOString(), nowEpoch ); } /** * Mark SDK session as completed */ markSessionCompleted(id: number): void { const now = new Date(); const nowEpoch = now.getTime(); const stmt = this.db.prepare(` UPDATE sdk_sessions SET status = 'completed', completed_at = ?, completed_at_epoch = ? WHERE id = ? `); stmt.run(now.toISOString(), nowEpoch, id); } /** * Mark SDK session as failed */ markSessionFailed(id: number): void { const now = new Date(); const nowEpoch = now.getTime(); const stmt = this.db.prepare(` UPDATE sdk_sessions SET status = 'failed', completed_at = ?, completed_at_epoch = ? WHERE id = ? `); stmt.run(now.toISOString(), nowEpoch, id); } /** * Clean up orphaned active sessions (called on worker startup) */ cleanupOrphanedSessions(): number { const now = new Date(); const nowEpoch = now.getTime(); const stmt = this.db.prepare(` UPDATE sdk_sessions SET status = 'failed', completed_at = ?, completed_at_epoch = ? WHERE status = 'active' `); const result = stmt.run(now.toISOString(), nowEpoch); return result.changes; } /** * Close the database connection */ close(): void { this.db.close(); } }