package history import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // This file owns the per-session role binding (DevDocs/design/22 多角色并存 + // 按会话选角 §2/§4.1). A session's role is the single source of truth for "which // role answers in this conversation": the loop resolves the system prompt, the // memory scope, the emotion machine and the thinking budget from it. The value // is display/scope metadata only — identity and permissions are still decided by // the channel actor metadata (project red line). // // '' means "inherit the current global default role", so it is both the column // default and a legal explicit value: setting it clears an explicit binding and // lets the session follow a default-role change again (design/22 §7-6). // NewSubSessionFor creates a sub session bound to personaID and returns its id // (MainSessionID:uuid). A blank personaID binds no explicit role, so the session // inherits the global default — the same behavior as NewSubSession. ownerID is // accepted for symmetry with NewSubSession (the Web owner is identified by JWT, // not stored per session); it is logged at debug and not persisted. func (s *Store) NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) { id, err := newID() if err != nil { return "", fmt.Errorf("history: new sub id: %w", err) } sessionID := MainSessionID + ":" + id now := time.Now().UnixMilli() if _, err := s.db.ExecContext(ctx, `INSERT INTO chat_sessions (id, type, title, persona_id, created_at) VALUES (?, ?, ?, ?, ?)`, sessionID, SessionSub, "", strings.TrimSpace(personaID), now); err != nil { return "", fmt.Errorf("history: insert sub session: %w", err) } if s.log != nil { s.log.Debug("sub session created", "session_id", sessionID, "owner_id", ownerID, "persona_id", strings.TrimSpace(personaID)) } return sessionID, nil } // SetSessionPersona binds (or clears, when personaID is blank) a session's role. // Both the fixed main session and sub sessions may be retargeted: the binding is // scope metadata, not identity. An unknown id returns ErrSessionNotFound. It // never touches the conversation: the stored messages keep the role that spoke // them (design/22 §7-5), and the next turn simply resolves the new role. func (s *Store) SetSessionPersona(ctx context.Context, sessionID, personaID string) error { res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET persona_id = ? WHERE id = ?`, strings.TrimSpace(personaID), sessionID) if err != nil { return fmt.Errorf("history: set session persona %s: %w", sessionID, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("history: set session persona %s: %w", sessionID, err) } if n == 0 { return fmt.Errorf("history: set session persona %s: %w", sessionID, ErrSessionNotFound) } return nil } // SessionPersona returns the role bound to sessionID, or "" when the session // inherits the global default. An unknown id returns ErrSessionNotFound so a // caller can tell "no explicit role" (a real session, empty binding) from "no // such session"; a caller that only needs the effective role treats both as // "use the default". func (s *Store) SessionPersona(ctx context.Context, sessionID string) (string, error) { var personaID string err := s.db.QueryRowContext(ctx, `SELECT persona_id FROM chat_sessions WHERE id = ?`, sessionID).Scan(&personaID) switch { case errors.Is(err, sql.ErrNoRows): return "", fmt.Errorf("history: session persona %s: %w", sessionID, ErrSessionNotFound) case err != nil: return "", fmt.Errorf("history: session persona %s: %w", sessionID, err) } return personaID, nil }