// Package gorm provides GORM-based database operations for claude-mnemonic. package gorm import ( "context" "database/sql" "sync" "time" "github.com/rs/zerolog/log" "gorm.io/gorm" "gorm.io/gorm/clause" "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 using GORM. type PromptStore struct { db *gorm.DB cleanupFunc PromptCleanupFunc cleanupMu sync.RWMutex } // NewPromptStore creates a new prompt store. func NewPromptStore(store *Store, cleanupFunc PromptCleanupFunc) *PromptStore { return &PromptStore{ db: store.DB, cleanupFunc: cleanupFunc, } } // SetCleanupFunc sets the callback for when prompts are deleted during cleanup. func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) { s.cleanupMu.Lock() defer s.cleanupMu.Unlock() 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() prompt := &UserPrompt{ ClaudeSessionID: claudeSessionID, PromptNumber: promptNumber, PromptText: promptText, MatchedObservations: matchedObservations, CreatedAt: now.Format(time.RFC3339), CreatedAtEpoch: now.UnixMilli(), } // INSERT OR IGNORE using OnConflict result := s.db.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "claude_session_id"}, {Name: "prompt_number"}}, DoNothing: true, }). Create(prompt) if result.Error != nil { return 0, result.Error } // If RowsAffected is 0, the insert was ignored (duplicate) - fetch the existing ID if result.RowsAffected == 0 { var existing UserPrompt err := s.db.Where("claude_session_id = ? AND prompt_number = ?", claudeSessionID, promptNumber). First(&existing).Error if err != nil { return 0, err } // Return existing ID without triggering cleanup (already handled when first inserted) return existing.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() if deletedIDs, err := s.CleanupOldPrompts(cleanupCtx); err != nil { log.Warn().Err(err).Msg("Background prompt cleanup failed") } else if len(deletedIDs) > 0 { s.cleanupMu.RLock() fn := s.cleanupFunc s.cleanupMu.RUnlock() if fn != nil { fn(cleanupCtx, deletedIDs) } } }() return prompt.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) { // Use a transaction to prevent TOCTOU race condition var idsToDelete []int64 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Find IDs to keep (most recent MaxPromptsGlobal) var idsToKeep []int64 err := tx.Model(&UserPrompt{}). Order("created_at_epoch DESC"). Limit(MaxPromptsGlobal). Pluck("id", &idsToKeep).Error if err != nil { return err } if len(idsToKeep) == 0 { return nil } // Find IDs to delete (all IDs not in the keep list) // This happens in the same transaction to prevent race conditions err = tx.Model(&UserPrompt{}). Where("id NOT IN ?", idsToKeep). Pluck("id", &idsToDelete).Error if err != nil { return err } if len(idsToDelete) == 0 { return nil } // Delete the prompts return tx.Delete(&UserPrompt{}, idsToDelete).Error }) if err != nil { return nil, err } return idsToDelete, 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 } var results []struct { Project sql.NullString `gorm:"column:project"` SDKSessionID sql.NullString `gorm:"column:sdk_session_id"` UserPrompt } query := s.db.WithContext(ctx). Table("user_prompts up"). 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"). Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id"). Where("up.id IN ?", ids) // Apply ordering switch orderBy { case "date_asc": query = query.Order("up.created_at_epoch ASC") case "date_desc", "default", "": query = query.Order("up.created_at_epoch DESC") } // Apply limit if limit > 0 { query = query.Limit(limit) } err := query.Scan(&results).Error if err != nil { return nil, err } return toModelUserPromptsWithSession(results), nil } // GetAllRecentUserPrompts retrieves recent user prompts across all projects. func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([]*models.UserPromptWithSession, error) { var results []struct { Project sql.NullString `gorm:"column:project"` SDKSessionID sql.NullString `gorm:"column:sdk_session_id"` UserPrompt } query := s.db.WithContext(ctx). Table("user_prompts up"). 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"). Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id"). Order("up.created_at_epoch DESC"). Limit(limit) err := query.Scan(&results).Error if err != nil { return nil, err } return toModelUserPromptsWithSession(results), nil } // GetAllPrompts retrieves all user prompts (for vector rebuild). func (s *PromptStore) GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error) { var results []struct { Project sql.NullString `gorm:"column:project"` SDKSessionID sql.NullString `gorm:"column:sdk_session_id"` UserPrompt } query := s.db.WithContext(ctx). Table("user_prompts up"). 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"). Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id"). Order("up.id") err := query.Scan(&results).Error if err != nil { return nil, err } return toModelUserPromptsWithSession(results), nil } // FindRecentPromptByText finds a recent prompt by exact text match within a time window. // Returns (promptID, promptNumber, found). func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool) { cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli() var prompt UserPrompt err := s.db.WithContext(ctx). Where("claude_session_id = ? AND prompt_text = ? AND created_at_epoch >= ?", claudeSessionID, promptText, cutoffEpoch). Order("created_at_epoch DESC"). First(&prompt).Error if err != nil { return 0, 0, false } return prompt.ID, prompt.PromptNumber, true } // FindRecentPromptByTextGlobal finds a recent prompt by exact text match within a time window // across ALL sessions (not scoped to a single claude session ID). // This catches duplicates when the same prompt arrives from different session IDs, // e.g. when the SDK processor's callClaudeCLI subprocess fires with a different session ID. // Returns (promptID, promptNumber, found). func (s *PromptStore) FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) { cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli() var prompt UserPrompt err := s.db.WithContext(ctx). Where("prompt_text = ? AND created_at_epoch >= ?", promptText, cutoffEpoch). Order("created_at_epoch DESC"). First(&prompt).Error if err != nil { return 0, 0, false } return prompt.ID, prompt.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) { var results []struct { Project sql.NullString `gorm:"column:project"` SDKSessionID sql.NullString `gorm:"column:sdk_session_id"` UserPrompt } query := s.db.WithContext(ctx). Table("user_prompts up"). 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"). Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id"). Where("s.project = ?", project). Order("up.created_at_epoch DESC"). Limit(limit) err := query.Scan(&results).Error if err != nil { return nil, err } return toModelUserPromptsWithSession(results), nil } // toModelUserPromptsWithSession converts query results to pkg/models.UserPromptWithSession. func toModelUserPromptsWithSession(results []struct { Project sql.NullString `gorm:"column:project"` SDKSessionID sql.NullString `gorm:"column:sdk_session_id"` UserPrompt }) []*models.UserPromptWithSession { prompts := make([]*models.UserPromptWithSession, len(results)) for i, r := range results { project := "" if r.Project.Valid { project = r.Project.String } sdkSessionID := "" if r.SDKSessionID.Valid { sdkSessionID = r.SDKSessionID.String } prompts[i] = &models.UserPromptWithSession{ UserPrompt: models.UserPrompt{ ID: r.ID, ClaudeSessionID: r.ClaudeSessionID, PromptNumber: r.PromptNumber, PromptText: r.PromptText, MatchedObservations: r.MatchedObservations, CreatedAt: r.CreatedAt, CreatedAtEpoch: r.CreatedAtEpoch, }, Project: project, SDKSessionID: sdkSessionID, } } return prompts }