mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
5104477631
Open the /resume session picker faster by extracting per-file metadata across a GOMAXPROCS-sized worker pool instead of sequentially. Each extractSessionInfo call is I/O + JSON-parse bound and independent, so wall time drops roughly proportionally to core count — meaningful for users with many sessions, where ListSessions + ListAllSessions ran back-to-back on the UI goroutine before the picker rendered.
296 lines
7.4 KiB
Go
296 lines
7.4 KiB
Go
package session
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SessionInfo contains metadata about a discovered session, used for listing
|
|
// and session picker display.
|
|
type SessionInfo struct {
|
|
// Path is the absolute path to the JSONL session file.
|
|
Path string
|
|
|
|
// ID is the session UUID from the header.
|
|
ID string
|
|
|
|
// Cwd is the working directory the session was created in.
|
|
Cwd string
|
|
|
|
// Name is the user-defined display name (from session_info entries).
|
|
Name string
|
|
|
|
// ParentSessionPath is the parent session path if this session was forked.
|
|
ParentSessionPath string
|
|
|
|
// ParentSessionID is the UUID of the parent session (for subagent sessions).
|
|
ParentSessionID string
|
|
|
|
// SubagentTask is the original task prompt (for subagent sessions).
|
|
SubagentTask string
|
|
|
|
// Created is when the session was first created.
|
|
Created time.Time
|
|
|
|
// Modified is the timestamp of the last activity (latest message).
|
|
Modified time.Time
|
|
|
|
// MessageCount is the number of message entries in the session.
|
|
MessageCount int
|
|
|
|
// FirstMessage is a preview of the first user message.
|
|
FirstMessage string
|
|
}
|
|
|
|
// ListSessions finds all sessions for a given working directory, sorted by
|
|
// modification time (newest first).
|
|
func ListSessions(cwd string) ([]SessionInfo, error) {
|
|
sessionDir := DefaultSessionDir(cwd)
|
|
return listSessionsInDir(sessionDir)
|
|
}
|
|
|
|
// ListAllSessions finds all sessions across all working directories, sorted
|
|
// by modification time (newest first).
|
|
func ListAllSessions() ([]SessionInfo, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find home directory: %w", err)
|
|
}
|
|
|
|
sessionsRoot := filepath.Join(home, ".kit", "sessions")
|
|
if _, err := os.Stat(sessionsRoot); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
|
|
var allSessions []SessionInfo
|
|
|
|
dirs, err := os.ReadDir(sessionsRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read sessions directory: %w", err)
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
if !dir.IsDir() {
|
|
continue
|
|
}
|
|
dirPath := filepath.Join(sessionsRoot, dir.Name())
|
|
sessions, err := listSessionsInDir(dirPath)
|
|
if err != nil {
|
|
continue // skip unreadable directories
|
|
}
|
|
allSessions = append(allSessions, sessions...)
|
|
}
|
|
|
|
// Sort by modification time, newest first.
|
|
sort.Slice(allSessions, func(i, j int) bool {
|
|
return allSessions[i].Modified.After(allSessions[j].Modified)
|
|
})
|
|
|
|
return allSessions, nil
|
|
}
|
|
|
|
// listSessionsInDir reads all .jsonl files in a directory and extracts session info.
|
|
// Empty sessions (no messages) are automatically cleaned up and not returned.
|
|
//
|
|
// Per-file extraction is parallelized across a small worker pool because each
|
|
// file requires a full JSONL scan to compute MessageCount and FirstMessage —
|
|
// for users with many sessions this is the dominant cost of opening the
|
|
// session picker.
|
|
func listSessionsInDir(dir string) ([]SessionInfo, error) {
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read directory %s: %w", dir, err)
|
|
}
|
|
|
|
// Collect candidate paths first so we can parallelize the heavy work.
|
|
paths := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
|
continue
|
|
}
|
|
paths = append(paths, filepath.Join(dir, entry.Name()))
|
|
}
|
|
|
|
results := make([]*SessionInfo, len(paths))
|
|
|
|
// Worker pool sized to GOMAXPROCS, capped to avoid thrashing for tiny lists.
|
|
workers := max(min(runtime.GOMAXPROCS(0), len(paths)), 1)
|
|
|
|
var wg sync.WaitGroup
|
|
jobs := make(chan int, len(paths))
|
|
for range workers {
|
|
wg.Go(func() {
|
|
for i := range jobs {
|
|
info, err := extractSessionInfo(paths[i])
|
|
if err != nil {
|
|
continue // skip malformed session files
|
|
}
|
|
results[i] = info
|
|
}
|
|
})
|
|
}
|
|
for i := range paths {
|
|
jobs <- i
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
|
|
sessions := make([]SessionInfo, 0, len(results))
|
|
for i, info := range results {
|
|
if info == nil {
|
|
continue
|
|
}
|
|
// Clean up and skip empty sessions (no messages).
|
|
if info.MessageCount == 0 {
|
|
_ = os.Remove(paths[i])
|
|
continue
|
|
}
|
|
sessions = append(sessions, *info)
|
|
}
|
|
|
|
// Sort by modification time, newest first.
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].Modified.After(sessions[j].Modified)
|
|
})
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
// extractSessionInfo reads a JSONL session file and extracts metadata.
|
|
// It only reads enough of the file to get the header and scan for messages.
|
|
func extractSessionInfo(path string) (*SessionInfo, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
info := &SessionInfo{
|
|
Path: path,
|
|
}
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
// Increase scanner buffer for large lines.
|
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
lineNum := 0
|
|
var lastTimestamp time.Time
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
lineNum++
|
|
|
|
if lineNum == 1 {
|
|
// Parse header.
|
|
var h SessionHeader
|
|
if err := json.Unmarshal([]byte(line), &h); err != nil {
|
|
return nil, fmt.Errorf("failed to parse header: %w", err)
|
|
}
|
|
if h.Type != EntryTypeSession {
|
|
return nil, fmt.Errorf("first line is not a session header")
|
|
}
|
|
info.ID = h.ID
|
|
info.Cwd = h.Cwd
|
|
info.Created = h.Timestamp
|
|
info.Modified = h.Timestamp
|
|
info.ParentSessionPath = h.ParentSession
|
|
info.ParentSessionID = h.ParentSessionID
|
|
info.SubagentTask = h.SubagentTask
|
|
continue
|
|
}
|
|
|
|
// For subsequent lines, only parse enough to get type and timestamp.
|
|
var env struct {
|
|
Type EntryType `json:"type"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Role string `json:"role,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &env); err != nil {
|
|
continue
|
|
}
|
|
|
|
if !env.Timestamp.IsZero() && env.Timestamp.After(lastTimestamp) {
|
|
lastTimestamp = env.Timestamp
|
|
}
|
|
|
|
switch env.Type {
|
|
case EntryTypeMessage:
|
|
info.MessageCount++
|
|
// Capture first user message as preview.
|
|
if env.Role == "user" && info.FirstMessage == "" {
|
|
var msgEntry struct {
|
|
Parts json.RawMessage `json:"parts"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &msgEntry); err == nil {
|
|
info.FirstMessage = extractTextPreview(msgEntry.Parts)
|
|
}
|
|
}
|
|
case EntryTypeSessionInfo:
|
|
if env.Name != "" {
|
|
info.Name = env.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
if !lastTimestamp.IsZero() {
|
|
info.Modified = lastTimestamp
|
|
}
|
|
|
|
// Fall back to file modification time if no timestamps found.
|
|
if info.Modified.IsZero() {
|
|
fi, err := os.Stat(path)
|
|
if err == nil {
|
|
info.Modified = fi.ModTime()
|
|
}
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// extractTextPreview extracts a short text preview from type-tagged parts JSON.
|
|
func extractTextPreview(partsJSON json.RawMessage) string {
|
|
var parts []struct {
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(partsJSON, &parts); err != nil {
|
|
return ""
|
|
}
|
|
|
|
for _, p := range parts {
|
|
if p.Type == "text" {
|
|
var text struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(p.Data, &text); err == nil && text.Text != "" {
|
|
preview := text.Text
|
|
if len(preview) > 100 {
|
|
preview = preview[:100] + "..."
|
|
}
|
|
return preview
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DeleteSession removes a session file from disk.
|
|
func DeleteSession(path string) error {
|
|
return os.Remove(path)
|
|
}
|