package services import ( "bufio" "encoding/json" "os" "path/filepath" "sort" "strings" "time" "sproutclaw-web/internal/db" "sproutclaw-web/internal/models" ) // jsonlLine is a minimal parse of one line in a session JSONL file. type jsonlLine struct { Type string `json:"type"` // session header ID string `json:"id"` Created string `json:"created"` // message Role string `json:"role"` Content any `json:"content"` // session_info Name string `json:"name"` } // BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries. func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) { entries, err := os.ReadDir(sessionsDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } pinned, _ := database.GetPinnedSessions() pinnedSet := map[string]struct{}{} for _, p := range pinned { pinnedSet[p] = struct{}{} } var summaries []models.SessionSummary for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { continue } path := filepath.Join(sessionsDir, e.Name()) sum, err := readSessionSummary(path) if err != nil { continue } _, sum.Pinned = pinnedSet[path] sum.Path = path summaries = append(summaries, sum) } // Sort: pinned first, then by modified desc sort.SliceStable(summaries, func(i, j int) bool { pi, pj := summaries[i].Pinned, summaries[j].Pinned if pi != pj { return pi } return summaries[i].Modified > summaries[j].Modified }) return summaries, nil } func readSessionSummary(path string) (models.SessionSummary, error) { f, err := os.Open(path) if err != nil { return models.SessionSummary{}, err } defer f.Close() info, _ := f.Stat() modified := "" if info != nil { modified = info.ModTime().UTC().Format(time.RFC3339) } var sum models.SessionSummary sum.Modified = modified scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024) msgCount := 0 for scanner.Scan() { var line jsonlLine if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { continue } switch line.Type { case "session": sum.Created = line.Created case "session_info": if line.Name != "" { sum.Name = line.Name } case "message": msgCount++ if sum.FirstMessage == "" && line.Role == "user" { sum.FirstMessage = extractTextContent(line.Content) } } } sum.MessageCount = msgCount return sum, nil } func extractTextContent(content any) string { switch v := content.(type) { case string: return truncate(v, 120) case []any: for _, item := range v { if m, ok := item.(map[string]any); ok { if m["type"] == "text" { if t, ok := m["text"].(string); ok { return truncate(t, 120) } } } } } return "" } func truncate(s string, n int) string { if len([]rune(s)) <= n { return s } runes := []rune(s) return string(runes[:n]) + "…" } // ReadSessionMessages reads all lines from a JSONL session file. func ReadSessionMessages(path string) ([]json.RawMessage, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var lines []json.RawMessage scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024) for scanner.Scan() { raw := append([]byte(nil), scanner.Bytes()...) lines = append(lines, raw) } return lines, nil } // AppendSessionName appends a session_info rename record to the JSONL file. func AppendSessionName(path, name string) error { f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() record := map[string]string{"type": "session_info", "name": name} b, err := json.Marshal(record) if err != nil { return err } _, err = f.Write(append(b, '\n')) return err }