mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
4c1bda9541
Empty sessions (no messages) are now automatically cleaned up: 1. On shutdown: When kit exits cleanly, if the current session has no messages, the session file is deleted. 2. On /resume: When listing sessions for the resume picker, any empty session files are deleted and not shown in the list. This prevents accumulation of orphaned empty session files when users start sessions but don't send any messages. Changes: - internal/session/tree_manager.go: add IsEmpty() helper - internal/app/app.go: delete empty session on Close() - internal/session/store.go: filter and delete empty sessions in listSessionsInDir()
262 lines
6.5 KiB
Go
262 lines
6.5 KiB
Go
package session
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"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.
|
|
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)
|
|
}
|
|
|
|
var sessions []SessionInfo
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(dir, entry.Name())
|
|
info, err := extractSessionInfo(path)
|
|
if err != nil {
|
|
continue // skip malformed session files
|
|
}
|
|
// Clean up and skip empty sessions (no messages)
|
|
if info.MessageCount == 0 {
|
|
_ = os.Remove(path)
|
|
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)
|
|
}
|