use std::time::SystemTime; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::error::{AiError, AiResult}; /// Current session file format version. pub const SESSION_VERSION: u32 = 2; /// Session metadata header. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionHeader { pub version: u32, pub id: Uuid, pub created_at: String, pub parent_session: Option, pub name: Option, } impl SessionHeader { pub fn new() -> Self { Self { version: SESSION_VERSION, id: Uuid::new_v4(), created_at: iso_now(), parent_session: None, name: None, } } pub fn with_parent(mut self, parent: Uuid) -> Self { self.parent_session = Some(parent); self } pub fn with_name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); self } } /// Typed session entry — each entry in a session transcript is one of these variants. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SessionEntry { /// A user or assistant message. Message { id: Uuid, parent_id: Option, timestamp: String, role: SessionMessageRole, content: String, #[serde(default, skip_serializing_if = "Option::is_none")] tool_calls: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] tool_result: Option, }, /// A context compaction event (older messages summarized). Compaction { id: Uuid, parent_id: Option, timestamp: String, summary: String, first_kept_entry_id: Uuid, messages_compacted: usize, tokens_saved: i64, #[serde(default, skip_serializing_if = "Option::is_none")] details: Option, }, /// A branch summary (created when forking from a different point in the tree). BranchSummary { id: Uuid, parent_id: Option, timestamp: String, from_entry_id: Uuid, summary: String, entries_summarized: usize, #[serde(default, skip_serializing_if = "Option::is_none")] label: Option, }, /// Model change during a session. ModelChange { id: Uuid, parent_id: Option, timestamp: String, provider: String, model_id: String, }, /// Thinking level change during a session. ThinkingLevelChange { id: Uuid, parent_id: Option, timestamp: String, level: String, }, /// Custom extension data (not sent to LLM). Custom { id: Uuid, parent_id: Option, timestamp: String, custom_type: String, data: Option, }, } impl SessionEntry { pub fn id(&self) -> Uuid { match self { Self::Message { id, .. } | Self::Compaction { id, .. } | Self::BranchSummary { id, .. } | Self::ModelChange { id, .. } | Self::ThinkingLevelChange { id, .. } | Self::Custom { id, .. } => *id, } } pub fn parent_id(&self) -> Option { match self { Self::Message { parent_id, .. } | Self::Compaction { parent_id, .. } | Self::BranchSummary { parent_id, .. } | Self::ModelChange { parent_id, .. } | Self::ThinkingLevelChange { parent_id, .. } | Self::Custom { parent_id, .. } => *parent_id, } } pub fn timestamp(&self) -> &str { match self { Self::Message { timestamp, .. } | Self::Compaction { timestamp, .. } | Self::BranchSummary { timestamp, .. } | Self::ModelChange { timestamp, .. } | Self::ThinkingLevelChange { timestamp, .. } | Self::Custom { timestamp, .. } => timestamp, } } /// Create a user message entry. pub fn user_message( parent_id: Option, content: impl Into, ) -> Self { Self::Message { id: Uuid::new_v4(), parent_id, timestamp: iso_now(), role: SessionMessageRole::User, content: content.into(), tool_calls: None, tool_result: None, } } /// Create an assistant message entry. pub fn assistant_message( parent_id: Option, content: impl Into, tool_calls: Option>, ) -> Self { Self::Message { id: Uuid::new_v4(), parent_id, timestamp: iso_now(), role: SessionMessageRole::Assistant, content: content.into(), tool_calls, tool_result: None, } } /// Create a compaction entry. pub fn compaction( parent_id: Option, summary: impl Into, first_kept_entry_id: Uuid, messages_compacted: usize, tokens_saved: i64, ) -> Self { Self::Compaction { id: Uuid::new_v4(), parent_id, timestamp: iso_now(), summary: summary.into(), first_kept_entry_id, messages_compacted, tokens_saved, details: None, } } /// Create a branch summary entry. pub fn branch_summary( parent_id: Option, from_entry_id: Uuid, summary: impl Into, entries_summarized: usize, label: Option, ) -> Self { Self::BranchSummary { id: Uuid::new_v4(), parent_id, timestamp: iso_now(), from_entry_id, summary: summary.into(), entries_summarized, label, } } /// Create a model change entry. pub fn model_change( parent_id: Option, provider: impl Into, model_id: impl Into, ) -> Self { Self::ModelChange { id: Uuid::new_v4(), parent_id, timestamp: iso_now(), provider: provider.into(), model_id: model_id.into(), } } /// Create a custom extension entry. pub fn custom( parent_id: Option, custom_type: impl Into, data: Option, ) -> Self { Self::Custom { id: Uuid::new_v4(), parent_id, timestamp: iso_now(), custom_type: custom_type.into(), data, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SessionMessageRole { User, Assistant, ToolResult, System, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionToolCall { pub id: String, pub name: String, pub arguments: Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionToolResult { pub tool_call_id: String, pub tool_name: String, pub content: String, pub is_error: bool, } /// A full session: header + ordered list of entries forming a tree. /// /// The tree structure supports forking: entries share `parent_id` links, /// and the "active branch" is determined by following from the leaf /// back to the root. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub header: SessionHeader, pub entries: Vec, } impl Session { pub fn new() -> Self { Self { header: SessionHeader::new(), entries: Vec::new(), } } pub fn with_name(mut self, name: impl Into) -> Self { self.header = self.header.with_name(name); self } /// Append an entry to the session. pub fn push(&mut self, entry: SessionEntry) { self.entries.push(entry); } /// Get the last entry's id (used as parent_id for the next entry). pub fn last_entry_id(&self) -> Option { self.entries.last().map(|e| e.id()) } /// Get all entries on the active branch (from root to leaf). pub fn active_branch(&self) -> Vec<&SessionEntry> { if self.entries.is_empty() { return Vec::new(); } let mut branch = Vec::new(); let mut current_id = Some(self.entries.last().unwrap().id()); while let Some(id) = current_id { if let Some(entry) = self.entries.iter().find(|e| e.id() == id) { branch.push(entry); current_id = entry.parent_id(); } else { break; } } branch.reverse(); branch } /// Get all message entries on the active branch (for LLM context). pub fn active_messages(&self) -> Vec<&SessionEntry> { self.active_branch() .into_iter() .filter(|e| { matches!( e, SessionEntry::Message { .. } | SessionEntry::Compaction { .. } ) }) .collect() } /// Find all children of a given entry (for tree navigation). pub fn children_of(&self, parent_id: Uuid) -> Vec<&SessionEntry> { self.entries .iter() .filter(|e| e.parent_id() == Some(parent_id)) .collect() } /// Get all leaf entries (entries with no children). pub fn leaves(&self) -> Vec<&SessionEntry> { let parent_ids: std::collections::HashSet = self.entries.iter().filter_map(|e| e.parent_id()).collect(); self.entries .iter() .filter(|e| !parent_ids.contains(&e.id())) .collect() } /// Count total entries. pub fn entry_count(&self) -> usize { self.entries.len() } /// Fork from a specific entry, creating entries that belong to a new branch. /// Returns the entries that should be in the new branch (from root to fork point). pub fn fork_from(&self, fork_entry_id: Uuid) -> AiResult { let fork_idx = self .entries .iter() .position(|e| e.id() == fork_entry_id) .ok_or_else(|| { AiError::Config(format!( "fork entry {fork_entry_id} not found in session" )) })?; let mut new_session = Session::new(); new_session.header = new_session.header.with_parent(self.header.id); // Copy entries up to and including the fork point for entry in &self.entries[..=fork_idx] { new_session.entries.push(entry.clone()); } Ok(new_session) } /// Find the common ancestor of two entries. pub fn common_ancestor(&self, id_a: Uuid, id_b: Uuid) -> Option { let ancestors_a = self.ancestor_chain(id_a); let ancestors_b: std::collections::HashSet = self.ancestor_chain(id_b).into_iter().collect(); for ancestor in ancestors_a { if ancestors_b.contains(&ancestor) { return Some(ancestor); } } None } /// Get the chain of ancestor IDs from an entry back to the root. fn ancestor_chain(&self, entry_id: Uuid) -> Vec { let mut chain = Vec::new(); let mut current_id = Some(entry_id); while let Some(id) = current_id { chain.push(id); current_id = self .entries .iter() .find(|e| e.id() == id) .and_then(|e| e.parent_id()); } chain } } /// Options for session compaction. #[derive(Debug, Clone)] pub struct CompactionOptions { /// Custom instructions for the compaction LLM call. pub custom_instructions: Option, /// Reserve this many tokens for the prompt + LLM response. pub reserve_tokens: i64, /// Keep this many recent message pairs untouched. pub keep_recent_pairs: usize, /// Whether to generate branch summaries for forked branches. pub branch_summarization: bool, } impl Default for CompactionOptions { fn default() -> Self { Self { custom_instructions: None, reserve_tokens: 16_384, keep_recent_pairs: 4, branch_summarization: true, } } } fn iso_now() -> String { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| { let secs = d.as_secs(); // Simple ISO 8601 format (UTC) let days = secs / 86400; let years = (days * 400) / 146097; let remaining_days = days - (years * 365 + years / 4 - years / 100 + years / 400); let month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; let is_leap = (years % 4 == 0 && years % 100 != 0) || years % 400 == 0; let mut month = 0usize; let mut day_acc = remaining_days as i64; for (i, &md) in month_days.iter().enumerate() { let md = if i == 1 && is_leap { md + 1 } else { md }; if day_acc < md as i64 { month = i; break; } day_acc -= md as i64; } let day = day_acc + 1; let hour = (secs % 86400) / 3600; let minute = (secs % 3600) / 60; let second = secs % 60; format!( "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", 1970 + years, month + 1, day, hour, minute, second, ) }) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_session_basic() { let mut session = Session::new(); let msg1 = SessionEntry::user_message(None, "Hello"); let msg1_id = msg1.id(); session.push(msg1); let msg2 = SessionEntry::assistant_message(Some(msg1_id), "Hi there!", None); session.push(msg2); assert_eq!(session.entry_count(), 2); assert_eq!(session.active_branch().len(), 2); } #[test] fn test_session_fork() { let mut session = Session::new(); let msg1 = SessionEntry::user_message(None, "First"); let msg1_id = msg1.id(); session.push(msg1); let msg2 = SessionEntry::assistant_message(Some(msg1_id), "Reply 1", None); let msg2_id = msg2.id(); session.push(msg2); let msg3 = SessionEntry::user_message(Some(msg2_id), "Second"); session.push(msg3); // Fork from msg2 let forked = session.fork_from(msg2_id).unwrap(); assert_eq!(forked.entry_count(), 2); assert_eq!(forked.header.parent_session, Some(session.header.id)); } #[test] fn test_session_leaves() { let mut session = Session::new(); let msg1 = SessionEntry::user_message(None, "Root"); let msg1_id = msg1.id(); session.push(msg1); // Two children branching from root let msg2a = SessionEntry::assistant_message(Some(msg1_id), "Branch A", None); let msg2b = SessionEntry::assistant_message(Some(msg1_id), "Branch B", None); session.push(msg2a); session.push(msg2b); let leaves = session.leaves(); assert_eq!(leaves.len(), 2); } }