// Package sqlite provides SQLite database operations for claude-mnemonic. package sqlite import ( "context" "time" "github.com/lukaszraczylo/claude-mnemonic/pkg/models" ) // PromptCleanupFunc is a callback for when prompts are cleaned up. // Receives the IDs of deleted prompts for downstream cleanup (e.g., vector DB). type PromptCleanupFunc func(ctx context.Context, deletedIDs []int64) // MaxPromptsGlobal is the hard limit of prompts across all projects. const MaxPromptsGlobal = 500 // PromptStore provides user prompt-related database operations. type PromptStore struct { store *Store cleanupFunc PromptCleanupFunc } // NewPromptStore creates a new prompt store. func NewPromptStore(store *Store) *PromptStore { return &PromptStore{store: store} } // SetCleanupFunc sets the callback for when prompts are deleted during cleanup. func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) { s.cleanupFunc = fn } // SaveUserPromptWithMatches saves a user prompt with matched observation count. // Uses INSERT OR IGNORE to be idempotent - duplicate (session, prompt_number) pairs are silently ignored. // This prevents duplicate prompts when the user-prompt hook fires multiple times. func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) { now := time.Now() // Use INSERT OR IGNORE for idempotency - if (claude_session_id, prompt_number) already exists, // the insert is silently ignored. This handles concurrent/duplicate hook invocations. const query = ` INSERT OR IGNORE INTO user_prompts (claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch) VALUES (?, ?, ?, ?, ?, ?) ` result, err := s.store.ExecContext(ctx, query, claudeSessionID, promptNumber, promptText, matchedObservations, now.Format(time.RFC3339), now.UnixMilli(), ) if err != nil { return 0, err } id, _ := result.LastInsertId() // If id is 0, the insert was ignored (duplicate) - fetch the existing ID if id == 0 { const selectQuery = `SELECT id FROM user_prompts WHERE claude_session_id = ? AND prompt_number = ?` row := s.store.QueryRowContext(ctx, selectQuery, claudeSessionID, promptNumber) if err := row.Scan(&id); err != nil { return 0, err } // Return existing ID without triggering cleanup (already handled when first inserted) return id, nil } // Cleanup old prompts beyond the global limit (async to not block handler) go func() { cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() deletedIDs, _ := s.CleanupOldPrompts(cleanupCtx) if len(deletedIDs) > 0 && s.cleanupFunc != nil { s.cleanupFunc(cleanupCtx, deletedIDs) } }() return id, nil } // CleanupOldPrompts deletes prompts beyond the global limit. // Keeps the most recent MaxPromptsGlobal prompts. // Returns the IDs of deleted prompts for downstream cleanup (e.g., vector DB). func (s *PromptStore) CleanupOldPrompts(ctx context.Context) ([]int64, error) { // First, find IDs that will be deleted const selectQuery = ` SELECT id FROM user_prompts WHERE id NOT IN ( SELECT id FROM user_prompts ORDER BY created_at_epoch DESC LIMIT ? ) ` rows, err := s.store.QueryContext(ctx, selectQuery, MaxPromptsGlobal) if err != nil { return nil, err } defer rows.Close() var toDelete []int64 for rows.Next() { var id int64 if scanErr := rows.Scan(&id); scanErr != nil { return nil, scanErr } toDelete = append(toDelete, id) } if rowsErr := rows.Err(); rowsErr != nil { return nil, rowsErr } if len(toDelete) == 0 { return nil, nil } // Delete the prompts const deleteQuery = ` DELETE FROM user_prompts WHERE id NOT IN ( SELECT id FROM user_prompts ORDER BY created_at_epoch DESC LIMIT ? ) ` _, err = s.store.ExecContext(ctx, deleteQuery, MaxPromptsGlobal) if err != nil { return nil, err } return toDelete, nil } // GetPromptsByIDs retrieves user prompts by a list of IDs. func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.UserPromptWithSession, error) { if len(ids) == 0 { return nil, nil } // Build query with placeholders // #nosec G202 -- query uses parameterized placeholders, not user input query := ` SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text, COALESCE(up.matched_observations, 0) as matched_observations, up.created_at, up.created_at_epoch, COALESCE(s.project, '') as project, COALESCE(s.sdk_session_id, '') as sdk_session_id FROM user_prompts up LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id WHERE up.id IN (?` + repeatPlaceholders(len(ids)-1) + `) ORDER BY up.created_at_epoch ` if orderBy == "date_asc" { query += "ASC" } else { query += "DESC" } if limit > 0 { query += " LIMIT ?" } args := int64SliceToInterface(ids) if limit > 0 { args = append(args, limit) } rows, err := s.store.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() return scanPromptWithSessionRows(rows) } // GetAllRecentUserPrompts retrieves recent user prompts across all sessions. func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([]*models.UserPromptWithSession, error) { const query = ` SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text, COALESCE(up.matched_observations, 0) as matched_observations, up.created_at, up.created_at_epoch, COALESCE(s.project, '') as project, COALESCE(s.sdk_session_id, '') as sdk_session_id FROM user_prompts up LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id ORDER BY up.created_at_epoch DESC LIMIT ? ` rows, err := s.store.QueryContext(ctx, query, limit) if err != nil { return nil, err } defer rows.Close() return scanPromptWithSessionRows(rows) } // GetAllPrompts retrieves all user prompts (for vector rebuild). func (s *PromptStore) GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error) { const query = ` SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text, COALESCE(up.matched_observations, 0) as matched_observations, up.created_at, up.created_at_epoch, COALESCE(s.project, '') as project, COALESCE(s.sdk_session_id, '') as sdk_session_id FROM user_prompts up LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id ORDER BY up.id ` rows, err := s.store.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() return scanPromptWithSessionRows(rows) } // FindRecentPromptByText finds a prompt with the same text for a session within the last few seconds. // This is used to detect duplicate hook invocations. // Returns (promptID, promptNumber, found). func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool) { // Look for an existing prompt with the same text within the time window // This catches duplicate hook invocations that happen in quick succession const query = ` SELECT id, prompt_number FROM user_prompts WHERE claude_session_id = ? AND prompt_text = ? AND created_at_epoch > ? ORDER BY created_at_epoch DESC LIMIT 1 ` cutoff := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli() var id int64 var promptNumber int err := s.store.QueryRowContext(ctx, query, claudeSessionID, promptText, cutoff).Scan(&id, &promptNumber) if err != nil { return 0, 0, false } return id, promptNumber, true } // GetRecentUserPromptsByProject retrieves recent user prompts for a specific project. func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) { const query = ` SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text, COALESCE(up.matched_observations, 0) as matched_observations, up.created_at, up.created_at_epoch, COALESCE(s.project, '') as project, COALESCE(s.sdk_session_id, '') as sdk_session_id FROM user_prompts up LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id WHERE s.project = ? ORDER BY up.created_at_epoch DESC LIMIT ? ` rows, err := s.store.QueryContext(ctx, query, project, limit) if err != nil { return nil, err } defer rows.Close() return scanPromptWithSessionRows(rows) }