1use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::fs::{self, File};
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use uuid::Uuid;
15
16fn atomic_write(path: &Path, content: &str) -> Result<(), std::io::Error> {
23 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
24 std::fs::write(&tmp_path, content)?;
25 std::fs::rename(&tmp_path, path)?;
26 Ok(())
27}
28
29pub type EntryId = Uuid;
31
32pub const CURRENT_SESSION_VERSION: i32 = 3;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SessionMeta {
42 pub id: Uuid,
44 pub parent_id: Option<Uuid>,
46 pub root_id: Option<Uuid>,
48 pub branch_point: Option<Uuid>,
50 pub created_at: i64,
52 pub updated_at: i64,
54 pub name: Option<String>,
56}
57
58impl SessionMeta {
59 pub fn new(id: Uuid) -> Self {
61 let now = Utc::now().timestamp_millis();
62 Self {
63 id,
64 parent_id: None,
65 root_id: None,
66 branch_point: None,
67 created_at: now,
68 updated_at: now,
69 name: None,
70 }
71 }
72
73 pub fn branched_from(parent_id: Uuid, root_id: Option<Uuid>, branch_point: Uuid) -> Self {
75 let now = Utc::now().timestamp_millis();
76 Self {
77 id: Uuid::new_v4(),
78 parent_id: Some(parent_id),
79 root_id: root_id.or(Some(parent_id)),
80 branch_point: Some(branch_point),
81 created_at: now,
82 updated_at: now,
83 name: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct BranchInfo {
91 pub session_id: Uuid,
93 pub parent_session_id: Option<Uuid>,
95 pub root_session_id: Option<Uuid>,
97 pub branch_point_entry_id: Option<Uuid>,
99 pub parent_session_name: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SessionHeader {
110 #[serde(rename = "type")]
112 pub entry_type: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub version: Option<i32>,
116 pub id: String,
118 pub timestamp: String,
120 pub cwd: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub parent_session: Option<String>,
125}
126
127impl SessionHeader {
128 pub fn new(id: String, cwd: String, parent_session: Option<String>) -> Self {
130 Self {
131 entry_type: "session".to_string(),
132 version: Some(CURRENT_SESSION_VERSION),
133 id,
134 timestamp: Utc::now().to_rfc3339(),
135 cwd,
136 parent_session,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(untagged)]
148pub enum ContentValue {
149 String(String),
151 Blocks(Vec<ContentBlock>),
153}
154
155impl ContentValue {
156 pub fn as_str(&self) -> &str {
158 match self {
159 ContentValue::String(s) => s,
160 ContentValue::Blocks(blocks) => {
161 for block in blocks {
163 if let ContentBlock::Text { text } = block {
164 return text;
165 }
166 }
167 ""
168 }
169 }
170 }
171}
172
173impl From<String> for ContentValue {
174 fn from(s: String) -> Self {
175 ContentValue::String(s)
176 }
177}
178
179impl From<&str> for ContentValue {
180 fn from(s: &str) -> Self {
181 ContentValue::String(s.to_string())
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(tag = "type")]
188pub enum ContentBlock {
189 #[serde(rename = "text")]
191 Text {
192 text: String,
194 },
195 #[serde(rename = "image")]
197 Image {
198 data: String,
200 media_type: Option<String>,
202 },
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(tag = "role")]
212pub enum AgentMessage {
213 #[serde(rename = "user")]
215 User {
216 #[serde(flatten)]
218 content: ContentValue,
219 },
220 #[serde(rename = "assistant")]
222 Assistant {
223 content: Vec<AssistantContentBlock>,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 provider: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 model_id: Option<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 usage: Option<Usage>,
234 #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
236 stop_reason: Option<String>,
237 },
238 #[serde(rename = "toolResult")]
240 ToolResult {
241 content: ContentValue,
243 #[serde(rename = "toolCallId")]
245 tool_call_id: String,
246 },
247 #[serde(rename = "system")]
249 System {
250 #[serde(flatten)]
252 content: ContentValue,
253 },
254 #[serde(rename = "bashExecution")]
256 BashExecution {
257 command: String,
259 output: String,
261 #[serde(rename = "exitCode")]
263 exit_code: Option<i32>,
264 cancelled: bool,
266 truncated: bool,
268 #[serde(rename = "fullOutputPath", skip_serializing_if = "Option::is_none")]
270 full_output_path: Option<String>,
271 #[serde(rename = "excludeFromContext", skip_serializing_if = "Option::is_none")]
273 exclude_from_context: Option<bool>,
274 timestamp: i64,
276 },
277 #[serde(rename = "custom")]
279 Custom {
280 #[serde(rename = "customType")]
282 custom_type: String,
283 content: ContentValue,
285 display: bool,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 details: Option<serde_json::Value>,
290 timestamp: i64,
292 },
293 #[serde(rename = "branchSummary")]
295 BranchSummary {
296 summary: String,
298 #[serde(rename = "fromId")]
300 from_id: String,
301 timestamp: i64,
303 },
304 #[serde(rename = "compactionSummary")]
306 CompactionSummary {
307 summary: String,
309 #[serde(rename = "tokensBefore")]
311 tokens_before: i64,
312 timestamp: i64,
314 },
315}
316
317impl AgentMessage {
318 pub fn content(&self) -> String {
320 match self {
321 AgentMessage::User { content } => content.as_str().to_string(),
322 AgentMessage::Assistant { content, .. } => {
323 let estimated_len = content
324 .iter()
325 .map(|b| match b {
326 AssistantContentBlock::Text { text: t } => t.len(),
327 _ => 0,
328 })
329 .sum::<usize>();
330 let mut text = String::with_capacity(estimated_len.max(256));
331 for block in content {
332 if let AssistantContentBlock::Text { text: t } = block {
333 text.push_str(t)
334 }
335 }
336 text
337 }
338 AgentMessage::ToolResult { content, .. } => content.as_str().to_string(),
339 AgentMessage::System { content } => content.as_str().to_string(),
340 AgentMessage::BashExecution { output, .. } => output.clone(),
341 AgentMessage::Custom { content, .. } => content.as_str().to_string(),
342 AgentMessage::BranchSummary { summary, .. } => summary.clone(),
343 AgentMessage::CompactionSummary { summary, .. } => summary.clone(),
344 }
345 }
346
347 pub fn is_user(&self) -> bool {
349 matches!(self, AgentMessage::User { .. })
350 }
351
352 pub fn is_assistant(&self) -> bool {
354 matches!(self, AgentMessage::Assistant { .. })
355 }
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
360#[serde(tag = "type")]
361pub enum AssistantContentBlock {
362 #[serde(rename = "text")]
364 Text {
365 text: String,
367 },
368 #[serde(rename = "thinking")]
370 Thinking {
371 thinking: String,
373 },
374 #[serde(rename = "toolCall")]
376 ToolCall {
377 id: String,
379 name: String,
381 arguments: serde_json::Value,
383 },
384 #[serde(rename = "toolPlan")]
386 ToolPlan {
387 content: String,
389 #[serde(rename = "toolCallId")]
391 tool_call_id: String,
392 },
393 #[serde(rename = "image")]
395 ImageResult {
396 data: String,
398 media_type: String,
400 },
401 #[serde(rename = "refusal")]
403 Refusal {
404 content: String,
406 },
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct Usage {
412 #[serde(rename = "inputTokens", skip_serializing_if = "Option::is_none")]
414 pub input: Option<i64>,
415 #[serde(rename = "outputTokens", skip_serializing_if = "Option::is_none")]
417 pub output: Option<i64>,
418 #[serde(rename = "cacheReadTokens", skip_serializing_if = "Option::is_none")]
420 pub cache_read: Option<i64>,
421 #[serde(rename = "cacheWriteTokens", skip_serializing_if = "Option::is_none")]
423 pub cache_write: Option<i64>,
424 #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
426 pub total_tokens: Option<i64>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct SessionEntryBase {
436 #[serde(rename = "type")]
438 pub entry_type: String,
439 pub id: String,
441 #[serde(rename = "parentId")]
443 pub parent_id: Option<String>,
444 pub timestamp: String,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct SessionMessageEntry {
451 #[serde(flatten)]
453 pub base: SessionEntryBase,
454 pub message: AgentMessage,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct ThinkingLevelChangeEntry {
461 #[serde(flatten)]
463 pub base: SessionEntryBase,
464 #[serde(rename = "thinkingLevel")]
466 pub thinking_level: String,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct ModelChangeEntry {
472 #[serde(flatten)]
474 pub base: SessionEntryBase,
475 pub provider: String,
477 #[serde(rename = "modelId")]
479 pub model_id: String,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct CompactionEntry {
485 #[serde(flatten)]
487 pub base: SessionEntryBase,
488 pub summary: String,
490 #[serde(rename = "firstKeptEntryId")]
492 pub first_kept_entry_id: String,
493 #[serde(rename = "tokensBefore")]
495 pub tokens_before: i64,
496 #[serde(skip_serializing_if = "Option::is_none")]
498 pub details: Option<serde_json::Value>,
499 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
501 pub from_hook: Option<bool>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct BranchSummaryEntry {
507 #[serde(flatten)]
509 pub base: SessionEntryBase,
510 #[serde(rename = "fromId")]
512 pub from_id: String,
513 pub summary: String,
515 #[serde(skip_serializing_if = "Option::is_none")]
517 pub details: Option<serde_json::Value>,
518 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
520 pub from_hook: Option<bool>,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct CustomEntry {
526 #[serde(flatten)]
528 pub base: SessionEntryBase,
529 #[serde(rename = "customType")]
531 pub custom_type: String,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub data: Option<serde_json::Value>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct LabelEntry {
540 #[serde(flatten)]
542 pub base: SessionEntryBase,
543 #[serde(rename = "targetId")]
545 pub target_id: String,
546 pub label: Option<String>,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct SessionInfoEntry {
553 #[serde(flatten)]
555 pub base: SessionEntryBase,
556 pub name: Option<String>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct CustomMessageEntry {
563 #[serde(flatten)]
565 pub base: SessionEntryBase,
566 #[serde(rename = "customType")]
568 pub custom_type: String,
569 pub content: ContentValue,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub details: Option<serde_json::Value>,
574 pub display: bool,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
580#[serde(untagged)]
581pub enum SessionEntryEnum {
582 Message(SessionMessageEntry),
584 ThinkingLevelChange(ThinkingLevelChangeEntry),
586 ModelChange(ModelChangeEntry),
588 Compaction(CompactionEntry),
590 BranchSummary(BranchSummaryEntry),
592 Custom(CustomEntry),
594 Label(LabelEntry),
596 SessionInfo(SessionInfoEntry),
598 CustomMessage(CustomMessageEntry),
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct SessionEntry {
606 pub id: String,
608 pub parent_id: Option<String>,
610 pub timestamp: i64,
612 pub message: AgentMessage,
614}
615
616impl SessionEntry {
617 pub fn new(message: AgentMessage) -> Self {
619 Self {
620 id: Uuid::new_v4().to_string(),
621 parent_id: None,
622 timestamp: Utc::now().timestamp_millis(),
623 message,
624 }
625 }
626
627 pub fn simple_message(role: &str, content: &str) -> Self {
629 use crate::session::ContentValue;
630 let message = match role {
631 "user" => AgentMessage::User {
632 content: ContentValue::String(content.to_string()),
633 },
634 "assistant" => AgentMessage::Assistant {
635 content: vec![AssistantContentBlock::Text {
636 text: content.to_string(),
637 }],
638 provider: None,
639 model_id: None,
640 usage: None,
641 stop_reason: None,
642 },
643 "system" => AgentMessage::System {
644 content: ContentValue::String(content.to_string()),
645 },
646 _ => AgentMessage::System {
647 content: ContentValue::String(content.to_string()),
648 },
649 };
650 Self::new(message)
651 }
652
653 pub fn branched(message: AgentMessage, parent_id: &str) -> Self {
655 Self {
656 id: Uuid::new_v4().to_string(),
657 parent_id: Some(parent_id.to_string()),
658 timestamp: Utc::now().timestamp_millis(),
659 message,
660 }
661 }
662
663 pub fn content(&self) -> String {
665 self.message.content()
666 }
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(untagged)]
672pub enum FileEntry {
673 Header(SessionHeader),
675 Entry(SessionEntryEnum),
677}
678
679#[derive(Debug, Clone)]
685pub struct SessionContext {
686 pub messages: Vec<AgentMessage>,
688 pub thinking_level: String,
690 pub model: Option<ModelInfo>,
692}
693
694#[derive(Debug, Clone)]
696pub struct ModelInfo {
697 pub provider: String,
699 pub model_id: String,
701}
702
703#[derive(Debug, Clone)]
709pub struct SessionInfo {
710 pub path: String,
712 pub id: String,
714 pub cwd: String,
716 pub name: Option<String>,
718 pub parent_session_path: Option<String>,
720 pub created: DateTime<Utc>,
722 pub modified: DateTime<Utc>,
724 pub message_count: i64,
726 pub first_message: String,
728 pub all_messages_text: String,
730}
731
732#[derive(Debug, Clone)]
738pub struct SessionTreeNode {
739 pub entry: SessionEntry,
741 pub children: Vec<SessionTreeNode>,
743 pub label: Option<String>,
745 pub label_timestamp: Option<String>,
747}
748
749fn generate_id(by_id: &HashSet<String>) -> String {
754 for _ in 0..100 {
755 let id = Uuid::new_v4().to_string()[..8].to_string();
756 if !by_id.contains(&id) {
757 return id;
758 }
759 }
760 Uuid::new_v4().to_string()
762}
763
764fn migrate_v1_to_v2(entries: &mut [FileEntry]) {
770 let mut ids = HashSet::new();
771 let mut prev_id: Option<String> = None;
772
773 for entry in entries.iter_mut() {
774 match entry {
775 FileEntry::Header(header) => {
776 header.version = Some(2);
777 }
778 FileEntry::Entry(entry) => {
779 let id = match entry {
780 SessionEntryEnum::Message(e) => {
781 e.base.id = generate_id(&ids);
782 e.base.parent_id = prev_id.clone();
783 e.base.entry_type = "message".to_string();
784 prev_id = Some(e.base.id.clone());
785 e.base.id.clone()
786 }
787 SessionEntryEnum::ThinkingLevelChange(e) => {
788 e.base.id = generate_id(&ids);
789 e.base.parent_id = prev_id.clone();
790 e.base.entry_type = "thinking_level_change".to_string();
791 prev_id = Some(e.base.id.clone());
792 e.base.id.clone()
793 }
794 SessionEntryEnum::ModelChange(e) => {
795 e.base.id = generate_id(&ids);
796 e.base.parent_id = prev_id.clone();
797 e.base.entry_type = "model_change".to_string();
798 prev_id = Some(e.base.id.clone());
799 e.base.id.clone()
800 }
801 SessionEntryEnum::Compaction(e) => {
802 e.base.id = generate_id(&ids);
803 e.base.parent_id = prev_id.clone();
804 e.base.entry_type = "compaction".to_string();
805 prev_id = Some(e.base.id.clone());
806 e.base.id.clone()
807 }
808 SessionEntryEnum::BranchSummary(e) => {
809 e.base.id = generate_id(&ids);
810 e.base.parent_id = prev_id.clone();
811 e.base.entry_type = "branch_summary".to_string();
812 prev_id = Some(e.base.id.clone());
813 e.base.id.clone()
814 }
815 SessionEntryEnum::Custom(e) => {
816 e.base.id = generate_id(&ids);
817 e.base.parent_id = prev_id.clone();
818 e.base.entry_type = "custom".to_string();
819 prev_id = Some(e.base.id.clone());
820 e.base.id.clone()
821 }
822 SessionEntryEnum::Label(e) => {
823 e.base.id = generate_id(&ids);
824 e.base.parent_id = prev_id.clone();
825 e.base.entry_type = "label".to_string();
826 prev_id = Some(e.base.id.clone());
827 e.base.id.clone()
828 }
829 SessionEntryEnum::SessionInfo(e) => {
830 e.base.id = generate_id(&ids);
831 e.base.parent_id = prev_id.clone();
832 e.base.entry_type = "session_info".to_string();
833 prev_id = Some(e.base.id.clone());
834 e.base.id.clone()
835 }
836 SessionEntryEnum::CustomMessage(e) => {
837 e.base.id = generate_id(&ids);
838 e.base.parent_id = prev_id.clone();
839 e.base.entry_type = "custom_message".to_string();
840 prev_id = Some(e.base.id.clone());
841 e.base.id.clone()
842 }
843 };
844 ids.insert(id);
845 }
846 }
847 }
848}
849
850fn migrate_v2_to_v3(entries: &mut [FileEntry]) {
852 for entry in entries.iter_mut() {
853 match entry {
854 FileEntry::Header(header) => {
855 header.version = Some(3);
856 }
857 FileEntry::Entry(_) => {
858 }
860 }
861 }
862}
863
864fn migrate_to_current_version(entries: &mut [FileEntry]) -> bool {
866 let header = entries.iter().find_map(|e| match e {
867 FileEntry::Header(h) => Some(h),
868 _ => None,
869 });
870 let version = header.and_then(|h| h.version).unwrap_or(1);
871
872 if version >= CURRENT_SESSION_VERSION {
873 return false;
874 }
875
876 if version < 2 {
877 migrate_v1_to_v2(entries);
878 }
879 if version < 3 {
880 migrate_v2_to_v3(entries);
881 }
882
883 true
884}
885
886pub struct SessionManager {
896 session_id: String,
897 session_file: Option<String>,
898 session_dir: String,
899 cwd: String,
900 persist: bool,
901 flushed: bool,
902 persisted_count: RwLock<usize>,
905 file_entries: RwLock<Vec<FileEntry>>,
906 by_id: RwLock<HashMap<String, SessionEntry>>,
907 labels_by_id: RwLock<HashMap<String, String>>,
908 label_timestamps_by_id: RwLock<HashMap<String, String>>,
909 leaf_id: RwLock<Option<String>>,
910}
911
912impl Clone for SessionManager {
914 fn clone(&self) -> Self {
915 Self {
916 session_id: self.session_id.clone(),
917 session_file: self.session_file.clone(),
918 session_dir: self.session_dir.clone(),
919 cwd: self.cwd.clone(),
920 persist: self.persist,
921 flushed: self.flushed,
922 persisted_count: RwLock::new(*self.persisted_count.read()),
923 file_entries: RwLock::new(self.file_entries.read().clone()),
924 by_id: RwLock::new(self.by_id.read().clone()),
925 labels_by_id: RwLock::new(self.labels_by_id.read().clone()),
926 label_timestamps_by_id: RwLock::new(self.label_timestamps_by_id.read().clone()),
927 leaf_id: RwLock::new(self.leaf_id.read().clone()),
928 }
929 }
930}
931
932impl SessionManager {
933 pub fn create(cwd: &str, session_dir: Option<&str>) -> Self {
935 let dir = session_dir
936 .map(|s| s.to_string())
937 .unwrap_or_else(|| get_default_session_dir(cwd));
938
939 let mut manager = Self::new_internal(cwd, &dir, None, true);
940 manager.persist = true;
941 manager
942 }
943
944 pub fn open(path: &str, session_dir: Option<&str>, cwd_override: Option<&str>) -> Self {
946 let entries = load_entries_from_file(path);
947 let header = entries.iter().find_map(|e| match e {
948 FileEntry::Header(h) => Some(h),
949 _ => None,
950 });
951 let cwd = cwd_override
952 .map(|s| s.to_string())
953 .or_else(|| header.as_ref().map(|h| h.cwd.clone()))
954 .unwrap_or_else(|| {
955 std::env::current_dir()
956 .unwrap_or_else(|_| PathBuf::from("."))
957 .to_string_lossy()
958 .to_string()
959 });
960 let dir = session_dir.map(|s| s.to_string()).unwrap_or_else(|| {
961 Path::new(path)
962 .parent()
963 .map(|p| p.to_string_lossy().to_string())
964 .unwrap_or_else(|| ".".to_string())
965 });
966
967 let mut manager = Self::new_internal(&cwd, &dir, Some(path), true);
968 manager.persist = true;
969 manager
970 }
971
972 pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Self {
974 let dir = session_dir
975 .map(|s| s.to_string())
976 .unwrap_or_else(|| get_default_session_dir(cwd));
977
978 if let Some(most_recent) = find_most_recent_session(&dir) {
979 return Self::open(&most_recent, None, None);
980 }
981 Self::create(cwd, None)
982 }
983
984 pub fn in_memory(cwd: &str) -> Self {
986 let cwd = cwd.to_string();
987 Self::new_internal(&cwd, "", None, false)
988 }
989
990 fn new_internal(
991 cwd: &str,
992 session_dir: &str,
993 session_file: Option<&str>,
994 persist: bool,
995 ) -> Self {
996 let cwd = cwd.to_string();
997 let session_dir = session_dir.to_string();
998
999 if persist && !session_dir.is_empty() && !Path::new(&session_dir).exists() {
1000 let _ = fs::create_dir_all(&session_dir);
1001 }
1002
1003 let mut manager = Self {
1004 session_id: Uuid::new_v4().to_string(),
1005 session_file: session_file.map(|s| s.to_string()),
1006 session_dir,
1007 cwd,
1008 persist,
1009 flushed: false,
1010 persisted_count: RwLock::new(0),
1011 file_entries: RwLock::new(Vec::new()),
1012 by_id: RwLock::new(HashMap::new()),
1013 labels_by_id: RwLock::new(HashMap::new()),
1014 label_timestamps_by_id: RwLock::new(HashMap::new()),
1015 leaf_id: RwLock::new(None),
1016 };
1017
1018 if let Some(file) = session_file {
1019 manager.set_session_file(file);
1020 } else {
1021 manager.new_session(None);
1022 }
1023
1024 manager
1025 }
1026
1027 pub fn set_session_file(&mut self, session_file: &str) {
1029 let path = Path::new(session_file)
1030 .canonicalize()
1031 .unwrap_or_else(|_| PathBuf::from(session_file));
1032 let path_str = path.to_string_lossy().to_string();
1033 self.session_file = Some(path_str.clone());
1034
1035 if path.exists() {
1036 let mut entries = load_entries_from_file(&path_str);
1037
1038 if entries.is_empty() {
1040 let explicit_path = self.session_file.take();
1041 self.new_session(None);
1042 self.session_file = explicit_path;
1043 self._rewrite_file();
1044 self.flushed = true;
1045 return;
1046 }
1047
1048 let header = entries.iter().find_map(|e| match e {
1049 FileEntry::Header(h) => Some(h),
1050 _ => None,
1051 });
1052 self.session_id = header
1053 .map(|h| h.id.clone())
1054 .unwrap_or_else(|| Uuid::new_v4().to_string());
1055
1056 if migrate_to_current_version(&mut entries) {
1057 self._rewrite_file();
1058 }
1059
1060 *self.file_entries.write() = entries;
1061 self._build_index();
1062 self.flushed = true;
1063 } else {
1064 let explicit_path = self.session_file.take();
1065 self.new_session(None);
1066 self.session_file = explicit_path;
1067 }
1068 }
1069
1070 pub fn new_session(&mut self, options: Option<NewSessionOptions>) {
1072 self.session_id = options
1073 .as_ref()
1074 .and_then(|o| o.id.clone())
1075 .unwrap_or_else(|| Uuid::new_v4().to_string());
1076 let timestamp = Utc::now().to_rfc3339();
1077 let header = SessionHeader::new(
1078 self.session_id.clone(),
1079 self.cwd.clone(),
1080 options.and_then(|o| o.parent_session),
1081 );
1082
1083 self.file_entries = RwLock::new(vec![FileEntry::Header(header)]);
1084 self.by_id.write().clear();
1085 self.labels_by_id.write().clear();
1086 self.label_timestamps_by_id.write().clear();
1087 *self.leaf_id.write() = None;
1088 *self.persisted_count.write() = 0;
1089 self.flushed = false;
1090
1091 if self.persist {
1092 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1093 let short_id = &self.session_id[..8];
1094 self.session_file = Some(format!(
1095 "{}/{}_{}.jsonl",
1096 self.session_dir, file_timestamp, short_id
1097 ));
1098 }
1099 }
1100
1101 fn _build_index(&mut self) {
1102 let mut by_id = self.by_id.write();
1103 let mut labels = self.labels_by_id.write();
1104 let mut label_timestamps = self.label_timestamps_by_id.write();
1105 let mut leaf_id = self.leaf_id.write();
1106
1107 by_id.clear();
1108 labels.clear();
1109 label_timestamps.clear();
1110 *leaf_id = None;
1111
1112 for entry in self.file_entries.read().iter() {
1113 if let FileEntry::Entry(e) = entry {
1114 if let Some(session_entry) = convert_to_session_entry(e) {
1116 by_id.insert(session_entry.id.clone(), session_entry.clone());
1117 *leaf_id = Some(session_entry.id.clone());
1118 }
1119
1120 if let SessionEntryEnum::Label(l) = e {
1122 if let Some(ref label) = l.label {
1123 labels.insert(l.target_id.clone(), label.clone());
1124 label_timestamps.insert(l.target_id.clone(), l.base.timestamp.clone());
1125 } else {
1126 labels.remove(&l.target_id);
1127 label_timestamps.remove(&l.target_id);
1128 }
1129 }
1130 }
1131 }
1132 }
1133
1134 fn _rewrite_file(&self) {
1135 if !self.persist || self.session_file.is_none() {
1136 return;
1137 }
1138
1139 let file = match self.session_file.as_ref() {
1140 Some(f) => f,
1141 None => return,
1142 };
1143
1144 let content: String = self
1145 .file_entries
1146 .read()
1147 .iter()
1148 .map(|e| serde_json::to_string(e).unwrap_or_default())
1149 .collect::<Vec<_>>()
1150 .join("\n")
1151 + "\n";
1152
1153 if let Err(e) = atomic_write(Path::new(file), &content) {
1154 tracing::warn!("Failed to rewrite session file {}: {}", file, e);
1155 }
1156 }
1157
1158 pub fn is_persisted(&self) -> bool {
1160 self.persist
1161 }
1162
1163 pub fn persisted_count(&self) -> usize {
1165 *self.persisted_count.read()
1166 }
1167
1168 pub fn set_persisted_count(&self, count: usize) {
1170 *self.persisted_count.write() = count;
1171 }
1172
1173 pub fn get_cwd(&self) -> String {
1175 self.cwd.clone()
1176 }
1177
1178 pub fn get_session_dir(&self) -> String {
1180 self.session_dir.clone()
1181 }
1182
1183 pub fn get_session_id(&self) -> String {
1185 self.session_id.clone()
1186 }
1187
1188 pub fn get_session_file(&self) -> Option<String> {
1190 self.session_file.clone()
1191 }
1192
1193 pub fn cleanup_if_empty(&self) {
1197 if !self.persist {
1198 return;
1199 }
1200 let Some(file) = &self.session_file else {
1201 return;
1202 };
1203
1204 let has_user = self.file_entries.read().iter().any(|e| {
1205 matches!(
1206 e,
1207 FileEntry::Entry(SessionEntryEnum::Message(m)) if m.message.is_user()
1208 )
1209 });
1210
1211 if !has_user {
1212 let path = Path::new(file);
1213 if path.exists() {
1214 if let Err(e) = fs::remove_file(path) {
1215 tracing::warn!("Failed to remove empty session file {}: {}", file, e);
1216 } else {
1217 tracing::debug!("Removed empty session file: {}", file);
1218 }
1219 }
1220 }
1221 }
1222
1223 fn _persist(&mut self, entry: &SessionEntry) {
1224 if !self.persist {
1225 return;
1226 }
1227 let Some(file) = &self.session_file else {
1228 return;
1229 };
1230
1231 let has_message = self
1234 .file_entries
1235 .read()
1236 .iter()
1237 .any(|e| matches!(e, FileEntry::Entry(SessionEntryEnum::Message(_))));
1238
1239 if !has_message {
1240 self.flushed = false;
1241 return;
1242 }
1243
1244 let mut handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1245 Ok(h) => h,
1246 Err(e) => {
1247 tracing::warn!("Failed to open session file for append {}: {}", file, e);
1248 return;
1249 }
1250 };
1251
1252 if !self.flushed {
1253 for e in self.file_entries.read().iter() {
1254 if let Ok(line) = serde_json::to_string(e) {
1255 let _ = writeln!(&mut handle, "{}", line);
1256 }
1257 }
1258 self.flushed = true;
1259 } else {
1260 let file_entry = convert_from_session_entry(entry);
1262 if let Ok(line) = serde_json::to_string(&file_entry) {
1263 let _ = writeln!(&mut handle, "{}", line);
1264 }
1265 }
1266 }
1267
1268 fn _append_entry(&mut self, entry: SessionEntry) {
1272 let file_entry = convert_from_session_entry(&entry);
1273 self.file_entries.write().push(FileEntry::Entry(file_entry));
1274 self.by_id.write().insert(entry.id.clone(), entry.clone());
1275 *self.leaf_id.write() = Some(entry.id.clone());
1276 self._persist(&entry);
1277 }
1278
1279 pub fn append_message(&mut self, message: AgentMessage) -> String {
1281 let leaf = self.leaf_id.read().clone();
1282 let id = Uuid::new_v4().to_string();
1283 let entry = SessionEntry {
1284 id: id.clone(),
1285 parent_id: leaf,
1286 timestamp: Utc::now().timestamp_millis(),
1287 message,
1288 };
1289 self._append_entry(entry);
1290 id
1291 }
1292
1293 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1295 let leaf = self.leaf_id.read().clone();
1296 let id = Uuid::new_v4().to_string();
1297 let entry = SessionEntry {
1298 id: id.clone(),
1299 parent_id: leaf,
1300 timestamp: Utc::now().timestamp_millis(),
1301 message: AgentMessage::Custom {
1302 custom_type: "thinking_level_change".to_string(),
1303 content: ContentValue::String(thinking_level.to_string()),
1304 display: false,
1305 details: None,
1306 timestamp: Utc::now().timestamp_millis(),
1307 },
1308 };
1309 self._append_entry(entry);
1310 id
1311 }
1312
1313 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1315 let leaf = self.leaf_id.read().clone();
1316 let id = Uuid::new_v4().to_string();
1317 let entry = SessionEntry {
1318 id: id.clone(),
1319 parent_id: leaf,
1320 timestamp: Utc::now().timestamp_millis(),
1321 message: AgentMessage::Custom {
1322 custom_type: "model_change".to_string(),
1323 content: ContentValue::String(format!("{}:{}", provider, model_id)),
1324 display: false,
1325 details: None,
1326 timestamp: Utc::now().timestamp_millis(),
1327 },
1328 };
1329 self._append_entry(entry);
1330 id
1331 }
1332
1333 pub fn append_compaction(
1335 &mut self,
1336 summary: &str,
1337 _first_kept_entry_id: &str,
1338 tokens_before: i64,
1339 _details: Option<serde_json::Value>,
1340 _from_hook: Option<bool>,
1341 ) -> String {
1342 let leaf = self.leaf_id.read().clone();
1343 let id = Uuid::new_v4().to_string();
1344 let entry = SessionEntry {
1345 id: id.clone(),
1346 parent_id: leaf,
1347 timestamp: Utc::now().timestamp_millis(),
1348 message: AgentMessage::CompactionSummary {
1349 summary: summary.to_string(),
1350 tokens_before,
1351 timestamp: Utc::now().timestamp_millis(),
1352 },
1353 };
1354 self._append_entry(entry);
1355 id
1356 }
1357
1358 pub fn append_custom_entry(
1360 &mut self,
1361 custom_type: &str,
1362 data: Option<serde_json::Value>,
1363 ) -> String {
1364 let leaf = self.leaf_id.read().clone();
1365 let id = Uuid::new_v4().to_string();
1366 let entry = SessionEntry {
1367 id: id.clone(),
1368 parent_id: leaf,
1369 timestamp: Utc::now().timestamp_millis(),
1370 message: AgentMessage::Custom {
1371 custom_type: custom_type.to_string(),
1372 content: data
1373 .as_ref()
1374 .map(|d| ContentValue::String(d.to_string()))
1375 .unwrap_or(ContentValue::String(String::new())),
1376 display: false,
1377 details: data.clone(),
1378 timestamp: Utc::now().timestamp_millis(),
1379 },
1380 };
1381 self._append_entry(entry);
1382 id
1383 }
1384
1385 pub fn append_session_info(&mut self, name: &str) -> String {
1387 let leaf = self.leaf_id.read().clone();
1388 let id = Uuid::new_v4().to_string();
1389 let entry = SessionEntry {
1390 id: id.clone(),
1391 parent_id: leaf,
1392 timestamp: Utc::now().timestamp_millis(),
1393 message: AgentMessage::Custom {
1394 custom_type: "session_info".to_string(),
1395 content: ContentValue::String(name.trim().to_string()),
1396 display: false,
1397 details: None,
1398 timestamp: Utc::now().timestamp_millis(),
1399 },
1400 };
1401 self._append_entry(entry);
1402 id
1403 }
1404
1405 pub fn get_session_name(&self) -> Option<String> {
1407 let entries = self.get_entries();
1408 for entry in entries.iter().rev() {
1409 if let AgentMessage::Custom {
1410 custom_type,
1411 content,
1412 ..
1413 } = &entry.message
1414 {
1415 if custom_type == "session_info" {
1416 return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1417 }
1418 }
1419 }
1420 None
1421 }
1422
1423 pub fn append_custom_message_entry(
1425 &mut self,
1426 custom_type: &str,
1427 content: ContentValue,
1428 display: bool,
1429 details: Option<serde_json::Value>,
1430 ) -> String {
1431 let leaf = self.leaf_id.read().clone();
1432 let id = Uuid::new_v4().to_string();
1433 let entry = SessionEntry {
1434 id: id.clone(),
1435 parent_id: leaf,
1436 timestamp: Utc::now().timestamp_millis(),
1437 message: AgentMessage::Custom {
1438 custom_type: custom_type.to_string(),
1439 content,
1440 display,
1441 details,
1442 timestamp: Utc::now().timestamp_millis(),
1443 },
1444 };
1445 self._append_entry(entry);
1446 id
1447 }
1448
1449 pub fn get_leaf_id(&self) -> Option<String> {
1455 self.leaf_id.read().clone()
1456 }
1457
1458 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1460 self.leaf_id
1461 .read()
1462 .as_ref()
1463 .and_then(|id| self.by_id.read().get(id).cloned())
1464 }
1465
1466 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1468 self.by_id.read().get(id).cloned()
1469 }
1470
1471 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1473 self.by_id
1474 .read()
1475 .values()
1476 .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1477 .cloned()
1478 .collect()
1479 }
1480
1481 pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1483 self.by_id
1484 .read()
1485 .get(id)
1486 .and_then(|e| e.parent_id.as_deref())
1487 .and_then(|pid| self.by_id.read().get(pid).cloned())
1488 }
1489
1490 pub fn get_label(&self, id: &str) -> Option<String> {
1492 self.labels_by_id.read().get(id).cloned()
1493 }
1494
1495 pub fn append_label_change(
1497 &mut self,
1498 target_id: &str,
1499 label: Option<&str>,
1500 ) -> Result<String, String> {
1501 if !self.by_id.read().contains_key(target_id) {
1502 return Err(format!("Entry {} not found", target_id));
1503 }
1504
1505 let leaf = self.leaf_id.read().clone();
1506 let id = Uuid::new_v4().to_string();
1507 let entry = SessionEntry {
1508 id: id.clone(),
1509 parent_id: leaf,
1510 timestamp: Utc::now().timestamp_millis(),
1511 message: AgentMessage::Custom {
1512 custom_type: "label".to_string(),
1513 content: ContentValue::String(label.unwrap_or("").to_string()),
1514 display: false,
1515 details: Some(serde_json::json!({ "targetId": target_id })),
1516 timestamp: Utc::now().timestamp_millis(),
1517 },
1518 };
1519
1520 self._append_entry(entry);
1521
1522 if let Some(l) = label {
1523 self.labels_by_id
1524 .write()
1525 .insert(target_id.to_string(), l.to_string());
1526 self.label_timestamps_by_id
1527 .write()
1528 .insert(target_id.to_string(), Utc::now().to_rfc3339());
1529 } else {
1530 self.labels_by_id.write().remove(target_id);
1531 self.label_timestamps_by_id.write().remove(target_id);
1532 }
1533
1534 Ok(id)
1535 }
1536
1537 pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1539 let mut path = Vec::new();
1540 let leaf_fallback = self.leaf_id.read().clone();
1541 let start_id = from_id.or(leaf_fallback.as_deref());
1542 let Some(start_id) = start_id else {
1543 return path;
1544 };
1545
1546 let by_id = self.by_id.read();
1548 let mut current = by_id.get(start_id).cloned();
1549 while let Some(entry) = current {
1550 path.insert(0, entry.clone());
1551 current = entry
1552 .parent_id
1553 .as_ref()
1554 .and_then(|pid| by_id.get(pid).cloned());
1555 }
1556 path
1557 }
1558
1559 pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1561 self.get_branch(Some(from_id))
1562 }
1563
1564 pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1566 self.get_branch(Some(from_id))
1567 }
1568
1569 pub fn get_depth(&self, id: &str) -> i64 {
1571 let mut depth = 0;
1572 let mut current = self.by_id.read().get(id).cloned();
1573 while let Some(entry) = current {
1574 depth += 1;
1575 current = entry
1576 .parent_id
1577 .as_ref()
1578 .and_then(|pid| self.by_id.read().get(pid).cloned());
1579 }
1580 depth - 1 }
1582
1583 pub fn build_session_context(&self) -> SessionContext {
1585 let entries = self.get_entries();
1586 let leaf_id = self.leaf_id.read().clone();
1587 build_session_context_internal(&entries, leaf_id, None)
1588 }
1589
1590 pub fn get_header(&self) -> Option<SessionHeader> {
1592 self.file_entries.read().iter().find_map(|e| match e {
1593 FileEntry::Header(h) => Some(h.clone()),
1594 _ => None,
1595 })
1596 }
1597
1598 pub fn get_entries(&self) -> Vec<SessionEntry> {
1600 self.by_id.read().values().cloned().collect()
1601 }
1602
1603 pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1606 let entries = self.get_entries();
1607 let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1608 let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1609
1610 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1611 let mut root_ids: Vec<String> = Vec::new();
1612
1613 for entry in &entries {
1615 adj.insert(entry.id.clone(), Vec::new());
1616 }
1617
1618 for entry in &entries {
1620 let is_root = match entry.parent_id.as_deref() {
1621 Some(pid) if pid != entry.id => !adj.contains_key(pid),
1622 _ => true,
1623 };
1624 if is_root {
1625 root_ids.push(entry.id.clone());
1626 } else if let Some(ref pid) = entry.parent_id {
1627 if let Some(children) = adj.get_mut(pid.as_str()) {
1628 children.push(entry.id.clone());
1629 } else {
1630 root_ids.push(entry.id.clone());
1631 }
1632 }
1633 }
1634
1635 let entries_map: HashMap<String, SessionEntry> =
1637 entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1638
1639 fn build(
1641 id: &str,
1642 adj: &HashMap<String, Vec<String>>,
1643 entries_map: &HashMap<String, SessionEntry>,
1644 labels: &HashMap<String, String>,
1645 label_timestamps: &HashMap<String, String>,
1646 ) -> anyhow::Result<SessionTreeNode> {
1647 let entry = entries_map
1648 .get(id)
1649 .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1650 .clone();
1651 let child_ids = adj.get(id).cloned().unwrap_or_default();
1652 let children: Vec<SessionTreeNode> = child_ids
1653 .iter()
1654 .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1655 .collect::<Result<Vec<_>, _>>()?;
1656 Ok(SessionTreeNode {
1657 entry,
1658 children,
1659 label: labels.get(id).cloned(),
1660 label_timestamp: label_timestamps.get(id).cloned(),
1661 })
1662 }
1663
1664 let mut roots = root_ids
1665 .into_iter()
1666 .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1667 .collect::<anyhow::Result<Vec<_>>>()?;
1668
1669 sort_tree_by_timestamp(&mut roots);
1670 Ok(roots)
1671 }
1672
1673 pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1679 if !self.by_id.read().contains_key(branch_from_id) {
1680 return Err(format!("Entry {} not found", branch_from_id));
1681 }
1682 *self.leaf_id.write() = Some(branch_from_id.to_string());
1683 Ok(())
1684 }
1685
1686 pub fn reset_leaf(&mut self) {
1688 *self.leaf_id.write() = None;
1689 }
1690
1691 pub fn branch_with_summary(
1693 &mut self,
1694 branch_from_id: Option<&str>,
1695 summary: &str,
1696 _details: Option<serde_json::Value>,
1697 _from_hook: Option<bool>,
1698 ) -> String {
1699 if let Some(id) = branch_from_id {
1700 if !self.by_id.read().contains_key(id) {
1701 return String::new();
1702 }
1703 }
1704
1705 *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1706
1707 let id = Uuid::new_v4().to_string();
1708 let entry = SessionEntry {
1709 id: id.clone(),
1710 parent_id: branch_from_id.map(|s| s.to_string()),
1711 timestamp: Utc::now().timestamp_millis(),
1712 message: AgentMessage::BranchSummary {
1713 summary: summary.to_string(),
1714 from_id: branch_from_id.unwrap_or("root").to_string(),
1715 timestamp: Utc::now().timestamp_millis(),
1716 },
1717 };
1718
1719 self._append_entry(entry);
1720 id
1721 }
1722
1723 pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1725 self.append_label_change(target_id, Some(label))
1726 }
1727
1728 pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1730 self.append_label_change(target_id, None)
1731 }
1732
1733 pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1739 let entries = self.get_entries();
1740 for entry in entries.iter().rev() {
1741 if let AgentMessage::CompactionSummary { .. } = &entry.message {
1742 return Some(entry.clone());
1743 }
1744 }
1745 None
1746 }
1747
1748 pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1750 self.get_entries()
1751 .iter()
1752 .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1753 .cloned()
1754 .collect()
1755 }
1756
1757 pub fn get_session_stats(&self) -> SessionStats {
1763 let entries = self.get_entries();
1764 let mut message_count = 0i64;
1765 let mut user_message_count = 0i64;
1766 let mut assistant_message_count = 0i64;
1767 let mut total_chars = 0i64;
1768 let mut total_tokens_estimate = 0i64;
1769
1770 for entry in &entries {
1771 if let AgentMessage::User { .. } = &entry.message {
1772 user_message_count += 1;
1773 }
1774 if let AgentMessage::Assistant { .. } = &entry.message {
1775 assistant_message_count += 1;
1776 }
1777 if entry.message.is_user() || entry.message.is_assistant() {
1778 message_count += 1;
1779 let content = entry.content();
1781 let chars = content.len() as i64;
1782 total_chars += chars;
1783 total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1784 }
1785 }
1786
1787 SessionStats {
1788 message_count,
1789 user_message_count,
1790 assistant_message_count,
1791 total_chars,
1792 estimated_tokens: total_tokens_estimate,
1793 }
1794 }
1795
1796 pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1802 let dir = session_dir
1803 .map(|s| s.to_string())
1804 .unwrap_or_else(|| get_default_session_dir(cwd));
1805 list_sessions_from_dir(&dir).await
1806 }
1807
1808 pub async fn list_all() -> Result<Vec<SessionInfo>> {
1810 let sessions_dir = get_sessions_dir();
1811
1812 if !Path::new(&sessions_dir).exists() {
1813 return Ok(Vec::new());
1814 }
1815
1816 let mut all_sessions = Vec::new();
1817 let entries = fs::read_dir(&sessions_dir)?;
1818
1819 for entry in entries {
1820 let entry = entry?;
1821 let path = entry.path();
1822 if path.is_dir() {
1823 if let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await {
1824 all_sessions.extend(sessions);
1825 }
1826 }
1827 }
1828
1829 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1830 Ok(all_sessions)
1831 }
1832
1833 pub fn fork_from(
1835 source_path: &str,
1836 target_cwd: &str,
1837 session_dir: Option<&str>,
1838 ) -> Result<Self, String> {
1839 let source_entries = load_entries_from_file(source_path);
1840 if source_entries.is_empty() {
1841 return Err(format!(
1842 "Cannot fork: source session file is empty or invalid: {}",
1843 source_path
1844 ));
1845 }
1846
1847 let source_header = source_entries.iter().find_map(|e| match e {
1848 FileEntry::Header(h) => Some(h),
1849 _ => None,
1850 });
1851 if source_header.is_none() {
1852 return Err(format!(
1853 "Cannot fork: source session has no header: {}",
1854 source_path
1855 ));
1856 }
1857
1858 let dir = session_dir
1859 .map(|s| s.to_string())
1860 .unwrap_or_else(|| get_default_session_dir(target_cwd));
1861
1862 if !Path::new(&dir).exists() {
1863 let _ = fs::create_dir_all(&dir);
1864 }
1865
1866 let new_session_id = Uuid::new_v4().to_string();
1867 let timestamp = Utc::now().to_rfc3339();
1868 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1869 let short_id = &new_session_id[..8];
1870 let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1871
1872 let new_header = SessionHeader {
1874 entry_type: "session".to_string(),
1875 version: Some(CURRENT_SESSION_VERSION),
1876 id: new_session_id.clone(),
1877 timestamp: timestamp.clone(),
1878 cwd: target_cwd.to_string(),
1879 parent_session: Some(source_path.to_string()),
1880 };
1881
1882 let mut handle = fs::OpenOptions::new()
1883 .create(true)
1884 .truncate(true)
1885 .write(true)
1886 .open(&new_session_file)
1887 .map_err(|e| e.to_string())?;
1888 writeln!(
1889 &mut handle,
1890 "{}",
1891 serde_json::to_string(&new_header).expect("session header serializable")
1892 )
1893 .map_err(|e| e.to_string())?;
1894
1895 for file_entry in &source_entries {
1897 if let FileEntry::Entry(_) = file_entry {
1898 writeln!(
1899 &mut handle,
1900 "{}",
1901 serde_json::to_string(file_entry).expect("session entry serializable")
1902 )
1903 .map_err(|e| e.to_string())?;
1904 }
1905 }
1906
1907 Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1908 }
1909
1910 pub fn delete_session(path: &str) -> Result<()> {
1912 fs::remove_file(path).context("Failed to delete session file")?;
1913 Ok(())
1914 }
1915
1916 pub fn rename_session(&mut self, name: &str) -> String {
1918 self.append_session_info(name)
1919 }
1920
1921 pub async fn new() -> Result<Self> {
1927 Self::new_async().await
1928 }
1929
1930 pub async fn new_async() -> Result<Self> {
1932 let home = dirs::home_dir().context("Cannot find home directory")?;
1933 let base_dir = home.join(".oxi");
1934 let sessions_dir = base_dir.join("sessions");
1935 tokio::fs::create_dir_all(&sessions_dir).await?;
1936 let cwd = std::env::current_dir()
1937 .unwrap_or_else(|_| PathBuf::from("."))
1938 .to_string_lossy()
1939 .to_string();
1940 Ok(Self::in_memory(&cwd))
1941 }
1942
1943 pub fn session_path(&self, id: &Uuid) -> PathBuf {
1945 if let Some(file) = &self.session_file {
1946 PathBuf::from(file)
1947 } else {
1948 PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
1949 }
1950 }
1951
1952 pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
1954 let mut metas = Vec::new();
1956 let session_dir = Path::new(&self.session_dir);
1957 if !session_dir.exists() {
1958 return Ok(metas);
1959 }
1960 let entries = fs::read_dir(session_dir)?;
1961 for entry in entries {
1962 let entry = entry?;
1963 let path = entry.path();
1964 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
1965 let file_name = path
1966 .file_stem()
1967 .unwrap_or_else(|| std::ffi::OsStr::new(""))
1968 .to_string_lossy()
1969 .to_string();
1970 if let Some(uuid_part) = file_name.split('_').next_back() {
1972 if let Ok(uuid) = Uuid::parse_str(uuid_part) {
1973 let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
1974 let now_ts = Utc::now().timestamp_millis();
1975 metas.push(SessionMeta {
1976 id: uuid,
1977 parent_id: None,
1978 root_id: None,
1979 branch_point: None,
1980 created_at: now_ts,
1981 updated_at: mtime
1982 .map(|t| {
1983 let dt: DateTime<Utc> = DateTime::from(t);
1984 dt.timestamp_millis()
1985 })
1986 .unwrap_or(now_ts),
1987 name: None,
1988 });
1989 }
1990 }
1991 }
1992 }
1993 metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
1994 Ok(metas)
1995 }
1996
1997 pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
1999 self._rewrite_file();
2000 Ok(())
2001 }
2002
2003 pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2005 Ok(self.get_entries())
2006 }
2007
2008 pub async fn delete(&self, id: Uuid) -> Result<()> {
2010 let path = self.session_path(&id);
2011 if path.exists() {
2012 fs::remove_file(path).context("Failed to delete session file")?;
2013 }
2014 Ok(())
2015 }
2016
2017 pub async fn branch_from(
2019 &self,
2020 parent_id: Uuid,
2021 entry_id: Uuid,
2022 ) -> Result<(Uuid, Vec<SessionEntry>)> {
2023 let _entry_id_str = entry_id.to_string();
2024 let _parent_id_str = parent_id.to_string();
2025
2026 let _entries = self.get_entries();
2028 let path = self.get_branch(Some(&entry_id.to_string()));
2029
2030 let new_id = Uuid::new_v4();
2031 let new_entries: Vec<SessionEntry> = path
2032 .into_iter()
2033 .map(|e| {
2034 let mut new_entry = e.clone();
2035 new_entry.id = Uuid::new_v4().to_string();
2036 new_entry
2037 })
2038 .collect();
2039
2040 Ok((new_id, new_entries))
2043 }
2044
2045 pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2047 Ok(None)
2049 }
2050
2051 pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2053 self.get_tree(Uuid::nil())
2054 }
2055
2056 pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2058 Ok(())
2059 }
2060
2061 pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2063 Ok(None)
2064 }
2065
2066 pub async fn create_session(&mut self) -> Result<SessionMeta> {
2068 let id = Uuid::new_v4();
2069 let meta = SessionMeta::new(id);
2070 Ok(meta)
2071 }
2072
2073 pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2075 let path = self
2076 .get_session_file()
2077 .ok_or_else(|| "No session file path".to_string())?;
2078 let source_entries = load_entries_from_file(&path);
2079 if source_entries.is_empty() {
2080 return Err("Cannot fork: source session is empty".to_string());
2081 }
2082 let _header = source_entries
2084 .iter()
2085 .find_map(|e| match e {
2086 FileEntry::Header(h) => Some(h),
2087 _ => None,
2088 })
2089 .ok_or_else(|| "Missing session header".to_string())?;
2090 let new_id = Uuid::new_v4().to_string();
2091 let timestamp = chrono::Utc::now().to_rfc3339();
2092 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2093 let short_id = &new_id[..8];
2094 let dir = std::path::Path::new(&path)
2095 .parent()
2096 .map(|p| p.to_string_lossy().into_owned())
2097 .unwrap_or_else(|| ".".to_string());
2098 let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2099 let mut found = false;
2100 let mut new_entries = vec![FileEntry::Header(SessionHeader {
2101 entry_type: "session".to_string(),
2102 version: Some(CURRENT_SESSION_VERSION),
2103 id: new_id.clone(),
2104 timestamp,
2105 cwd: self.get_cwd(),
2106 parent_session: Some(path),
2107 })];
2108 for file_entry in &source_entries {
2109 if let FileEntry::Entry(entry) = file_entry {
2110 let eid = match entry {
2111 SessionEntryEnum::Message(m) => m.base.id.clone(),
2112 SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2113 SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2114 SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2115 SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2116 SessionEntryEnum::Custom(m) => m.base.id.clone(),
2117 SessionEntryEnum::Label(m) => m.base.id.clone(),
2118 SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2119 SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2120 };
2121 if eid == entry_id {
2122 found = true;
2123 let mut entry = entry.clone();
2126 clear_entry_parent_id(&mut entry);
2127 new_entries.push(FileEntry::Entry(entry));
2128 } else if found {
2129 new_entries.push(FileEntry::Entry(entry.clone()));
2130 }
2131 }
2132 }
2133 if !found {
2134 return Err(format!("Entry not found: {}", entry_id));
2135 }
2136 let mut handle = std::fs::OpenOptions::new()
2137 .create(true)
2138 .truncate(true)
2139 .write(true)
2140 .open(&new_file)
2141 .map_err(|e| e.to_string())?;
2142 for entry in &new_entries {
2143 let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2144 writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2145 }
2146 Ok(new_file)
2147 }
2148}
2149
2150fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2157 match entry {
2158 SessionEntryEnum::Message(m) => m.base.parent_id = None,
2159 SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2160 SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2161 SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2162 SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2163 SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2164 SessionEntryEnum::Label(m) => m.base.parent_id = None,
2165 SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2166 SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2167 }
2168}
2169
2170fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2172 match entry {
2173 SessionEntryEnum::Message(m) => Some(SessionEntry {
2174 id: m.base.id.clone(),
2175 parent_id: m.base.parent_id.clone(),
2176 timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2177 .map(|dt| dt.timestamp_millis())
2178 .unwrap_or(0),
2179 message: m.message.clone(),
2180 }),
2181 _ => None, }
2183}
2184
2185fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2187 let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2188 .map(|dt| dt.to_rfc3339())
2189 .unwrap_or_else(|| Utc::now().to_rfc3339());
2190
2191 SessionEntryEnum::Message(SessionMessageEntry {
2192 base: SessionEntryBase {
2193 entry_type: "message".to_string(),
2194 id: entry.id.clone(),
2195 parent_id: entry.parent_id.clone(),
2196 timestamp,
2197 },
2198 message: entry.message.clone(),
2199 })
2200}
2201
2202#[derive(Debug, Clone)]
2208pub struct SessionStats {
2209 pub message_count: i64,
2211 pub user_message_count: i64,
2213 pub assistant_message_count: i64,
2215 pub total_chars: i64,
2217 pub estimated_tokens: i64,
2219}
2220
2221#[derive(Debug, Clone)]
2227pub struct NewSessionOptions {
2228 pub id: Option<String>,
2230 pub parent_session: Option<String>,
2232}
2233
2234pub fn get_default_session_dir(cwd: &str) -> String {
2240 let agent_dir = get_agent_dir();
2241 let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2242 let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2243
2244 if !Path::new(&session_dir).exists() {
2245 let _ = fs::create_dir_all(&session_dir);
2246 }
2247
2248 session_dir
2249}
2250
2251fn get_agent_dir() -> String {
2252 dirs::home_dir()
2253 .map(|h| h.join(".oxi").to_string_lossy().to_string())
2254 .unwrap_or_else(|| ".oxi".to_string())
2255}
2256
2257fn get_sessions_dir() -> String {
2258 format!("{}/sessions", get_agent_dir())
2259}
2260
2261fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2263 if !Path::new(file_path).exists() {
2264 return Vec::new();
2265 }
2266
2267 let file = match File::open(file_path) {
2268 Ok(f) => f,
2269 Err(_) => return Vec::new(),
2270 };
2271
2272 let reader = BufReader::new(file);
2273 let mut entries = Vec::new();
2274
2275 for line in reader.lines() {
2276 let line = match line {
2277 Ok(l) => l,
2278 Err(_) => continue,
2279 };
2280 if line.trim().is_empty() {
2281 continue;
2282 }
2283 match serde_json::from_str::<FileEntry>(&line) {
2284 Ok(entry) => entries.push(entry),
2285 Err(_) => continue,
2286 }
2287 }
2288
2289 if entries.is_empty() {
2291 return entries;
2292 }
2293 let header = match &entries[0] {
2294 FileEntry::Header(h) => h,
2295 _ => return Vec::new(),
2296 };
2297 if header.entry_type != "session" || header.id.is_empty() {
2298 return Vec::new();
2299 }
2300
2301 entries
2302}
2303
2304fn is_valid_session_file(file_path: &str) -> bool {
2306 if let Ok(mut file) = File::open(file_path) {
2307 use std::io::Read;
2308 let mut buffer = vec![0u8; 512];
2309 if let Ok(bytes_read) = file.read(&mut buffer) {
2310 if let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec()) {
2311 if let Some(first_line) = content.split('\n').next() {
2312 if let Ok(header) = serde_json::from_str::<SessionHeader>(first_line) {
2313 return header.entry_type == "session" && !header.id.is_empty();
2314 }
2315 }
2316 }
2317 }
2318 }
2319 false
2320}
2321
2322pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2324 let dir = get_default_session_dir(cwd);
2325 find_most_recent_session(&dir)
2326}
2327
2328fn find_most_recent_session(session_dir: &str) -> Option<String> {
2329 if !Path::new(session_dir).exists() {
2330 return None;
2331 }
2332
2333 let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2334
2335 if let Ok(entries) = fs::read_dir(session_dir) {
2336 for entry in entries.flatten() {
2337 let path = entry.path();
2338 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2339 if let Some(path_str) = path.to_str() {
2340 if is_valid_session_file(path_str) {
2341 if let Ok(metadata) = entry.metadata() {
2342 if let Ok(mtime) = metadata.modified() {
2343 files.push((path_str.to_string(), mtime));
2344 }
2345 }
2346 }
2347 }
2348 }
2349 }
2350 }
2351
2352 files.sort_by_key(|b| std::cmp::Reverse(b.1));
2353 files.into_iter().next().map(|(p, _)| p)
2354}
2355
2356pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2358 let path = input.trim();
2359 if path.is_empty() {
2360 return Err("Empty path".to_string());
2361 }
2362 let resolved = if let Some(rest) = path.strip_prefix('~') {
2363 if rest.is_empty() {
2364 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2365 home.to_string_lossy().into_owned()
2366 } else if let Some(rest) = rest.strip_prefix('/') {
2367 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2368 format!("{}/{}", home.to_string_lossy(), rest)
2369 } else {
2370 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2371 format!("{}/{}", home.to_string_lossy(), rest)
2372 }
2373 } else if path.starts_with('/') || path.contains(':') {
2374 path.to_string()
2375 } else {
2376 if let Some(stripped) = path.strip_prefix("./") {
2377 format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2378 } else {
2379 format!("{}/{}", cwd.trim_end_matches('/'), path)
2380 }
2381 };
2382 let p = std::path::Path::new(&resolved);
2383 p.canonicalize()
2384 .map(|c| c.to_string_lossy().into_owned())
2385 .or(Ok(resolved))
2386}
2387
2388fn build_session_context_internal(
2390 entries: &[SessionEntry],
2391 leaf_id: Option<String>,
2392 _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2393) -> SessionContext {
2394 let leaf: Option<&SessionEntry> = leaf_id
2396 .as_ref()
2397 .and_then(|id| entries.iter().find(|e| e.id == *id));
2398
2399 let leaf = leaf.or_else(|| entries.last());
2400
2401 let Some(leaf) = leaf else {
2402 return SessionContext {
2403 messages: Vec::new(),
2404 thinking_level: "off".to_string(),
2405 model: None,
2406 };
2407 };
2408
2409 let mut path: Vec<&SessionEntry> = Vec::new();
2411 let mut current: Option<&SessionEntry> = Some(leaf);
2412 while let Some(entry) = current {
2413 path.insert(0, entry);
2414 current = entry
2415 .parent_id
2416 .as_ref()
2417 .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2418 }
2419
2420 let mut thinking_level = "off".to_string();
2422 let mut model: Option<ModelInfo> = None;
2423
2424 for entry in &path {
2425 if let AgentMessage::Assistant {
2426 provider, model_id, ..
2427 } = &entry.message
2428 {
2429 model = Some(ModelInfo {
2430 provider: provider.clone().unwrap_or_default(),
2431 model_id: model_id.clone().unwrap_or_default(),
2432 });
2433 }
2434 if let AgentMessage::Custom {
2435 custom_type,
2436 content,
2437 ..
2438 } = &entry.message
2439 {
2440 if custom_type == "thinking_level_change" {
2441 thinking_level = content.as_str().to_string();
2442 }
2443 }
2444 }
2445
2446 let messages: Vec<AgentMessage> = path
2448 .iter()
2449 .filter(|e| {
2450 e.message.is_user()
2451 || e.message.is_assistant()
2452 || matches!(&e.message, AgentMessage::BranchSummary { .. })
2453 || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2454 })
2455 .map(|e| e.message.clone())
2456 .collect();
2457
2458 SessionContext {
2459 messages,
2460 thinking_level,
2461 model,
2462 }
2463}
2464
2465fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2467 nodes.sort_by_key(|a| a.entry.timestamp);
2468
2469 for node in nodes {
2470 sort_tree_by_timestamp(&mut node.children);
2471 }
2472}
2473
2474async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2476 if !Path::new(dir).exists() {
2477 return Ok(Vec::new());
2478 }
2479
2480 let mut sessions = Vec::new();
2481
2482 let entries = fs::read_dir(dir)?;
2483 let files: Vec<String> = entries
2484 .filter_map(|e| e.ok())
2485 .filter(|e| {
2486 e.path()
2487 .extension()
2488 .map(|ext| ext == "jsonl")
2489 .unwrap_or(false)
2490 })
2491 .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2492 .collect();
2493
2494 for file in files {
2495 if let Some(info) = build_session_info(&file).await {
2496 sessions.push(info);
2497 }
2498 }
2499
2500 Ok(sessions)
2501}
2502
2503async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2505 let content = fs::read_to_string(file_path).ok()?;
2506 let entries = parse_session_entries(&content)?;
2507
2508 if entries.is_empty() {
2509 return None;
2510 }
2511
2512 let header = match &entries[0] {
2513 FileEntry::Header(h) => h,
2514 _ => return None,
2515 };
2516
2517 let stats = fs::metadata(file_path).ok()?;
2518 let mut message_count = 0i64;
2519 let mut first_message = String::new();
2520 let mut all_messages = Vec::new();
2521 let mut name: Option<String> = None;
2522
2523 for entry in &entries {
2524 if let FileEntry::Entry(e) = entry {
2525 if let SessionEntryEnum::SessionInfo(si) = e {
2527 name = si
2528 .name
2529 .clone()
2530 .map(|n| n.trim().to_string())
2531 .filter(|n| !n.is_empty());
2532 }
2533 if let SessionEntryEnum::Message(m) = e {
2535 if m.message.is_user() {
2536 message_count += 1;
2537 let text = m.message.content();
2538 if !text.is_empty() {
2539 all_messages.push(text.clone());
2540 if first_message.is_empty() {
2541 first_message = text;
2542 }
2543 }
2544 }
2545 }
2546 }
2547 }
2548
2549 if message_count == 0 {
2551 return None;
2552 }
2553
2554 let cwd = header.cwd.clone();
2555 let parent_session_path = header.parent_session.clone();
2556 let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2557 .map(|dt| dt.with_timezone(&Utc))
2558 .unwrap_or_else(|_| Utc::now());
2559 let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2560
2561 Some(SessionInfo {
2562 path: file_path.to_string(),
2563 id: header.id.clone(),
2564 cwd,
2565 name,
2566 parent_session_path,
2567 created,
2568 modified,
2569 message_count,
2570 first_message: if first_message.is_empty() {
2571 "(no messages)".to_string()
2572 } else {
2573 first_message
2574 },
2575 all_messages_text: all_messages.join(" "),
2576 })
2577}
2578
2579fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2581 let mut entries = Vec::new();
2582
2583 for line in content.trim().lines() {
2584 if line.trim().is_empty() {
2585 continue;
2586 }
2587 if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2588 entries.push(entry);
2589 }
2590 }
2591
2592 Some(entries)
2593}
2594
2595fn get_session_modified_date(
2597 entries: &[FileEntry],
2598 header_timestamp: &str,
2599 stats: &std::fs::Metadata,
2600) -> DateTime<Utc> {
2601 let last_activity_time = get_last_activity_time(entries);
2602 if let Some(t) = last_activity_time {
2603 if t > 0 {
2604 return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2605 }
2606 }
2607
2608 let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2609 .map(|dt| dt.timestamp_millis())
2610 .unwrap_or(-1);
2611
2612 if header_time > 0 {
2613 return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2614 }
2615
2616 if let Ok(mtime) = stats.modified() {
2617 return DateTime::from(mtime);
2618 }
2619
2620 Utc::now()
2621}
2622
2623fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2625 let mut last_activity: Option<i64> = None;
2626
2627 for entry in entries {
2628 let entry = match entry {
2629 FileEntry::Entry(e) => e,
2630 _ => continue,
2631 };
2632
2633 if let SessionEntryEnum::Message(m) = entry {
2634 if m.message.is_user() || m.message.is_assistant() {
2635 last_activity = Some(std::cmp::max(
2636 last_activity.unwrap_or(0),
2637 m.base.timestamp.parse().unwrap_or(0),
2638 ));
2639 }
2640 }
2641 }
2642
2643 last_activity
2644}
2645
2646#[cfg(test)]
2651mod tests {
2652 use super::*;
2653
2654 #[test]
2655 fn test_session_creation() {
2656 let manager = SessionManager::in_memory("/tmp");
2657 assert!(!manager.get_session_id().is_empty());
2658 assert_eq!(manager.get_entries().len(), 0);
2659 }
2660
2661 #[test]
2662 fn test_append_message() {
2663 let mut manager = SessionManager::in_memory("/tmp");
2664 let id = manager.append_message(AgentMessage::User {
2665 content: ContentValue::String("Hello".to_string()),
2666 });
2667 assert!(!id.is_empty());
2668 assert_eq!(manager.get_entries().len(), 1);
2669 assert_eq!(manager.get_leaf_id(), Some(id));
2670 }
2671
2672 #[test]
2673 fn test_tree_traversal() {
2674 let mut manager = SessionManager::in_memory("/tmp");
2675 let id1 = manager.append_message(AgentMessage::User {
2676 content: ContentValue::String("Hello".to_string()),
2677 });
2678 let id2 = manager.append_message(AgentMessage::Assistant {
2679 content: vec![],
2680 provider: None,
2681 model_id: None,
2682 usage: None,
2683 stop_reason: None,
2684 });
2685
2686 let branch = manager.get_branch(None);
2688 assert_eq!(branch.len(), 2);
2689
2690 let branch = manager.get_branch(Some(&id1));
2692 assert_eq!(branch.len(), 1);
2693
2694 let children = manager.get_children(&id1);
2696 assert_eq!(children.len(), 1);
2697
2698 let parent = manager.get_parent(&id2);
2700 assert!(parent.is_some());
2701 assert_eq!(parent.unwrap().id, id1);
2702 }
2703
2704 #[test]
2705 fn test_branching() {
2706 let mut manager = SessionManager::in_memory("/tmp");
2707 let id1 = manager.append_message(AgentMessage::User {
2708 content: ContentValue::String("Hello".to_string()),
2709 });
2710 let _id2 = manager.append_message(AgentMessage::Assistant {
2711 content: vec![],
2712 provider: None,
2713 model_id: None,
2714 usage: None,
2715 stop_reason: None,
2716 });
2717 let _id3 = manager.append_message(AgentMessage::User {
2718 content: ContentValue::String("How are you?".to_string()),
2719 });
2720
2721 manager.branch(&id1).unwrap();
2723 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2724
2725 let id4 = manager.append_message(AgentMessage::Assistant {
2727 content: vec![],
2728 provider: None,
2729 model_id: None,
2730 usage: None,
2731 stop_reason: None,
2732 });
2733
2734 assert_eq!(manager.get_entries().len(), 4);
2736
2737 assert_eq!(manager.get_leaf_id(), Some(id4));
2739
2740 let tree = manager.get_tree(Uuid::nil()).unwrap();
2742 assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 2); }
2745
2746 #[test]
2747 fn test_session_context() {
2748 let mut manager = SessionManager::in_memory("/tmp");
2749 manager.append_message(AgentMessage::User {
2750 content: ContentValue::String("Hello".to_string()),
2751 });
2752 manager.append_message(AgentMessage::Assistant {
2753 content: vec![AssistantContentBlock::Text {
2754 text: "Hi there!".to_string(),
2755 }],
2756 provider: Some("test".to_string()),
2757 model_id: Some("model".to_string()),
2758 usage: None,
2759 stop_reason: None,
2760 });
2761
2762 let context = manager.build_session_context();
2763 assert_eq!(context.messages.len(), 2);
2764 assert!(context.model.is_some());
2765 }
2766
2767 #[test]
2768 fn test_compaction_entry() {
2769 let mut manager = SessionManager::in_memory("/tmp");
2770 let id1 = manager.append_message(AgentMessage::User {
2771 content: ContentValue::String("First message".to_string()),
2772 });
2773 let _id2 = manager.append_message(AgentMessage::Assistant {
2774 content: vec![],
2775 provider: None,
2776 model_id: None,
2777 usage: None,
2778 stop_reason: None,
2779 });
2780
2781 let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2782 assert!(!id3.is_empty());
2783
2784 let latest = manager.get_latest_compaction_entry();
2785 assert!(latest.is_some());
2786 }
2787
2788 #[test]
2789 fn test_labels() {
2790 let mut manager = SessionManager::in_memory("/tmp");
2791 let id1 = manager.append_message(AgentMessage::User {
2792 content: ContentValue::String("Hello".to_string()),
2793 });
2794
2795 manager.add_label(&id1, "important").unwrap();
2796 assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2797
2798 manager.remove_label(&id1).unwrap();
2799 assert_eq!(manager.get_label(&id1), None);
2800 }
2801
2802 fn user_msg(text: &str) -> AgentMessage {
2808 AgentMessage::User {
2809 content: ContentValue::String(text.to_string()),
2810 }
2811 }
2812
2813 fn assistant_msg(text: &str) -> AgentMessage {
2815 AgentMessage::Assistant {
2816 content: vec![AssistantContentBlock::Text {
2817 text: text.to_string(),
2818 }],
2819 provider: Some("anthropic".to_string()),
2820 model_id: Some("claude-test".to_string()),
2821 usage: None,
2822 stop_reason: None,
2823 }
2824 }
2825
2826 fn bare_assistant_msg() -> AgentMessage {
2828 AgentMessage::Assistant {
2829 content: vec![],
2830 provider: None,
2831 model_id: None,
2832 usage: None,
2833 stop_reason: None,
2834 }
2835 }
2836
2837 #[test]
2842 fn test_append_thinking_level_change_integrates() {
2843 let mut manager = SessionManager::in_memory("/tmp");
2844 let msg_id = manager.append_message(user_msg("hello"));
2845 let thinking_id = manager.append_thinking_level_change("high");
2846 let msg2_id = manager.append_message(assistant_msg("response"));
2847
2848 let entries = manager.get_entries();
2849 assert_eq!(entries.len(), 3);
2850
2851 let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2853 assert_eq!(thinking_entry.parent_id, Some(msg_id));
2854
2855 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2856 assert_eq!(msg2.parent_id, Some(thinking_id));
2857 }
2858
2859 #[test]
2860 fn test_append_model_change_integrates() {
2861 let mut manager = SessionManager::in_memory("/tmp");
2862 let msg_id = manager.append_message(user_msg("hello"));
2863 let model_id = manager.append_model_change("openai", "gpt-4");
2864 let msg2_id = manager.append_message(assistant_msg("response"));
2865
2866 let entries = manager.get_entries();
2867 let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2868 assert_eq!(model_entry.parent_id, Some(msg_id));
2869
2870 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2871 assert_eq!(msg2.parent_id, Some(model_id));
2872 }
2873
2874 #[test]
2875 fn test_append_compaction_integrates_into_tree() {
2876 let mut manager = SessionManager::in_memory("/tmp");
2877 let id1 = manager.append_message(user_msg("1"));
2878 let id2 = manager.append_message(assistant_msg("2"));
2879 let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2880 let id3 = manager.append_message(user_msg("3"));
2881
2882 let entries = manager.get_entries();
2883 let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2884 assert_eq!(compaction.parent_id, Some(id2));
2885
2886 let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2887 assert_eq!(msg3.parent_id, Some(compaction_id));
2888
2889 if let AgentMessage::CompactionSummary {
2891 summary,
2892 tokens_before,
2893 ..
2894 } = &compaction.message
2895 {
2896 assert_eq!(summary, "summary");
2897 assert_eq!(*tokens_before, 1000);
2898 } else {
2899 panic!("Expected CompactionSummary");
2900 }
2901 }
2902
2903 #[test]
2904 fn test_leaf_pointer_advances() {
2905 let mut manager = SessionManager::in_memory("/tmp");
2906 assert!(manager.get_leaf_id().is_none());
2907
2908 let id1 = manager.append_message(user_msg("1"));
2909 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2910
2911 let id2 = manager.append_message(assistant_msg("2"));
2912 assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2913
2914 let id3 = manager.append_thinking_level_change("high");
2915 assert_eq!(manager.get_leaf_id(), Some(id3));
2916 }
2917
2918 #[test]
2919 fn test_get_entry() {
2920 let mut manager = SessionManager::in_memory("/tmp");
2921 assert!(manager.get_entry("nonexistent").is_none());
2922
2923 let id1 = manager.append_message(user_msg("first"));
2924 let id2 = manager.append_message(assistant_msg("second"));
2925
2926 let entry1 = manager.get_entry(&id1);
2927 assert!(entry1.is_some());
2928 assert!(entry1.unwrap().message.is_user());
2929
2930 let entry2 = manager.get_entry(&id2);
2931 assert!(entry2.is_some());
2932 assert!(entry2.unwrap().message.is_assistant());
2933 }
2934
2935 #[test]
2936 fn test_get_leaf_entry() {
2937 let manager = SessionManager::in_memory("/tmp");
2938 assert!(manager.get_leaf_entry().is_none());
2939
2940 let mut manager = SessionManager::in_memory("/tmp");
2941 manager.append_message(user_msg("1"));
2942 let id2 = manager.append_message(assistant_msg("2"));
2943
2944 let leaf = manager.get_leaf_entry();
2945 assert!(leaf.is_some());
2946 assert_eq!(leaf.unwrap().id, id2);
2947 }
2948
2949 #[test]
2954 fn test_get_branch_full_path_root_to_leaf() {
2955 let mut manager = SessionManager::in_memory("/tmp");
2956 let id1 = manager.append_message(user_msg("1"));
2957 let id2 = manager.append_message(assistant_msg("2"));
2958 let id3 = manager.append_thinking_level_change("high");
2959 let id4 = manager.append_message(user_msg("3"));
2960
2961 let branch = manager.get_branch(None);
2962 assert_eq!(branch.len(), 4);
2963 assert_eq!(branch[0].id, id1);
2964 assert_eq!(branch[1].id, id2);
2965 assert_eq!(branch[2].id, id3);
2966 assert_eq!(branch[3].id, id4);
2967 }
2968
2969 #[test]
2970 fn test_get_branch_from_specific_entry() {
2971 let mut manager = SessionManager::in_memory("/tmp");
2972 let id1 = manager.append_message(user_msg("1"));
2973 let id2 = manager.append_message(assistant_msg("2"));
2974 manager.append_message(user_msg("3"));
2975 manager.append_message(assistant_msg("4"));
2976
2977 let branch = manager.get_branch(Some(&id2));
2978 assert_eq!(branch.len(), 2);
2979 assert_eq!(branch[0].id, id1);
2980 assert_eq!(branch[1].id, id2);
2981 }
2982
2983 #[test]
2988 fn test_multiple_branches_at_same_point() {
2989 let mut manager = SessionManager::in_memory("/tmp");
2990 manager.append_message(user_msg("root"));
2991 let id2 = manager.append_message(bare_assistant_msg());
2992
2993 manager.branch(&id2).unwrap();
2995 let id_a = manager.append_message(user_msg("branch-A"));
2996
2997 manager.branch(&id2).unwrap();
2999 let id_b = manager.append_message(user_msg("branch-B"));
3000
3001 manager.branch(&id2).unwrap();
3003 let id_c = manager.append_message(user_msg("branch-C"));
3004
3005 let tree = manager.get_tree(Uuid::nil()).unwrap();
3006 let node2 = &tree[0].children[0];
3007 assert_eq!(node2.entry.id, id2);
3008 assert_eq!(node2.children.len(), 3);
3009
3010 let mut branch_ids: Vec<String> =
3011 node2.children.iter().map(|c| c.entry.id.clone()).collect();
3012 branch_ids.sort();
3013 let mut expected = vec![id_a, id_b, id_c];
3014 expected.sort();
3015 assert_eq!(branch_ids, expected);
3016 }
3017
3018 #[test]
3023 fn test_deep_branching() {
3024 let mut manager = SessionManager::in_memory("/tmp");
3025
3026 manager.append_message(user_msg("1"));
3028 let id2 = manager.append_message(bare_assistant_msg());
3029 let id3 = manager.append_message(user_msg("3"));
3030 manager.append_message(bare_assistant_msg());
3031
3032 manager.branch(&id2).unwrap();
3034 let id5 = manager.append_message(user_msg("5"));
3035 manager.append_message(bare_assistant_msg());
3036
3037 manager.branch(&id5).unwrap();
3039 manager.append_message(user_msg("7"));
3040
3041 let tree = manager.get_tree(Uuid::nil()).unwrap();
3042
3043 let node2 = &tree[0].children[0];
3045 assert_eq!(node2.children.len(), 2);
3046
3047 let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3048 assert_eq!(node5.children.len(), 2); let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3051 assert_eq!(node3.children.len(), 1); }
3053
3054 #[test]
3059 fn test_branch_with_summary_inserts_and_advances() {
3060 let mut manager = SessionManager::in_memory("/tmp");
3061 let id1 = manager.append_message(user_msg("1"));
3062 manager.append_message(bare_assistant_msg());
3063 manager.append_message(user_msg("3"));
3064
3065 let summary_id =
3066 manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3067 assert!(!summary_id.is_empty());
3068 assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3069
3070 let entries = manager.get_entries();
3072 let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3073 assert_eq!(summary_entry.parent_id, Some(id1));
3074
3075 if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3076 assert_eq!(summary, "Summary of abandoned work");
3077 } else {
3078 panic!("Expected BranchSummary");
3079 }
3080 }
3081
3082 #[test]
3087 fn test_build_session_context_returns_branch_messages() {
3088 let mut manager = SessionManager::in_memory("/tmp");
3089
3090 manager.append_message(user_msg("msg1"));
3092 let id2 = manager.append_message(bare_assistant_msg());
3093 manager.append_message(user_msg("msg3"));
3094
3095 manager.branch(&id2).unwrap();
3097 manager.append_message(assistant_msg("msg4-branch"));
3098
3099 let ctx = manager.build_session_context();
3100 assert_eq!(ctx.messages.len(), 3);
3102 assert!(ctx.messages[0].is_user());
3103 assert!(ctx.messages[1].is_assistant());
3104 assert!(ctx.messages[2].is_assistant());
3105 }
3106
3107 #[test]
3108 fn test_build_session_context_follows_branch_path() {
3109 let mut manager = SessionManager::in_memory("/tmp");
3112 manager.append_message(user_msg("start"));
3113 let id2 = manager.append_message(bare_assistant_msg());
3114 manager.append_message(user_msg("branch A"));
3115
3116 manager.branch(&id2).unwrap();
3118 manager.append_message(user_msg("branch B"));
3119
3120 let ctx = manager.build_session_context();
3121 assert_eq!(ctx.messages.len(), 3);
3122 let last = ctx.messages.last().unwrap();
3124 assert_eq!(last.content(), "branch B");
3125 }
3126
3127 #[test]
3128 fn test_build_session_context_includes_branch_summary() {
3129 let mut manager = SessionManager::in_memory("/tmp");
3130 manager.append_message(user_msg("start"));
3131 let id2 = manager.append_message(bare_assistant_msg());
3132 manager.append_message(user_msg("abandoned path"));
3133
3134 manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3136 manager.append_message(user_msg("new direction"));
3137
3138 let ctx = manager.build_session_context();
3139 assert!(ctx.messages.len() >= 3);
3141
3142 let has_summary = ctx.messages.iter().any(|m| {
3144 if let AgentMessage::BranchSummary { summary, .. } = m {
3145 summary == "Summary of abandoned work"
3146 } else {
3147 false
3148 }
3149 });
3150 assert!(has_summary, "Branch summary should be in context messages");
3151 }
3152
3153 #[test]
3154 fn test_build_session_context_with_compaction() {
3155 let mut manager = SessionManager::in_memory("/tmp");
3156
3157 let id1 = manager.append_message(user_msg("first"));
3159 manager.append_message(assistant_msg("response1"));
3160 manager.append_message(user_msg("second"));
3161 manager.append_message(assistant_msg("response2"));
3162
3163 manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3165
3166 manager.append_message(user_msg("third"));
3168 manager.append_message(assistant_msg("response3"));
3169
3170 let ctx = manager.build_session_context();
3171 assert!(ctx.messages.len() >= 4); let compaction_entries = manager.get_compaction_entries();
3177 assert_eq!(compaction_entries.len(), 1);
3178 }
3179
3180 #[test]
3181 fn test_build_session_context_tracks_thinking_level() {
3182 let mut manager = SessionManager::in_memory("/tmp");
3183 manager.append_message(user_msg("hello"));
3184 manager.append_thinking_level_change("high");
3185 manager.append_message(assistant_msg("thinking hard"));
3186
3187 let ctx = manager.build_session_context();
3188 assert_eq!(ctx.thinking_level, "high");
3189 }
3190
3191 #[test]
3196 fn test_labels_in_tree_nodes() {
3197 let mut manager = SessionManager::in_memory("/tmp");
3198 let id1 = manager.append_message(user_msg("hello"));
3199 let id2 = manager.append_message(assistant_msg("hi"));
3200
3201 manager.add_label(&id1, "start").unwrap();
3202 manager.add_label(&id2, "response").unwrap();
3203
3204 let tree = manager.get_tree(Uuid::nil()).unwrap();
3205 let node1 = &tree[0];
3206 assert_eq!(node1.label, Some("start".to_string()));
3207
3208 let node2 = &node1.children[0];
3209 assert_eq!(node2.label, Some("response".to_string()));
3210 }
3211
3212 #[test]
3213 fn test_last_label_wins() {
3214 let mut manager = SessionManager::in_memory("/tmp");
3215 let id1 = manager.append_message(user_msg("hello"));
3216
3217 manager.add_label(&id1, "first").unwrap();
3218 manager.add_label(&id1, "second").unwrap();
3219 manager.add_label(&id1, "third").unwrap();
3220
3221 assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3222 }
3223
3224 #[test]
3229 fn test_branch_throws_for_nonexistent() {
3230 let mut manager = SessionManager::in_memory("/tmp");
3231 manager.append_message(user_msg("hello"));
3232
3233 let result = manager.branch("nonexistent");
3234 assert!(result.is_err());
3235 }
3236
3237 #[test]
3242 fn test_labels_not_in_session_context() {
3243 let mut manager = SessionManager::in_memory("/tmp");
3244 let msg_id = manager.append_message(user_msg("hello"));
3245 manager.add_label(&msg_id, "checkpoint").unwrap();
3246
3247 let ctx = manager.build_session_context();
3248 assert_eq!(ctx.messages.len(), 1);
3250 assert!(ctx.messages[0].is_user());
3251 }
3252
3253 #[test]
3258 fn test_custom_entry_integrates_into_tree() {
3259 let mut manager = SessionManager::in_memory("/tmp");
3260 let msg_id = manager.append_message(user_msg("hello"));
3261 let custom_id =
3262 manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3263 let msg2_id = manager.append_message(assistant_msg("response"));
3264
3265 let entries = manager.get_entries();
3266 let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3267 assert_eq!(custom.parent_id, Some(msg_id));
3268
3269 if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3270 assert_eq!(custom_type, "my_data");
3271 } else {
3272 panic!("Expected Custom message");
3273 }
3274
3275 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3276 assert_eq!(msg2.parent_id, Some(custom_id));
3277
3278 let ctx = manager.build_session_context();
3280 assert_eq!(ctx.messages.len(), 2);
3282 }
3283
3284 #[test]
3289 fn test_get_branch_empty_session() {
3290 let manager = SessionManager::in_memory("/tmp");
3291 let branch = manager.get_branch(None);
3292 assert!(branch.is_empty());
3293 }
3294
3295 #[test]
3296 fn test_get_tree_empty_session() {
3297 let manager = SessionManager::in_memory("/tmp");
3298 let tree = manager.get_tree(Uuid::nil()).unwrap();
3299 assert!(tree.is_empty());
3300 }
3301
3302 #[test]
3307 fn test_complex_tree_with_branches_and_compaction() {
3308 let mut manager = SessionManager::in_memory("/tmp");
3309
3310 manager.append_message(user_msg("start"));
3312 manager.append_message(assistant_msg("r1"));
3313 let id3 = manager.append_message(user_msg("q2"));
3314 manager.append_message(assistant_msg("r2"));
3315 manager.append_compaction("Compacted history", &id3, 1000, None, None);
3316 manager.append_message(user_msg("q3"));
3317 manager.append_message(assistant_msg("r3"));
3318
3319 manager.branch(&id3).unwrap();
3321 manager.append_message(user_msg("wrong path"));
3322 manager.append_message(assistant_msg("wrong response"));
3323
3324 manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3326 manager.append_message(user_msg("better approach"));
3327
3328 let tree = manager.get_tree(Uuid::nil()).unwrap();
3329 assert_eq!(tree.len(), 1);
3331
3332 let root = &tree[0];
3334 assert!(root.entry.message.is_user());
3335 }
3336
3337 #[test]
3342 fn test_multiple_compactions_returns_latest() {
3343 let mut manager = SessionManager::in_memory("/tmp");
3344 let id1 = manager.append_message(user_msg("a"));
3345 manager.append_message(bare_assistant_msg());
3346 manager.append_compaction("First summary", &id1, 1000, None, None);
3347 manager.append_message(user_msg("c"));
3348 manager.append_message(bare_assistant_msg());
3349 manager.append_compaction("Second summary", &id1, 2000, None, None);
3350
3351 let compactions = manager.get_compaction_entries();
3353 assert_eq!(compactions.len(), 2);
3354
3355 let latest = manager.get_latest_compaction_entry();
3357 assert!(latest.is_some());
3358 }
3359
3360 #[test]
3365 fn test_get_all_compaction_entries() {
3366 let mut manager = SessionManager::in_memory("/tmp");
3367 let id1 = manager.append_message(user_msg("a"));
3368 manager.append_message(bare_assistant_msg());
3369 manager.append_compaction("First", &id1, 1000, None, None);
3370 manager.append_message(user_msg("b"));
3371 manager.append_message(bare_assistant_msg());
3372 manager.append_compaction("Second", &id1, 2000, None, None);
3373
3374 let compactions = manager.get_compaction_entries();
3375 assert_eq!(compactions.len(), 2);
3376 }
3377}