package session import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "agentu/internal/llm" ) // Session represents a persisted conversation. type Session struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Provider string `json:"provider"` Model string `json:"model"` Messages []llm.Message `json:"messages"` LastUsage *llm.Usage `json:"last_usage,omitempty"` } // SessionMeta is a lightweight summary for listing. type SessionMeta struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Provider string `json:"provider"` Model string `json:"model"` MessageCount int `json:"message_count"` } // Store manages session files under a directory. type Store struct { dir string } // NewStore creates a store, ensuring the directory exists. func NewStore(dir string) (*Store, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create session dir: %w", err) } return &Store{dir: dir}, nil } // Save writes a session to disk. func (s *Store) Save(sess *Session) error { if sess.ID == "" { return fmt.Errorf("session ID is required") } sess.UpdatedAt = time.Now().UTC() if sess.CreatedAt.IsZero() { sess.CreatedAt = sess.UpdatedAt } data, err := json.MarshalIndent(sess, "", " ") if err != nil { return fmt.Errorf("marshal session: %w", err) } path := s.filePath(sess.ID) if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("write session: %w", err) } return nil } // Load reads a session by ID. func (s *Store) Load(id string) (*Session, error) { path := s.filePath(id) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("session %q not found", id) } return nil, fmt.Errorf("read session: %w", err) } var sess Session if err := json.Unmarshal(data, &sess); err != nil { return nil, fmt.Errorf("parse session: %w", err) } return &sess, nil } // List returns metadata for all sessions, sorted by updated_at descending. func (s *Store) List() ([]SessionMeta, error) { entries, err := os.ReadDir(s.dir) if err != nil { return nil, fmt.Errorf("read session dir: %w", err) } var metas []SessionMeta for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } path := filepath.Join(s.dir, entry.Name()) data, err := os.ReadFile(path) if err != nil { continue } var sess Session if err := json.Unmarshal(data, &sess); err != nil { continue } metas = append(metas, SessionMeta{ ID: sess.ID, Name: sess.Name, CreatedAt: sess.CreatedAt, UpdatedAt: sess.UpdatedAt, Provider: sess.Provider, Model: sess.Model, MessageCount: len(sess.Messages), }) } sort.Slice(metas, func(i, j int) bool { return metas[i].UpdatedAt.After(metas[j].UpdatedAt) }) return metas, nil } // Delete removes a session file. func (s *Store) Delete(id string) error { path := s.filePath(id) if err := os.Remove(path); err != nil { if os.IsNotExist(err) { return fmt.Errorf("session %q not found", id) } return fmt.Errorf("delete session: %w", err) } return nil } func (s *Store) filePath(id string) string { return filepath.Join(s.dir, id+".json") } // GenerateID creates a random 8-character hex session ID. func GenerateID() string { b := make([]byte, 4) _, _ = rand.Read(b) return hex.EncodeToString(b) } // DefaultName derives a session name from the first user message. func DefaultName(messages []llm.Message) string { for _, msg := range messages { if msg.Role != llm.RoleUser { continue } text := strings.TrimSpace(msg.Content) if text == "" { continue } // Take first line, truncate at 40 chars at word boundary if idx := strings.IndexByte(text, '\n'); idx >= 0 { text = text[:idx] } text = strings.TrimSpace(text) if len(text) > 40 { text = text[:40] if idx := strings.LastIndexByte(text, ' '); idx > 20 { text = text[:idx] } text += "..." } return text } return "" }