package postgres import ( "context" "database/sql" "fmt" "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type MemoryStore struct{} func NewMemoryStore() *MemoryStore { return &MemoryStore{} } func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error { if m.ID == "" { m.ID = store.NewID() } if m.Status == "" { m.Status = models.MemoryStatusActive } if m.Confidence == 0 { m.Confidence = 1.0 } _, err := DB.ExecContext(ctx, ` INSERT INTO memories (id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key) DO UPDATE SET value = EXCLUDED.value, confidence = EXCLUDED.confidence, status = EXCLUDED.status, source_channel_id = EXCLUDED.source_channel_id, updated_at = now() `, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value, m.SourceChannelID, m.Confidence, m.Status) return err } func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) { m := &models.Memory{} err := DB.QueryRowContext(ctx, ` SELECT id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status, created_at, updated_at FROM memories WHERE id = $1 `, id).Scan( &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, &m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("memory not found: %s", id) } return m, err } func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) { clauses := []string{"scope = $1", "owner_id = $2"} args := []interface{}{f.Scope, f.OwnerID} idx := 3 if f.UserID != nil { clauses = append(clauses, fmt.Sprintf("user_id = $%d", idx)) args = append(args, *f.UserID) idx++ } status := f.Status if status == "" { status = models.MemoryStatusActive } clauses = append(clauses, fmt.Sprintf("status = $%d", idx)) args = append(args, status) idx++ if f.Query != "" { clauses = append(clauses, fmt.Sprintf( "to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)) args = append(args, f.Query) idx++ } limit := f.Limit if limit <= 0 || limit > 200 { limit = 50 } args = append(args, limit) query := fmt.Sprintf(` SELECT id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status, created_at, updated_at FROM memories WHERE %s ORDER BY confidence DESC, updated_at DESC LIMIT $%d `, strings.Join(clauses, " AND "), idx) rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() return scanMemories(rows) } func (s *MemoryStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = $1`, id) return err } func (s *MemoryStore) Archive(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, ` UPDATE memories SET status = 'archived', updated_at = now() WHERE id = $1 `, id) return err } func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) { if limit <= 0 || limit > 100 { limit = 20 } // Build a UNION query across applicable scopes. // Always include user-scope memories for this user. // If a persona is active, also include persona and persona+user memories. parts := []string{} args := []interface{}{} idx := 1 // 1. User-scope memories userPart := fmt.Sprintf(` SELECT id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status, created_at, updated_at FROM memories WHERE scope = 'user' AND owner_id = $%d AND status = 'active' `, idx) args = append(args, userID) idx++ if query != "" { userPart += fmt.Sprintf( " AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx) args = append(args, query) idx++ } parts = append(parts, userPart) // 2. Persona-scope memories (if persona active) if personaID != nil && *personaID != "" { personaPart := fmt.Sprintf(` SELECT id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status, created_at, updated_at FROM memories WHERE scope = 'persona' AND owner_id = $%d AND status = 'active' `, idx) args = append(args, *personaID) idx++ if query != "" { personaPart += fmt.Sprintf( " AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx) args = append(args, query) idx++ } parts = append(parts, personaPart) // 3. Persona+user memories puPart := fmt.Sprintf(` SELECT id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status, created_at, updated_at FROM memories WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active' `, idx, idx+1) args = append(args, *personaID, userID) idx += 2 if query != "" { puPart += fmt.Sprintf( " AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx) args = append(args, query) idx++ } parts = append(parts, puPart) } // Scope priority: persona_user > persona > user (CASE ordering) fullQuery := fmt.Sprintf(` SELECT * FROM (%s) AS combined ORDER BY CASE scope WHEN 'persona_user' THEN 0 WHEN 'persona' THEN 1 WHEN 'user' THEN 2 END, confidence DESC, updated_at DESC LIMIT $%d `, strings.Join(parts, " UNION ALL "), idx) args = append(args, limit) rows, err := DB.QueryContext(ctx, fullQuery, args...) if err != nil { return nil, err } defer rows.Close() return scanMemories(rows) } func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) { var count int err := DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM memories WHERE scope = $1 AND owner_id = $2 AND status = 'active' `, scope, ownerID).Scan(&count) return count, err } // ── Helpers ───────────────────────────────── func scanMemories(rows *sql.Rows) ([]models.Memory, error) { var memories []models.Memory for rows.Next() { var m models.Memory if err := rows.Scan( &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, &m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt, ); err != nil { return nil, err } memories = append(memories, m) } if memories == nil { memories = []models.Memory{} } return memories, rows.Err() } // scanMemory scans a single memory row — unused currently but available // for future single-row queries. func scanMemory(row *sql.Row) (*models.Memory, error) { m := &models.Memory{} err := row.Scan( &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, &m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } return m, err } // ensure compile-time interface satisfaction var _ interface { Upsert(ctx context.Context, m *models.Memory) error GetByID(ctx context.Context, id string) (*models.Memory, error) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) Delete(ctx context.Context, id string) error Archive(ctx context.Context, id string) error Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) } = (*MemoryStore)(nil) // ensure unused imports don't cause errors var _ = time.Now