Skip to main content

matrixcode_core/
session.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use uuid::Uuid;
6
7use crate::compress::CompressionHistoryEntry;
8use crate::providers::Message;
9
10/// Session metadata stored in the index.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionMetadata {
13    /// Unique session identifier (UUID).
14    pub id: String,
15    /// User-defined session name (optional).
16    pub name: Option<String>,
17    /// Project path this session is associated with (optional).
18    pub project_path: Option<String>,
19    /// When the session was created.
20    pub created_at: DateTime<Utc>,
21    /// When the session was last updated.
22    pub updated_at: DateTime<Utc>,
23    /// Number of messages in the session.
24    pub message_count: usize,
25    /// Last input tokens reported.
26    pub last_input_tokens: u64,
27    /// Cumulative output tokens.
28    pub total_output_tokens: u64,
29    /// Compression history entries.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub compression_history: Vec<CompressionHistoryEntry>,
32}
33
34impl SessionMetadata {
35    /// Create a new session metadata with a fresh UUID and auto-generated name.
36    pub fn new(project_path: Option<&Path>) -> Self {
37        let now = Utc::now();
38        Self {
39            id: Uuid::new_v4().to_string(),
40            name: None, // Will be auto-generated from first meaningful user message
41            project_path: project_path.map(|p| p.to_string_lossy().to_string()),
42            created_at: now,
43            updated_at: now,
44            message_count: 0,
45            last_input_tokens: 0,
46            total_output_tokens: 0,
47            compression_history: Vec::new(),
48        }
49    }
50
51    /// Generate a friendly time-based name for the session.
52    /// Format: "YYYY-MM-DD HH:mm" (e.g., "2024-01-15 14:30")
53    fn generate_time_name(time: DateTime<Utc>) -> String {
54        // Use local timezone for display
55        let local: chrono::DateTime<chrono::Local> = time.with_timezone(&chrono::Local);
56        local.format("%Y-%m-%d %H:%M").to_string()
57    }
58
59    /// Add a compression entry to history.
60    pub fn add_compression_entry(&mut self, entry: CompressionHistoryEntry) {
61        self.compression_history.push(entry);
62        // Keep only last 10 entries to avoid bloat
63        if self.compression_history.len() > 10 {
64            self.compression_history.remove(0);
65        }
66    }
67
68    /// Get total tokens saved across all compressions.
69    pub fn total_tokens_saved(&self) -> u32 {
70        self.compression_history
71            .iter()
72            .map(|e| e.tokens_saved)
73            .sum()
74    }
75
76    /// Get compression count.
77    pub fn compression_count(&self) -> usize {
78        self.compression_history.len()
79    }
80
81    /// Get a display name for the session.
82    /// Returns user-defined name if set, otherwise a time-based fallback.
83    pub fn display_name(&self) -> String {
84        if let Some(ref name) = self.name {
85            name.clone()
86        } else {
87            // Fallback: show creation time
88            Self::generate_time_name(self.created_at)
89        }
90    }
91
92    /// Get a short ID for the session (first 8 chars of UUID).
93    pub fn short_id(&self) -> String {
94        self.id[..8].to_string()
95    }
96
97    /// Format the session for display in a list.
98    pub fn format_line(&self, is_current: bool) -> String {
99        let marker = if is_current { "*" } else { " " };
100        let name = self.display_name();
101        let msgs = self.message_count;
102        let project = self
103            .project_path
104            .as_ref()
105            .map(|p| {
106                // Show just the directory name, not full path
107                PathBuf::from(p)
108                    .file_name()
109                    .map(|n| n.to_string_lossy().to_string())
110                    .unwrap_or_else(|| p.clone())
111            })
112            .unwrap_or_else(|| "-".to_string());
113
114        // Add compression info if any
115        let compression_info = if self.compression_count() > 0 {
116            format!("  💾 {} comps", self.compression_count())
117        } else {
118            "".to_string()
119        };
120
121        format!(
122            "{} {}  {} msgs  {}{}",
123            marker, name, msgs, project, compression_info
124        )
125    }
126}
127
128/// Index of all sessions, stored in ~/.matrix/sessions/index.json
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct SessionIndex {
131    /// All known sessions.
132    pub sessions: Vec<SessionMetadata>,
133    /// ID of the most recently active session (for --continue).
134    pub last_session_id: Option<String>,
135}
136
137impl SessionIndex {
138    /// Find a session by ID or name.
139    pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
140        // First try exact ID match
141        if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
142            return Some(s);
143        }
144        // Then try exact name match
145        if let Some(s) = self
146            .sessions
147            .iter()
148            .find(|s| s.name.as_deref() == Some(query))
149        {
150            return Some(s);
151        }
152        // Then try partial ID match (for convenience)
153        if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
154            return Some(s);
155        }
156        None
157    }
158
159    /// Get the last session (for --continue).
160    pub fn last_session(&self) -> Option<&SessionMetadata> {
161        self.last_session_id
162            .as_ref()
163            .and_then(|id| self.sessions.iter().find(|s| s.id == *id))
164    }
165
166    /// Add or update a session in the index.
167    pub fn upsert(&mut self, meta: SessionMetadata) {
168        // Remove existing entry with same ID
169        self.sessions.retain(|s| s.id != meta.id);
170        // Add new entry
171        self.sessions.push(meta.clone());
172        // Update last_session_id
173        self.last_session_id = Some(meta.id);
174        // Sort by updated_at descending (most recent first)
175        self.sessions
176            .sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
177    }
178
179    /// Remove a session from the index.
180    pub fn remove(&mut self, id: &str) -> Option<SessionMetadata> {
181        let removed = self.sessions.iter().position(|s| s.id == id);
182        if let Some(idx) = removed {
183            let meta = self.sessions.remove(idx);
184            if self.last_session_id.as_deref() == Some(id) {
185                self.last_session_id = self.sessions.first().map(|s| s.id.clone());
186            }
187            Some(meta)
188        } else {
189            None
190        }
191    }
192
193    /// Rename a session.
194    pub fn rename(&mut self, id: &str, new_name: &str) -> Result<()> {
195        let session = self.sessions.iter_mut().find(|s| s.id == id);
196        if let Some(s) = session {
197            s.name = Some(new_name.to_string());
198            s.updated_at = Utc::now();
199            Ok(())
200        } else {
201            anyhow::bail!("session {} not found", id)
202        }
203    }
204}
205
206/// Message summary for display (lightweight version).
207/// Used when full message content is compressed but user still needs to see history.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct MessageSummary {
210    /// Role of the message sender.
211    pub role: String,
212    /// Brief preview of content (truncated).
213    pub preview: String,
214    /// Timestamp (if available).
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub timestamp: Option<DateTime<Utc>>,
217    /// Whether this message was compressed.
218    pub is_compressed: bool,
219    /// Original message index before compression.
220    pub original_index: usize,
221}
222
223impl MessageSummary {
224    /// Create a summary from a message.
225    pub fn from_message(msg: &Message, index: usize) -> Self {
226        use crate::providers::{ContentBlock, MessageContent, Role};
227        use crate::truncate::truncate_chars;
228
229        let role = match msg.role {
230            Role::User => "user",
231            Role::Assistant => "assistant",
232            Role::Tool => "tool",
233            Role::System => "system",
234        };
235
236        let preview = match &msg.content {
237            MessageContent::Text(t) => truncate_chars(t, 100),
238            MessageContent::Blocks(blocks) => {
239                let parts: Vec<String> = blocks
240                    .iter()
241                    .take(3)
242                    .map(|b| match b {
243                        ContentBlock::Text { text } => truncate_chars(text, 50),
244                        ContentBlock::ToolUse { name, .. } => format!("[{}]", name),
245                        ContentBlock::ToolResult { content, .. } => truncate_chars(content, 50),
246                        ContentBlock::Thinking { thinking, .. } => format!("💭 {}", truncate_chars(thinking, 30)),
247                        _ => "...".to_string(),
248                    })
249                    .collect();
250                parts.join(" ")
251            }
252        };
253
254        Self {
255            role: role.to_string(),
256            preview,
257            timestamp: None,
258            is_compressed: false,
259            original_index: index,
260        }
261    }
262}
263
264/// Full session data including messages.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Session {
267    pub metadata: SessionMetadata,
268    /// Full message history for display (TUI shows this).
269    #[serde(default)]
270    pub full_messages: Vec<Message>,
271    /// Compressed messages for API requests (Agent uses this).
272    /// If empty, use full_messages (no compression happened).
273    #[serde(default)]
274    pub compressed_messages: Vec<Message>,
275    /// Summaries of compressed messages (for TUI history view).
276    #[serde(default)]
277    pub message_summaries: Vec<MessageSummary>,
278    /// Legacy field - migrated to full_messages on load.
279    #[serde(default, skip_serializing)]
280    pub messages: Vec<Message>,
281}
282
283impl Session {
284    /// Create a new empty session.
285    pub fn new(project_path: Option<&Path>) -> Self {
286        Self {
287            metadata: SessionMetadata::new(project_path),
288            full_messages: Vec::new(),
289            compressed_messages: Vec::new(),
290            message_summaries: Vec::new(),
291            messages: Vec::new(),
292        }
293    }
294
295    /// Create a session from existing messages.
296    pub fn from_messages(messages: Vec<Message>, project_path: Option<&Path>) -> Self {
297        let mut meta = SessionMetadata::new(project_path);
298        meta.message_count = messages.len();
299        Self {
300            metadata: meta,
301            full_messages: messages.clone(),
302            compressed_messages: Vec::new(),
303            message_summaries: messages.iter().enumerate()
304                .map(|(i, m)| MessageSummary::from_message(m, i))
305                .collect(),
306            messages: messages,
307        }
308    }
309
310    /// Get messages for API requests (use compressed if available).
311    pub fn api_messages(&self) -> &[Message] {
312        if self.compressed_messages.is_empty() {
313            &self.full_messages
314        } else {
315            &self.compressed_messages
316        }
317    }
318
319    /// Get messages for display (always full messages).
320    pub fn display_messages(&self) -> &[Message] {
321        &self.full_messages
322    }
323
324    /// Update metadata after a turn.
325    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
326        self.metadata.message_count = self.full_messages.len();
327        self.metadata.last_input_tokens = last_input_tokens as u64;
328        self.metadata.total_output_tokens = total_output_tokens;
329        self.metadata.updated_at = Utc::now();
330    }
331
332    /// Set compressed messages (called after compression).
333    pub fn set_compressed(&mut self, compressed: Vec<Message>, summaries: Vec<MessageSummary>) {
334        self.compressed_messages = compressed;
335        self.message_summaries = summaries;
336    }
337
338    /// Migrate legacy messages field to full_messages.
339    fn migrate_legacy(&mut self) {
340        if !self.messages.is_empty() && self.full_messages.is_empty() {
341            log::info!(
342                "Migrating legacy session: {} messages -> full_messages",
343                self.messages.len()
344            );
345            self.full_messages = self.messages.clone();
346            self.message_summaries = self.messages.iter().enumerate()
347                .map(|(i, m)| MessageSummary::from_message(m, i))
348                .collect();
349            self.messages.clear();
350            log::info!(
351                "Migration complete: full_messages={}, summaries={}",
352                self.full_messages.len(),
353                self.message_summaries.len()
354            );
355        }
356    }
357}
358
359/// File lock for preventing concurrent access to session storage.
360struct SessionFileLock {
361    /// Path to the lock file.
362    lock_path: PathBuf,
363    /// Whether we currently hold the lock.
364    locked: bool,
365}
366
367impl SessionFileLock {
368    /// Create a new file lock for the given directory.
369    fn new(base_dir: &Path) -> Self {
370        Self {
371            lock_path: base_dir.join("sessions.lock"),
372            locked: false,
373        }
374    }
375
376    /// Acquire the lock (blocking with timeout).
377    /// Returns Ok(()) if lock acquired, Err if timeout.
378    fn acquire(&mut self, timeout_ms: u64) -> Result<()> {
379        if self.locked {
380            return Ok(());
381        }
382
383        let start = std::time::Instant::now();
384
385        while start.elapsed().as_millis() < timeout_ms as u128 {
386            match std::fs::File::create_new(&self.lock_path) {
387                Ok(_) => {
388                    let lock_info = format!("{}:{}", std::process::id(), Utc::now().to_rfc3339());
389                    std::fs::write(&self.lock_path, lock_info)?;
390                    self.locked = true;
391                    return Ok(());
392                }
393                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
394                    if self.is_stale_lock()? {
395                        self.remove_stale_lock()?;
396                    }
397                    std::thread::sleep(std::time::Duration::from_millis(50));
398                }
399                Err(e) => {
400                    return Err(e.into());
401                }
402            }
403        }
404
405        // Timeout - return error instead of Ok(false)
406        anyhow::bail!("Failed to acquire session lock after {}ms timeout", timeout_ms)
407    }
408
409    /// Check if the existing lock is stale (either old or process is dead).
410    fn is_stale_lock(&self) -> Result<bool> {
411        if !self.lock_path.exists() {
412            return Ok(false);
413        }
414
415        // Check if the lock owner process is still running
416        if let Ok(content) = std::fs::read_to_string(&self.lock_path)
417            && let Some(pid_str) = content.split(':').next()
418            && let Ok(pid) = pid_str.parse::<u32>()
419            && !self.is_process_running(pid)
420        {
421            return Ok(true);
422        }
423
424        // Check lock age as fallback
425        let metadata = std::fs::metadata(&self.lock_path)?;
426        let modified = metadata.modified()?;
427        let age = std::time::SystemTime::now()
428            .duration_since(modified)
429            .unwrap_or(std::time::Duration::ZERO);
430
431        Ok(age > std::time::Duration::from_secs(60))
432    }
433
434    /// Check if a process with the given PID is still running.
435    fn is_process_running(&self, pid: u32) -> bool {
436        #[cfg(unix)]
437        {
438            std::path::Path::new(&format!("/proc/{}", pid)).exists()
439        }
440        #[cfg(windows)]
441        {
442            use std::process::Command;
443            let output = Command::new("tasklist")
444                .args(["/FI", &format!("PID eq {}", pid), "/NH"])
445                .output();
446
447            match output {
448                Ok(out) => {
449                    let stdout = String::from_utf8_lossy(&out.stdout);
450                    stdout.contains(&pid.to_string()) && !stdout.contains("No tasks")
451                }
452                Err(_) => true,
453            }
454        }
455    }
456
457    /// Remove stale lock file.
458    fn remove_stale_lock(&self) -> Result<()> {
459        if self.lock_path.exists() {
460            std::fs::remove_file(&self.lock_path)?;
461        }
462        Ok(())
463    }
464
465    /// Release the lock.
466    fn release(&mut self) -> Result<()> {
467        if self.locked {
468            std::fs::remove_file(&self.lock_path)?;
469            self.locked = false;
470        }
471        Ok(())
472    }
473}
474
475impl Drop for SessionFileLock {
476    fn drop(&mut self) {
477        let _ = self.release();
478    }
479}
480
481/// Manager for session storage.
482pub struct SessionManager {
483    /// Base directory for session storage (~/.matrix).
484    base_dir: PathBuf,
485    /// Current active session (if any).
486    current_session: Option<Session>,
487    /// Session index.
488    index: SessionIndex,
489    /// File lock for preventing concurrent writes.
490    lock: SessionFileLock,
491}
492
493impl SessionManager {
494    /// Create a new session manager.
495    pub fn new() -> Result<Self> {
496        let base_dir = Self::get_base_dir()?;
497        let lock = SessionFileLock::new(&base_dir);
498        let manager = Self {
499            base_dir,
500            current_session: None,
501            index: SessionIndex::default(),
502            lock,
503        };
504        manager.ensure_dirs()?;
505        let mut manager = manager;
506        manager.load_index()?;
507        Ok(manager)
508    }
509
510    /// Get the base directory for session storage.
511    fn get_base_dir() -> Result<PathBuf> {
512        let home = std::env::var_os("HOME")
513            .or_else(|| std::env::var_os("USERPROFILE"))
514            .ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE environment variable not set"))?;
515        let mut p = PathBuf::from(home);
516        p.push(".matrix");
517        Ok(p)
518    }
519
520    /// Get the sessions directory.
521    fn sessions_dir(&self) -> PathBuf {
522        self.base_dir.join("sessions")
523    }
524
525    /// Get the index file path.
526    fn index_path(&self) -> PathBuf {
527        self.sessions_dir().join("index.json")
528    }
529
530    /// Get the path for a specific session file.
531    fn session_path(&self, id: &str) -> PathBuf {
532        self.sessions_dir().join(format!("{}.json", id))
533    }
534
535    /// Ensure directories exist.
536    fn ensure_dirs(&self) -> Result<()> {
537        std::fs::create_dir_all(&self.base_dir)
538            .with_context(|| format!("creating base dir {}", self.base_dir.display()))?;
539        std::fs::create_dir_all(self.sessions_dir())
540            .with_context(|| format!("creating sessions dir {}", self.sessions_dir().display()))?;
541        Ok(())
542    }
543
544    /// Load the session index from disk.
545    fn load_index(&mut self) -> Result<()> {
546        let path = self.index_path();
547        if !path.exists() {
548            return Ok(());
549        }
550        let data = std::fs::read_to_string(&path)
551            .with_context(|| format!("reading index file {}", path.display()))?;
552        if data.trim().is_empty() {
553            return Ok(());
554        }
555        self.index = serde_json::from_str(&data)
556            .with_context(|| format!("parsing index file {}", path.display()))?;
557        Ok(())
558    }
559
560    /// Save the session index to disk (internal, assumes lock held).
561    fn save_index_locked(&mut self) -> Result<()> {
562        let path = self.index_path();
563        let json =
564            serde_json::to_string_pretty(&self.index).context("serializing session index")?;
565        let tmp = path.with_extension("json.tmp");
566        std::fs::write(&tmp, json)
567            .with_context(|| format!("writing index tmp file {}", tmp.display()))?;
568        std::fs::rename(&tmp, &path)
569            .with_context(|| format!("renaming index tmp file to {}", path.display()))?;
570        Ok(())
571    }
572
573    /// Save the session index to disk (public, acquires lock).
574    pub fn save_index(&mut self) -> Result<()> {
575        self.lock.acquire(5000)?;
576        let result = self.save_index_locked();
577        self.lock.release()?;
578        result
579    }
580
581    /// Start a new session.
582    pub fn start_new(&mut self, project_path: Option<&Path>) -> Result<&Session> {
583        let session = Session::new(project_path);
584        self.current_session = Some(session);
585        self.save_current()?;
586        // SAFETY: current_session was just set and save_current succeeded
587        Ok(self.current_session.as_ref().unwrap())
588    }
589
590    /// Continue the last session (for --continue).
591    /// Returns the session without modifying its project_path.
592    /// The caller should use session.metadata.project_path as the effective path.
593    pub fn continue_last(&mut self) -> Result<Option<&Session>> {
594        let last_id = self.index.last_session().map(|m| m.id.clone());
595        if let Some(id) = last_id {
596            self.load_session(&id)?;
597            Ok(self.current_session.as_ref())
598        } else {
599            Ok(None)
600        }
601    }
602
603    /// Resume a specific session by ID or name (for --resume).
604    /// Returns the session without modifying its project_path.
605    /// The caller should use session.metadata.project_path as the effective path.
606    pub fn resume(&mut self, query: &str) -> Result<Option<&Session>> {
607        let session_id = self.index.find(query).map(|m| m.id.clone());
608        if let Some(id) = session_id {
609            self.load_session(&id)?;
610            Ok(self.current_session.as_ref())
611        } else {
612            Ok(None)
613        }
614    }
615
616    /// Load a session from disk by ID.
617    fn load_session(&mut self, id: &str) -> Result<()> {
618        let path = self.session_path(id);
619        if !path.exists() {
620            anyhow::bail!("session file {} not found", path.display());
621        }
622        let data = std::fs::read_to_string(&path)
623            .with_context(|| format!("reading session file {}", path.display()))?;
624        let mut session: Session = serde_json::from_str(&data)
625            .with_context(|| format!("parsing session file {}", path.display()))?;
626
627        // Migrate legacy messages field to full_messages
628        session.migrate_legacy();
629
630        // If session name is null but index has a name, use index's name
631        if session.metadata.name.is_none()
632            && let Some(index_meta) = self.index.find(id)
633        {
634            session.metadata.name = index_meta.name.clone();
635        }
636
637        self.current_session = Some(session);
638        Ok(())
639    }
640
641    /// Save the current session to disk (with file lock).
642    pub fn save_current(&mut self) -> Result<()> {
643        if let Some(ref session) = self.current_session {
644            // Clone entire session to avoid borrow conflicts
645            let session_clone = session.clone();
646
647            // Acquire lock for the entire save operation
648            self.lock.acquire(5000)?;
649
650            // Update index first (if index save fails, session file won't be updated)
651            self.index.upsert(session_clone.metadata.clone());
652            self.save_index_locked()?;
653
654            // Now save session file
655            let path = self.session_path(&session_clone.metadata.id);
656            let json = serde_json::to_string(&session_clone).context("serializing session")?;
657            let tmp = path.with_extension("json.tmp");
658            std::fs::write(&tmp, json)
659                .with_context(|| format!("writing session tmp file {}", tmp.display()))?;
660            std::fs::rename(&tmp, &path)
661                .with_context(|| format!("renaming session tmp file to {}", path.display()))?;
662
663            // Release lock
664            self.lock.release()?;
665        }
666        Ok(())
667    }
668
669    /// Update current session stats after a turn.
670    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
671        if let Some(ref mut session) = self.current_session {
672            session.update_stats(last_input_tokens, total_output_tokens);
673        }
674    }
675
676    /// Record a compression event in the session history.
677    pub fn record_compression(&mut self, entry: crate::compress::CompressionHistoryEntry) {
678        if let Some(ref mut session) = self.current_session {
679            session.metadata.add_compression_entry(entry);
680        }
681    }
682
683    /// Set messages for the current session.
684    pub fn set_messages(&mut self, messages: Vec<Message>) {
685        if let Some(ref mut session) = self.current_session {
686            // Auto-generate name from first user message if name is None
687            if session.metadata.name.is_none()
688                && !messages.is_empty()
689                && let Some(name) = Self::generate_name_from_messages(&messages)
690            {
691                session.metadata.name = Some(name);
692            }
693
694            // Update both full_messages and summaries
695            session.full_messages = messages.clone();
696            session.message_summaries = messages.iter().enumerate()
697                .map(|(i, m)| MessageSummary::from_message(m, i))
698                .collect();
699            session.metadata.message_count = session.full_messages.len();
700            session.metadata.updated_at = Utc::now();
701        }
702    }
703
704    /// Set compressed messages for the current session.
705    pub fn set_compressed_messages(&mut self, compressed: Vec<Message>) {
706        if let Some(ref mut session) = self.current_session {
707            // Mark all summaries as compressed first
708            for summary in &mut session.message_summaries {
709                summary.is_compressed = true;
710            }
711
712            // Then mark summaries as NOT compressed if their original message is in compressed version
713            // Compare by role and content preview (since Message doesn't implement PartialEq)
714            for compressed_msg in &compressed {
715                for (idx, full_msg) in session.full_messages.iter().enumerate() {
716                    // Simple comparison: same role and similar content
717                    if session.message_summaries.get(idx).is_some() {
718                        let same_role = compressed_msg.role == full_msg.role;
719                        if same_role {
720                            // Mark as not compressed
721                            if let Some(summary) = session.message_summaries.get_mut(idx) {
722                                summary.is_compressed = false;
723                            }
724                        }
725                    }
726                }
727            }
728
729            session.compressed_messages = compressed;
730        }
731    }
732
733    /// Get messages for API requests (compressed if available).
734    pub fn api_messages(&self) -> Option<&[Message]> {
735        self.current_session.as_ref().map(|s| s.api_messages())
736    }
737
738    /// Get messages for display (always full messages).
739    pub fn display_messages(&self) -> Option<&[Message]> {
740        self.current_session.as_ref().map(|s| s.display_messages())
741    }
742
743    /// Generate a human-readable session name from the first user message.
744    /// Takes the first meaningful user input and truncates it.
745    fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
746        use crate::providers::{ContentBlock, MessageContent, Role};
747
748        // Find first meaningful user message (skip very short/generic ones)
749        let user_messages: Vec<&Message> =
750            messages.iter().filter(|m| m.role == Role::User).collect();
751
752        for msg in user_messages.iter().take(3) {
753            let text = match &msg.content {
754                MessageContent::Text(t) => t.clone(),
755                MessageContent::Blocks(blocks) => blocks
756                    .iter()
757                    .filter_map(|b| {
758                        if let ContentBlock::Text { text } = b {
759                            Some(text.clone())
760                        } else {
761                            None
762                        }
763                    })
764                    .collect::<Vec<_>>()
765                    .join(" "),
766            };
767
768            let cleaned = text.trim().lines().next().unwrap_or("").trim();
769
770            // Skip too short or generic messages
771            if cleaned.len() < 5 || is_generic_message(cleaned) {
772                continue;
773            }
774
775            // Truncate to reasonable length for display
776            let name = if cleaned.chars().count() > 40 {
777                let truncated: String = cleaned.chars().take(37).collect();
778                format!("{}...", truncated)
779            } else {
780                cleaned.to_string()
781            };
782
783            return Some(name);
784        }
785
786        None
787    }
788
789    /// Get the current session's messages (for API - compressed if available).
790    pub fn messages(&self) -> Option<&[Message]> {
791        self.current_session.as_ref().map(|s| s.api_messages())
792    }
793
794    /// Get mutable reference to messages (returns full_messages for editing).
795    pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
796        self.current_session.as_mut().map(|s| &mut s.full_messages)
797    }
798
799    /// Get full messages for display (TUI).
800    pub fn full_messages(&self) -> Option<&[Message]> {
801        self.current_session.as_ref().map(|s| s.display_messages())
802    }
803
804    /// Get the current session ID.
805    pub fn current_id(&self) -> Option<&str> {
806        self.current_session
807            .as_ref()
808            .map(|s| s.metadata.id.as_str())
809    }
810
811    /// Get the current session name.
812    pub fn current_name(&self) -> Option<&str> {
813        self.current_session.as_ref().and_then(|s| s.name())
814    }
815
816    /// Rename the current session.
817    pub fn rename_current(&mut self, new_name: &str) -> Result<()> {
818        if let Some(ref session) = self.current_session {
819            let id = session.metadata.id.clone();
820            self.index.rename(&id, new_name)?;
821            if let Some(ref mut session) = self.current_session {
822                session.metadata.name = Some(new_name.to_string());
823            }
824            self.save_current()?;
825        }
826        Ok(())
827    }
828
829    /// Clear the current session (start fresh).
830    pub fn clear_current(&mut self) -> Result<()> {
831        if let Some(ref session) = self.current_session {
832            // Acquire lock
833            self.lock.acquire(5000)?;
834
835            // Remove session file
836            let path = self.session_path(&session.metadata.id);
837            let _ = std::fs::remove_file(&path);
838            // Remove from index
839            self.index.remove(&session.metadata.id);
840            self.save_index_locked()?;
841
842            // Release lock
843            self.lock.release()?;
844        }
845        self.current_session = None;
846        Ok(())
847    }
848
849    /// List all sessions.
850    pub fn list_sessions(&self) -> &[SessionMetadata] {
851        &self.index.sessions
852    }
853
854    /// Clean up old sessions that haven't been updated in N days.
855    /// Returns the number of sessions removed.
856    pub fn cleanup_old_sessions(&mut self, max_age_days: u64) -> Result<usize> {
857        let now = chrono::Utc::now();
858        let threshold = chrono::Duration::days(max_age_days as i64);
859
860        let mut to_remove: Vec<String> = Vec::new();
861
862        for session in &self.index.sessions {
863            let age = now - session.updated_at;
864            if age > threshold {
865                to_remove.push(session.id.clone());
866            }
867        }
868
869        let removed_count = to_remove.len();
870
871        if removed_count > 0 {
872            self.lock.acquire(5000)?;
873
874            for id in &to_remove {
875                // Remove session file
876                let path = self.session_path(id);
877                let _ = std::fs::remove_file(&path);
878                // Remove from index
879                self.index.remove(id);
880            }
881
882            self.save_index_locked()?;
883            self.lock.release()?;
884        }
885
886        Ok(removed_count)
887    }
888
889    /// Prune sessions to keep only the most recent N sessions.
890    /// Returns the number of sessions removed.
891    pub fn prune_sessions(&mut self, max_sessions: usize) -> Result<usize> {
892        if self.index.sessions.len() <= max_sessions {
893            return Ok(0);
894        }
895
896        let to_remove = self.index.sessions.len() - max_sessions;
897        let mut ids_to_remove: Vec<String> = Vec::new();
898
899        // Remove oldest sessions (sessions are sorted by updated_at descending)
900        for session in self.index.sessions.iter().skip(max_sessions) {
901            ids_to_remove.push(session.id.clone());
902        }
903
904        self.lock.acquire(5000)?;
905
906        for id in &ids_to_remove {
907            let path = self.session_path(id);
908            let _ = std::fs::remove_file(&path);
909            self.index.remove(id);
910        }
911
912        self.save_index_locked()?;
913        self.lock.release()?;
914
915        Ok(to_remove)
916    }
917
918    /// Get total session count.
919    pub fn session_count(&self) -> usize {
920        self.index.sessions.len()
921    }
922
923    /// Check if there's a current session.
924    pub fn has_current(&self) -> bool {
925        self.current_session.is_some()
926    }
927
928    /// Get current session metadata.
929    pub fn current_metadata(&self) -> Option<&SessionMetadata> {
930        self.current_session.as_ref().map(|s| &s.metadata)
931    }
932
933    /// Get the history file path (legacy compatibility).
934    pub fn history_path(&self) -> PathBuf {
935        self.base_dir.join("history.txt")
936    }
937}
938
939impl Session {
940    /// Get the session name (user-defined or fallback).
941    pub fn name(&self) -> Option<&str> {
942        self.metadata.name.as_deref()
943    }
944}
945
946use anyhow::Context;
947
948/// Check if a message is too generic to be a good session name.
949fn is_generic_message(msg: &str) -> bool {
950    let generic = [
951        "继续", "好的", "ok", "yes", "no", "是", "否", "嗯", "对", "行", "可以", "好", "谢谢",
952        "thanks", "hi", "hello", "你好", "开始", "start",
953    ];
954    generic.iter().any(|g| msg.eq_ignore_ascii_case(g))
955}