package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type PersonaStore struct{} func NewPersonaStore() *PersonaStore { return &PersonaStore{} } const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id, system_prompt, temperature, max_tokens, thinking_budget, top_p, scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt, created_at, updated_at` func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error { p.ID = store.NewID() now := time.Now().UTC() p.CreatedAt = now p.UpdatedAt = now // Auto-generate handle from name if not set if p.Handle == "" { p.Handle = models.HandleFromName(p.Name) } _, err := DB.ExecContext(ctx, ` INSERT INTO personas (id, name, handle, description, icon, avatar, base_model_id, provider_config_id, system_prompt, temperature, max_tokens, thinking_budget, top_p, scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, p.ID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID, models.NullString(p.ProviderConfigID), p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens), models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP), p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared, p.MemoryEnabled, p.MemoryExtractionPrompt, now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) { row := DB.QueryRowContext(ctx, fmt.Sprintf("SELECT %s FROM personas WHERE id = ?", personaCols), id) p, err := scanPersona(row) if err != nil { return nil, err } // Load grants grants, _ := s.GetGrants(ctx, id) p.Grants = grants return p, nil } func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error { b := NewUpdate("personas") if patch.Name != nil { b.Set("name", *patch.Name) } if patch.Handle != nil { b.Set("handle", *patch.Handle) } if patch.Description != nil { b.Set("description", *patch.Description) } if patch.Icon != nil { b.Set("icon", *patch.Icon) } if patch.Avatar != nil { b.Set("avatar", *patch.Avatar) } if patch.BaseModelID != nil { b.Set("base_model_id", *patch.BaseModelID) } if patch.ProviderConfigID != nil { b.Set("provider_config_id", models.NullString(patch.ProviderConfigID)) } if patch.SystemPrompt != nil { b.Set("system_prompt", *patch.SystemPrompt) } if patch.Temperature != nil { b.Set("temperature", models.NullFloat(patch.Temperature)) } if patch.MaxTokens != nil { b.Set("max_tokens", models.NullInt(patch.MaxTokens)) } if patch.ThinkingBudget != nil { b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget)) } if patch.TopP != nil { b.Set("top_p", models.NullFloat(patch.TopP)) } if patch.IsActive != nil { b.Set("is_active", *patch.IsActive) } if patch.IsShared != nil { b.Set("is_shared", *patch.IsShared) } if patch.MemoryEnabled != nil { b.Set("memory_enabled", *patch.MemoryEnabled) } if patch.MemoryExtractionPrompt != nil { b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt) } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *PersonaStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = ?", id) return err } // ListForUser returns all Personas visible to a user: // global active + team-scoped (for user's teams) + personal + shared + group-granted. func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) { rows, err := DB.QueryContext(ctx, fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = 1 AND ( scope = 'global' OR (scope = 'personal' AND created_by = ?) OR (scope = 'team' AND owner_id IN ( SELECT team_id FROM team_members WHERE user_id = ? )) OR (scope = 'personal' AND is_shared = 1) OR id IN ( SELECT rg.resource_id FROM resource_grants rg WHERE rg.resource_type = 'persona' AND ( rg.grant_scope = 'global' OR (rg.grant_scope = 'groups' AND EXISTS ( SELECT 1 FROM group_members gm JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id WHERE gm.user_id = ? )) ) ) ) ORDER BY scope, name`, personaCols), userID, userID, userID) if err != nil { return nil, err } defer rows.Close() return scanPersonas(rows) } func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) { rows, err := DB.QueryContext(ctx, fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = ? AND is_active = 1 ORDER BY name", personaCols), teamID) if err != nil { return nil, err } defer rows.Close() return scanPersonas(rows) } func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) { rows, err := DB.QueryContext(ctx, fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols)) if err != nil { return nil, err } defer rows.Close() return scanPersonas(rows) } // ── Grants ────────────────────────────────── // SetGrants replaces all grants for a Persona (delete + re-insert). func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = ?", personaID) if err != nil { return err } for _, g := range grants { configJSON := ToJSON(g.Config) _, err = tx.ExecContext(ctx, ` INSERT INTO persona_grants (id, persona_id, grant_type, grant_ref, config) VALUES (?, ?, ?, ?, ?)`, store.NewID(), personaID, g.GrantType, g.GrantRef, configJSON) if err != nil { return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err) } } return tx.Commit() } func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) { rows, err := DB.QueryContext(ctx, "SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = ? ORDER BY grant_type, grant_ref", personaID) if err != nil { return nil, err } defer rows.Close() var result []models.Grant for rows.Next() { var g models.Grant var configJSON []byte err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, st(&g.CreatedAt)) if err != nil { return nil, err } json.Unmarshal(configJSON, &g.Config) result = append(result, g) } return result, rows.Err() } // GetToolGrants returns just the tool names for a Persona. func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) { rows, err := DB.QueryContext(ctx, "SELECT grant_ref FROM persona_grants WHERE persona_id = ? AND grant_type = 'tool' ORDER BY grant_ref", personaID) if err != nil { return nil, err } defer rows.Close() var result []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } result = append(result, name) } return result, rows.Err() } // UserCanAccess checks if a user can see/use a specific Persona. func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, ` SELECT EXISTS( SELECT 1 FROM personas WHERE id = ? AND is_active = 1 AND ( scope = 'global' OR (scope = 'personal' AND created_by = ?) OR (scope = 'team' AND owner_id IN ( SELECT team_id FROM team_members WHERE user_id = ? )) OR (scope = 'personal' AND is_shared = 1) OR id IN ( SELECT rg.resource_id FROM resource_grants rg WHERE rg.resource_type = 'persona' AND rg.resource_id = ? AND ( rg.grant_scope = 'global' OR (rg.grant_scope = 'groups' AND EXISTS ( SELECT 1 FROM group_members gm JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id WHERE gm.user_id = ? )) ) ) ) )`, personaID, userID, userID, personaID, userID).Scan(&exists) return exists, err } // ── Scanners ──────────────────────────────── func scanPersona(row *sql.Row) (*models.Persona, error) { var p models.Persona var providerConfigID, ownerID, handle sql.NullString var temp, topP sql.NullFloat64 var maxTokens, thinkingBudget sql.NullInt64 err := row.Scan( &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar, &p.BaseModelID, &providerConfigID, &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, &p.MemoryEnabled, &p.MemoryExtractionPrompt, st(&p.CreatedAt), st(&p.UpdatedAt), ) if err != nil { return nil, err } if handle.Valid { p.Handle = handle.String } p.ProviderConfigID = NullableStringPtr(providerConfigID) p.OwnerID = NullableStringPtr(ownerID) p.Temperature = NullableFloat64Ptr(temp) p.MaxTokens = NullableIntPtr(maxTokens) p.ThinkingBudget = NullableIntPtr(thinkingBudget) p.TopP = NullableFloat64Ptr(topP) return &p, nil } func scanPersonas(rows *sql.Rows) ([]models.Persona, error) { var result []models.Persona for rows.Next() { var p models.Persona var providerConfigID, ownerID, handle sql.NullString var temp, topP sql.NullFloat64 var maxTokens, thinkingBudget sql.NullInt64 err := rows.Scan( &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar, &p.BaseModelID, &providerConfigID, &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, &p.MemoryEnabled, &p.MemoryExtractionPrompt, st(&p.CreatedAt), st(&p.UpdatedAt), ) if err != nil { return nil, err } if handle.Valid { p.Handle = handle.String } p.ProviderConfigID = NullableStringPtr(providerConfigID) p.OwnerID = NullableStringPtr(ownerID) p.Temperature = NullableFloat64Ptr(temp) p.MaxTokens = NullableIntPtr(maxTokens) p.ThinkingBudget = NullableIntPtr(thinkingBudget) p.TopP = NullableFloat64Ptr(topP) result = append(result, p) } return result, rows.Err() } // ── Persona-KB Bindings (v0.17.0) ─────────── func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = ?", personaID) if err != nil { return err } for _, kbID := range kbIDs { auto := false if autoSearch != nil { auto = autoSearch[kbID] } _, err = tx.ExecContext(ctx, ` INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search) VALUES (?, ?, ?)`, personaID, kbID, auto) if err != nil { return fmt.Errorf("persona KB %s: %w", kbID, err) } } return tx.Commit() } func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) { rows, err := DB.QueryContext(ctx, ` SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at, kb.name AS kb_name, kb.document_count, kb.chunk_count FROM persona_knowledge_bases pkb JOIN knowledge_bases kb ON kb.id = pkb.kb_id WHERE pkb.persona_id = ? ORDER BY kb.name`, personaID) if err != nil { return nil, err } defer rows.Close() var result []models.PersonaKB for rows.Next() { var pkb models.PersonaKB if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, st(&pkb.AddedAt), &pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil { return nil, err } result = append(result, pkb) } if result == nil { result = make([]models.PersonaKB, 0) } return result, rows.Err() } func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) { rows, err := DB.QueryContext(ctx, "SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = ?", personaID) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } if ids == nil { ids = make([]string, 0) } return ids, rows.Err() }