package sqlite import ( "context" "database/sql" "fmt" "strings" "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 (?, ?, ?, ?, ?, ?, ?, ?, ?) 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 = datetime('now') `, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value, m.SourceChannelID, m.Confidence, m.Status) return err } func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error { _, err := DB.ExecContext(ctx, ` UPDATE memories SET key = ?, value = ?, confidence = ?, status = ?, source_channel_id = ?, updated_at = datetime('now') WHERE id = ? `, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID) 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 = ? `, id).Scan( &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, &m.SourceChannelID, &m.Confidence, &m.Status, st(&m.CreatedAt), st(&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 = ?", "owner_id = ?"} args := []interface{}{f.Scope, f.OwnerID} if f.UserID != nil { clauses = append(clauses, "user_id = ?") args = append(args, *f.UserID) } status := f.Status if status == "" { status = models.MemoryStatusActive } clauses = append(clauses, "status = ?") args = append(args, status) if f.Query != "" { // SQLite: LIKE-based keyword search (no tsvector) clauses = append(clauses, "(key || ' ' || value) LIKE ?") args = append(args, "%"+f.Query+"%") } 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 ? `, strings.Join(clauses, " AND ")) rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() return s.scanMemories(rows) } func (s *MemoryStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = ?`, id) return err } func (s *MemoryStore) Archive(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, ` UPDATE memories SET status = 'archived', updated_at = datetime('now') WHERE id = ? `, 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 UNION query across applicable scopes parts := []string{} args := []interface{}{} // 1. User-scope memories userPart := ` 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 = ? AND status = 'active' ` args = append(args, userID) if query != "" { userPart += " AND (key || ' ' || value) LIKE ?" args = append(args, "%"+query+"%") } parts = append(parts, userPart) // 2. Persona-scope memories if personaID != nil && *personaID != "" { personaPart := ` 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 = ? AND status = 'active' ` args = append(args, *personaID) if query != "" { personaPart += " AND (key || ' ' || value) LIKE ?" args = append(args, "%"+query+"%") } parts = append(parts, personaPart) // 3. Persona+user memories puPart := ` 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 = ? AND user_id = ? AND status = 'active' ` args = append(args, *personaID, userID) if query != "" { puPart += " AND (key || ' ' || value) LIKE ?" args = append(args, "%"+query+"%") } parts = append(parts, puPart) } fullQuery := fmt.Sprintf(` SELECT * FROM (%s) ORDER BY CASE scope WHEN 'persona_user' THEN 0 WHEN 'persona' THEN 1 WHEN 'user' THEN 2 END, confidence DESC, updated_at DESC LIMIT ? `, strings.Join(parts, " UNION ALL ")) args = append(args, limit) rows, err := DB.QueryContext(ctx, fullQuery, args...) if err != nil { return nil, err } defer rows.Close() return s.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 = ? AND owner_id = ? AND status = 'active' `, scope, ownerID).Scan(&count) return count, err } func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) { if limit <= 0 || limit > 500 { limit = 100 } rows, err := DB.QueryContext(ctx, ` SELECT id, scope, owner_id, user_id, key, value, source_channel_id, confidence, status, created_at, updated_at FROM memories WHERE status = ? ORDER BY created_at DESC LIMIT ? `, status, limit) if err != nil { return nil, err } defer rows.Close() return s.scanMemories(rows) } // ── Helpers ───────────────────────────────── func (s *MemoryStore) 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, st(&m.CreatedAt), st(&m.UpdatedAt), ); err != nil { return nil, err } memories = append(memories, m) } if memories == nil { memories = []models.Memory{} } return memories, rows.Err() } // ensure compile-time interface satisfaction var _ store.MemoryStore = (*MemoryStore)(nil) // ── CS5b additions (v0.29.0) ──────────────────────────────────────────── func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error { _, err := DB.ExecContext(ctx, `UPDATE memories SET embedding = ? WHERE id = ?`, embedding, id) return err } func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) { var lastID string err := DB.QueryRowContext(ctx, `SELECT last_message_id FROM memory_extraction_log WHERE channel_id = ? AND user_id = ?`, channelID, userID).Scan(&lastID) if err != nil { return "", nil } return lastID, nil } func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error { _, err := DB.ExecContext(ctx, ` INSERT INTO memory_extraction_log (id, channel_id, user_id, last_message_id, memory_count) VALUES (?, ?, ?, ?, ?) ON CONFLICT(channel_id, user_id) DO UPDATE SET last_message_id = excluded.last_message_id, extracted_at = datetime('now'), memory_count = memory_extraction_log.memory_count + excluded.memory_count `, store.NewID(), channelID, userID, lastMessageID, count) return err } // ── v0.30.1 — Memory Compaction ──────────────────────────────────────── func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) { res, err := DB.ExecContext(ctx, ` UPDATE memories SET confidence = confidence * ?, updated_at = datetime('now') WHERE status = 'active' AND updated_at < ? AND confidence > 0.1 `, decayFactor, olderThan) if err != nil { return 0, err } n, _ := res.RowsAffected() return int(n), nil } func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) { res, err := DB.ExecContext(ctx, ` UPDATE memories SET status = 'archived', updated_at = datetime('now') WHERE status = 'active' AND confidence < ? `, belowConfidence) if err != nil { return 0, err } n, _ := res.RowsAffected() return int(n), nil }