package session import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "time" "github.com/mark3labs/kit/internal/message" ) // EntryType identifies the kind of entry stored in a JSONL session file. // Sessions are append-only JSONL files where each line is a typed entry // linked by id/parent_id to form a tree structure. type EntryType string const ( EntryTypeSession EntryType = "session" EntryTypeMessage EntryType = "message" EntryTypeModelChange EntryType = "model_change" EntryTypeBranchSummary EntryType = "branch_summary" EntryTypeLabel EntryType = "label" EntryTypeSessionInfo EntryType = "session_info" EntryTypeExtensionData EntryType = "extension_data" EntryTypeCompaction EntryType = "compaction" EntryTypeSystemPrompt EntryType = "system_prompt" ) // CurrentVersion is the session format version for JSONL tree sessions. const CurrentVersion = 3 // SessionHeader is the first line in a JSONL session file. It carries // metadata about the session and does NOT participate in the tree structure // (it has no ID or ParentID). type SessionHeader struct { Type EntryType `json:"type"` // always "session" Version int `json:"version"` // format version (3) ID string `json:"id"` // session UUID Timestamp time.Time `json:"timestamp"` // creation time Cwd string `json:"cwd"` // working directory ParentSession string `json:"parent_session,omitempty"` // path to parent if forked // Subagent fields (set when session is created by a subagent) ParentSessionID string `json:"parent_session_id,omitempty"` // UUID of parent session SubagentTask string `json:"subagent_task,omitempty"` // original task prompt } // Entry is the common structure shared by all tree entries (everything except // the session header). Every entry has an ID, an optional ParentID (empty for // root entries), a type tag, and a timestamp. type Entry struct { Type EntryType `json:"type"` ID string `json:"id"` ParentID string `json:"parent_id,omitempty"` Timestamp time.Time `json:"timestamp"` } // MessageEntry stores a conversation message as a tree entry. The message // content uses the same type-tagged parts format as the existing session // persistence layer, enabling reuse of MarshalParts/UnmarshalParts. type MessageEntry struct { Entry Role string `json:"role"` Parts json.RawMessage `json:"parts"` // type-tagged parts array Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` } // ModelChangeEntry records a provider/model switch in the session tree. type ModelChangeEntry struct { Entry Provider string `json:"provider"` ModelID string `json:"model_id"` } // BranchSummaryEntry provides LLM-generated context from an abandoned branch. // When the user navigates away from a branch, a summary of that branch's // conversation is stored so the LLM retains context about what was explored. type BranchSummaryEntry struct { Entry FromID string `json:"from_id"` // leaf of the summarized branch Summary string `json:"summary"` } // LabelEntry bookmarks a specific entry with a user-defined label. type LabelEntry struct { Entry TargetID string `json:"target_id"` Label string `json:"label"` } // SessionInfoEntry stores a user-defined display name for the session. type SessionInfoEntry struct { Entry Name string `json:"name"` } // ExtensionDataEntry stores custom extension data in the session tree. // Extensions use this to persist state that survives across session restarts. type ExtensionDataEntry struct { Entry ExtType string `json:"ext_type"` // Extension-defined type string (e.g. "plan-mode:state") Data string `json:"data"` // Extension-defined data (JSON or plain text) } // CompactionEntry records an LLM-generated summary of older messages. // Instead of deleting old messages, the tree manager skips entries before // FirstKeptEntryID when building the LLM context, preserving full history. type CompactionEntry struct { Entry Summary string `json:"summary"` FirstKeptEntryID string `json:"first_kept_entry_id"` TokensBefore int `json:"tokens_before"` TokensAfter int `json:"tokens_after"` MessagesRemoved int `json:"messages_removed"` ReadFiles []string `json:"read_files,omitempty"` ModifiedFiles []string `json:"modified_files,omitempty"` } // SystemPromptEntry records the system prompt and model used for the session. // This is primarily for sharing/debugging to see what instructions were // active during the conversation. It does NOT participate in the tree // structure (no ParentID) and is not used when building LLM context. type SystemPromptEntry struct { Type EntryType `json:"type"` // always "system_prompt" ID string `json:"id"` // unique entry ID Timestamp time.Time `json:"timestamp"` // when captured Content string `json:"content"` // the system prompt text Model string `json:"model"` // the model used (e.g., "claude-sonnet-4-5") Provider string `json:"provider"` // the provider used (e.g., "anthropic") } // GenerateEntryID creates a unique entry identifier (16 hex chars). func GenerateEntryID() string { bytes := make([]byte, 8) _, _ = rand.Read(bytes) return hex.EncodeToString(bytes) } // GenerateSessionID creates a unique session identifier (32 hex chars). func GenerateSessionID() string { bytes := make([]byte, 16) _, _ = rand.Read(bytes) return hex.EncodeToString(bytes) } // NewEntry creates a base Entry with a generated ID and current timestamp. func NewEntry(entryType EntryType, parentID string) Entry { return Entry{ Type: entryType, ID: GenerateEntryID(), ParentID: parentID, Timestamp: time.Now(), } } // --- Entry constructors --- // NewMessageEntry creates a MessageEntry from a message.Message, linking it // to the given parent entry in the tree. func NewMessageEntry(parentID string, msg message.Message) (*MessageEntry, error) { parts, err := message.MarshalParts(msg.Parts) if err != nil { return nil, fmt.Errorf("failed to marshal message parts: %w", err) } return &MessageEntry{ Entry: NewEntry(EntryTypeMessage, parentID), Role: string(msg.Role), Parts: parts, Model: msg.Model, Provider: msg.Provider, }, nil } // NewModelChangeEntry creates a ModelChangeEntry. func NewModelChangeEntry(parentID, provider, modelID string) *ModelChangeEntry { return &ModelChangeEntry{ Entry: NewEntry(EntryTypeModelChange, parentID), Provider: provider, ModelID: modelID, } } // NewBranchSummaryEntry creates a BranchSummaryEntry. func NewBranchSummaryEntry(parentID, fromID, summary string) *BranchSummaryEntry { return &BranchSummaryEntry{ Entry: NewEntry(EntryTypeBranchSummary, parentID), FromID: fromID, Summary: summary, } } // NewLabelEntry creates a LabelEntry. func NewLabelEntry(parentID, targetID, label string) *LabelEntry { return &LabelEntry{ Entry: NewEntry(EntryTypeLabel, parentID), TargetID: targetID, Label: label, } } // NewSessionInfoEntry creates a SessionInfoEntry. func NewSessionInfoEntry(parentID, name string) *SessionInfoEntry { return &SessionInfoEntry{ Entry: NewEntry(EntryTypeSessionInfo, parentID), Name: name, } } // NewExtensionDataEntry creates an ExtensionDataEntry. func NewExtensionDataEntry(parentID, extType, data string) *ExtensionDataEntry { return &ExtensionDataEntry{ Entry: NewEntry(EntryTypeExtensionData, parentID), ExtType: extType, Data: data, } } // NewCompactionEntry creates a CompactionEntry. func NewCompactionEntry(parentID, summary, firstKeptEntryID string, tokensBefore, tokensAfter, messagesRemoved int, readFiles, modifiedFiles []string) *CompactionEntry { return &CompactionEntry{ Entry: NewEntry(EntryTypeCompaction, parentID), Summary: summary, FirstKeptEntryID: firstKeptEntryID, TokensBefore: tokensBefore, TokensAfter: tokensAfter, MessagesRemoved: messagesRemoved, ReadFiles: readFiles, ModifiedFiles: modifiedFiles, } } // NewSystemPromptEntry creates a SystemPromptEntry. func NewSystemPromptEntry(content, model, provider string) *SystemPromptEntry { return &SystemPromptEntry{ Type: EntryTypeSystemPrompt, ID: GenerateEntryID(), Timestamp: time.Now(), Content: content, Model: model, Provider: provider, } } // --- JSONL marshaling helpers --- // MarshalEntry serializes any entry to a JSON line (no trailing newline). func MarshalEntry(entry any) ([]byte, error) { return json.Marshal(entry) } // entryEnvelope is used for initial unmarshaling to determine the entry type. type entryEnvelope struct { Type EntryType `json:"type"` } // UnmarshalEntry deserializes a JSON line into the appropriate entry type. // Returns one of: *SessionHeader, *MessageEntry, *ModelChangeEntry, // *BranchSummaryEntry, *LabelEntry, *SessionInfoEntry. func UnmarshalEntry(data []byte) (any, error) { var env entryEnvelope if err := json.Unmarshal(data, &env); err != nil { return nil, fmt.Errorf("failed to detect entry type: %w", err) } switch env.Type { case EntryTypeSession: var h SessionHeader if err := json.Unmarshal(data, &h); err != nil { return nil, fmt.Errorf("failed to unmarshal session header: %w", err) } return &h, nil case EntryTypeMessage: var e MessageEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal message entry: %w", err) } return &e, nil case EntryTypeModelChange: var e ModelChangeEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal model_change entry: %w", err) } return &e, nil case EntryTypeBranchSummary: var e BranchSummaryEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal branch_summary entry: %w", err) } return &e, nil case EntryTypeLabel: var e LabelEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal label entry: %w", err) } return &e, nil case EntryTypeSessionInfo: var e SessionInfoEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal session_info entry: %w", err) } return &e, nil case EntryTypeExtensionData: var e ExtensionDataEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal extension_data entry: %w", err) } return &e, nil case EntryTypeCompaction: var e CompactionEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal compaction entry: %w", err) } return &e, nil case EntryTypeSystemPrompt: var e SystemPromptEntry if err := json.Unmarshal(data, &e); err != nil { return nil, fmt.Errorf("failed to unmarshal system_prompt entry: %w", err) } return &e, nil default: return nil, fmt.Errorf("unknown entry type: %q", env.Type) } } // ToMessage converts a MessageEntry back to a message.Message by // unmarshaling the type-tagged parts. func (e *MessageEntry) ToMessage() (message.Message, error) { parts, err := message.UnmarshalParts(e.Parts) if err != nil { return message.Message{}, fmt.Errorf("failed to unmarshal parts: %w", err) } return message.Message{ ID: e.ID, Role: message.MessageRole(e.Role), Parts: parts, Model: e.Model, Provider: e.Provider, CreatedAt: e.Timestamp, UpdatedAt: e.Timestamp, }, nil }