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.iter().map(|e| e.tokens_saved).sum()
71    }
72
73    /// Get compression count.
74    pub fn compression_count(&self) -> usize {
75        self.compression_history.len()
76    }
77
78    /// Get a display name for the session.
79    /// Returns user-defined name if set, otherwise a time-based fallback.
80    pub fn display_name(&self) -> String {
81        if let Some(ref name) = self.name {
82            name.clone()
83        } else {
84            // Fallback: show creation time
85            Self::generate_time_name(self.created_at)
86        }
87    }
88
89    /// Get a short ID for the session (first 8 chars of UUID).
90    pub fn short_id(&self) -> String {
91        self.id[..8].to_string()
92    }
93
94    /// Format the session for display in a list.
95    pub fn format_line(&self, is_current: bool) -> String {
96        let marker = if is_current { "*" } else { " " };
97        let name = self.display_name();
98        let msgs = self.message_count;
99        let project = self.project_path
100            .as_ref()
101            .map(|p| {
102                // Show just the directory name, not full path
103                PathBuf::from(p)
104                    .file_name()
105                    .map(|n| n.to_string_lossy().to_string())
106                    .unwrap_or_else(|| p.clone())
107            })
108            .unwrap_or_else(|| "-".to_string());
109        
110        // Add compression info if any
111        let compression_info = if self.compression_count() > 0 {
112            format!("  💾 {} comps", self.compression_count())
113        } else {
114            "".to_string()
115        };
116        
117        format!("{} {}  {} msgs  {}{}", marker, name, msgs, project, compression_info)
118    }
119}
120
121/// Index of all sessions, stored in ~/.matrix/sessions/index.json
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct SessionIndex {
124    /// All known sessions.
125    pub sessions: Vec<SessionMetadata>,
126    /// ID of the most recently active session (for --continue).
127    pub last_session_id: Option<String>,
128}
129
130impl SessionIndex {
131    /// Find a session by ID or name.
132    pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
133        // First try exact ID match
134        if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
135            return Some(s);
136        }
137        // Then try exact name match
138        if let Some(s) = self.sessions.iter().find(|s| s.name.as_deref() == Some(query)) {
139            return Some(s);
140        }
141        // Then try partial ID match (for convenience)
142        if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
143            return Some(s);
144        }
145        None
146    }
147
148    /// Get the last session (for --continue).
149    pub fn last_session(&self) -> Option<&SessionMetadata> {
150        self.last_session_id
151            .as_ref()
152            .and_then(|id| self.sessions.iter().find(|s| s.id == *id))
153    }
154
155    /// Add or update a session in the index.
156    pub fn upsert(&mut self, meta: SessionMetadata) {
157        // Remove existing entry with same ID
158        self.sessions.retain(|s| s.id != meta.id);
159        // Add new entry
160        self.sessions.push(meta.clone());
161        // Update last_session_id
162        self.last_session_id = Some(meta.id);
163        // Sort by updated_at descending (most recent first)
164        self.sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
165    }
166
167    /// Remove a session from the index.
168    pub fn remove(&mut self, id: &str) -> Option<SessionMetadata> {
169        let removed = self.sessions.iter().position(|s| s.id == id);
170        if let Some(idx) = removed {
171            let meta = self.sessions.remove(idx);
172            if self.last_session_id.as_deref() == Some(id) {
173                self.last_session_id = self.sessions.first().map(|s| s.id.clone());
174            }
175            Some(meta)
176        } else {
177            None
178        }
179    }
180
181    /// Rename a session.
182    pub fn rename(&mut self, id: &str, new_name: &str) -> Result<()> {
183        let session = self.sessions.iter_mut().find(|s| s.id == id);
184        if let Some(s) = session {
185            s.name = Some(new_name.to_string());
186            s.updated_at = Utc::now();
187            Ok(())
188        } else {
189            anyhow::bail!("session {} not found", id)
190        }
191    }
192}
193
194/// Full session data including messages.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct Session {
197    pub metadata: SessionMetadata,
198    pub messages: Vec<Message>,
199}
200
201impl Session {
202    /// Create a new empty session.
203    pub fn new(project_path: Option<&Path>) -> Self {
204        Self {
205            metadata: SessionMetadata::new(project_path),
206            messages: Vec::new(),
207        }
208    }
209
210    /// Create a session from existing messages.
211    pub fn from_messages(messages: Vec<Message>, project_path: Option<&Path>) -> Self {
212        let mut meta = SessionMetadata::new(project_path);
213        meta.message_count = messages.len();
214        Self {
215            metadata: meta,
216            messages,
217        }
218    }
219
220    /// Update metadata after a turn.
221    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
222        self.metadata.message_count = self.messages.len();
223        self.metadata.last_input_tokens = last_input_tokens as u64;
224        self.metadata.total_output_tokens = total_output_tokens;
225        self.metadata.updated_at = Utc::now();
226    }
227}
228
229/// Manager for session storage.
230pub struct SessionManager {
231    /// Base directory for session storage (~/.matrix).
232    base_dir: PathBuf,
233    /// Current active session (if any).
234    current_session: Option<Session>,
235    /// Session index.
236    index: SessionIndex,
237}
238
239impl SessionManager {
240    /// Create a new session manager.
241    pub fn new() -> Result<Self> {
242        let base_dir = Self::get_base_dir()?;
243        let manager = Self {
244            base_dir,
245            current_session: None,
246            index: SessionIndex::default(),
247        };
248        manager.ensure_dirs()?;
249        let mut manager = manager;
250        manager.load_index()?;
251        Ok(manager)
252    }
253
254    /// Get the base directory for session storage.
255    fn get_base_dir() -> Result<PathBuf> {
256        let home = std::env::var_os("HOME")
257            .or_else(|| std::env::var_os("USERPROFILE"))
258            .ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE environment variable not set"))?;
259        let mut p = PathBuf::from(home);
260        p.push(".matrix");
261        Ok(p)
262    }
263
264    /// Get the sessions directory.
265    fn sessions_dir(&self) -> PathBuf {
266        self.base_dir.join("sessions")
267    }
268
269    /// Get the index file path.
270    fn index_path(&self) -> PathBuf {
271        self.sessions_dir().join("index.json")
272    }
273
274    /// Get the path for a specific session file.
275    fn session_path(&self, id: &str) -> PathBuf {
276        self.sessions_dir().join(format!("{}.json", id))
277    }
278
279    /// Ensure directories exist.
280    fn ensure_dirs(&self) -> Result<()> {
281        std::fs::create_dir_all(&self.base_dir)
282            .with_context(|| format!("creating base dir {}", self.base_dir.display()))?;
283        std::fs::create_dir_all(self.sessions_dir())
284            .with_context(|| format!("creating sessions dir {}", self.sessions_dir().display()))?;
285        Ok(())
286    }
287
288    /// Load the session index from disk.
289    fn load_index(&mut self) -> Result<()> {
290        let path = self.index_path();
291        if !path.exists() {
292            return Ok(());
293        }
294        let data = std::fs::read_to_string(&path)
295            .with_context(|| format!("reading index file {}", path.display()))?;
296        if data.trim().is_empty() {
297            return Ok(());
298        }
299        self.index = serde_json::from_str(&data)
300            .with_context(|| format!("parsing index file {}", path.display()))?;
301        Ok(())
302    }
303
304    /// Save the session index to disk.
305    fn save_index(&self) -> Result<()> {
306        let path = self.index_path();
307        let json = serde_json::to_string_pretty(&self.index)
308            .context("serializing session index")?;
309        let tmp = path.with_extension("json.tmp");
310        std::fs::write(&tmp, json)
311            .with_context(|| format!("writing index tmp file {}", tmp.display()))?;
312        std::fs::rename(&tmp, &path)
313            .with_context(|| format!("renaming index tmp file to {}", path.display()))?;
314        Ok(())
315    }
316
317    /// Start a new session.
318    pub fn start_new(&mut self, project_path: Option<&Path>) -> Result<&Session> {
319        let session = Session::new(project_path);
320        self.current_session = Some(session);
321        self.save_current()?;
322        Ok(self.current_session.as_ref().unwrap())
323    }
324
325    /// Continue the last session (for --continue).
326    pub fn continue_last(&mut self, project_path: Option<&Path>) -> Result<Option<&Session>> {
327        let last_id = self.index.last_session().map(|m| m.id.clone());
328        if let Some(id) = last_id {
329            self.load_session(&id)?;
330            // Update project path if provided and different
331            if let Some(path) = project_path
332                && let Some(ref mut session) = self.current_session {
333                    session.metadata.project_path = Some(path.to_string_lossy().to_string());
334                }
335            Ok(self.current_session.as_ref())
336        } else {
337            Ok(None)
338        }
339    }
340
341    /// Resume a specific session by ID or name (for --resume).
342    pub fn resume(&mut self, query: &str, project_path: Option<&Path>) -> Result<Option<&Session>> {
343        let session_id = self.index.find(query).map(|m| m.id.clone());
344        if let Some(id) = session_id {
345            self.load_session(&id)?;
346            // Update project path if provided
347            if let Some(path) = project_path
348                && let Some(ref mut session) = self.current_session {
349                    session.metadata.project_path = Some(path.to_string_lossy().to_string());
350                }
351            Ok(self.current_session.as_ref())
352        } else {
353            Ok(None)
354        }
355    }
356
357    /// Load a session from disk by ID.
358    fn load_session(&mut self, id: &str) -> Result<()> {
359        let path = self.session_path(id);
360        if !path.exists() {
361            anyhow::bail!("session file {} not found", path.display());
362        }
363        let data = std::fs::read_to_string(&path)
364            .with_context(|| format!("reading session file {}", path.display()))?;
365        let mut session: Session = serde_json::from_str(&data)
366            .with_context(|| format!("parsing session file {}", path.display()))?;
367        
368        // If session name is null but index has a name, use index's name
369        if session.metadata.name.is_none()
370            && let Some(index_meta) = self.index.find(id) {
371                session.metadata.name = index_meta.name.clone();
372            }
373        
374        self.current_session = Some(session);
375        Ok(())
376    }
377
378    /// Save the current session to disk.
379    pub fn save_current(&mut self) -> Result<()> {
380        if let Some(ref session) = self.current_session {
381            let path = self.session_path(&session.metadata.id);
382            let json = serde_json::to_string(session)
383                .context("serializing session")?;
384            let tmp = path.with_extension("json.tmp");
385            std::fs::write(&tmp, json)
386                .with_context(|| format!("writing session tmp file {}", tmp.display()))?;
387            std::fs::rename(&tmp, &path)
388                .with_context(|| format!("renaming session tmp file to {}", path.display()))?;
389            
390            // Update index
391            self.index.upsert(session.metadata.clone());
392            self.save_index()?;
393        }
394        Ok(())
395    }
396
397    /// Update current session stats after a turn.
398    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
399        if let Some(ref mut session) = self.current_session {
400            session.update_stats(last_input_tokens, total_output_tokens);
401        }
402    }
403
404    /// Record a compression event in the session history.
405    pub fn record_compression(&mut self, entry: crate::compress::CompressionHistoryEntry) {
406        if let Some(ref mut session) = self.current_session {
407            session.metadata.add_compression_entry(entry);
408        }
409    }
410
411    /// Set messages for the current session.
412    pub fn set_messages(&mut self, messages: Vec<Message>) {
413        if let Some(ref mut session) = self.current_session {
414            // Auto-generate name from first user message if name is None
415            if session.metadata.name.is_none() && !messages.is_empty()
416                && let Some(name) = Self::generate_name_from_messages(&messages) {
417                    session.metadata.name = Some(name);
418                }
419            
420            session.messages = messages;
421            session.metadata.message_count = session.messages.len();
422            session.metadata.updated_at = Utc::now();
423        }
424    }
425
426    /// Generate a human-readable session name from the first user message.
427    /// Takes the first meaningful user input and truncates it.
428    fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
429        use crate::providers::{Role, MessageContent, ContentBlock};
430        
431        // Find first meaningful user message (skip very short/generic ones)
432        let user_messages: Vec<&Message> = messages.iter()
433            .filter(|m| m.role == Role::User)
434            .collect();
435        
436        for msg in user_messages.iter().take(3) {
437            let text = match &msg.content {
438                MessageContent::Text(t) => t.clone(),
439                MessageContent::Blocks(blocks) => {
440                    blocks.iter().filter_map(|b| {
441                        if let ContentBlock::Text { text } = b {
442                            Some(text.clone())
443                        } else {
444                            None
445                        }
446                    }).collect::<Vec<_>>().join(" ")
447                }
448            };
449            
450            let cleaned = text.trim().lines().next().unwrap_or("").trim();
451            
452            // Skip too short or generic messages
453            if cleaned.len() < 5 || is_generic_message(cleaned) {
454                continue;
455            }
456            
457            // Truncate to reasonable length for display
458            let name = if cleaned.chars().count() > 40 {
459                let truncated: String = cleaned.chars().take(37).collect();
460                format!("{}...", truncated)
461            } else {
462                cleaned.to_string()
463            };
464            
465            return Some(name);
466        }
467        
468        None
469    }
470
471    /// Get the current session's messages.
472    pub fn messages(&self) -> Option<&[Message]> {
473        self.current_session.as_ref().map(|s| s.messages.as_slice())
474    }
475
476    /// Get mutable reference to messages.
477    pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
478        self.current_session.as_mut().map(|s| &mut s.messages)
479    }
480
481    /// Get the current session ID.
482    pub fn current_id(&self) -> Option<&str> {
483        self.current_session.as_ref().map(|s| s.metadata.id.as_str())
484    }
485
486    /// Get the current session name.
487    pub fn current_name(&self) -> Option<&str> {
488        self.current_session.as_ref().and_then(|s| s.name())
489    }
490
491    /// Rename the current session.
492    pub fn rename_current(&mut self, new_name: &str) -> Result<()> {
493        if let Some(ref session) = self.current_session {
494            let id = session.metadata.id.clone();
495            self.index.rename(&id, new_name)?;
496            if let Some(ref mut session) = self.current_session {
497                session.metadata.name = Some(new_name.to_string());
498            }
499            self.save_current()?;
500        }
501        Ok(())
502    }
503
504    /// Clear the current session (start fresh).
505    pub fn clear_current(&mut self) -> Result<()> {
506        if let Some(ref session) = self.current_session {
507            // Remove session file
508            let path = self.session_path(&session.metadata.id);
509            let _ = std::fs::remove_file(&path);
510            // Remove from index
511            self.index.remove(&session.metadata.id);
512            self.save_index()?;
513        }
514        self.current_session = None;
515        Ok(())
516    }
517
518    /// List all sessions.
519    pub fn list_sessions(&self) -> &[SessionMetadata] {
520        &self.index.sessions
521    }
522
523    /// Check if there's a current session.
524    pub fn has_current(&self) -> bool {
525        self.current_session.is_some()
526    }
527
528    /// Get current session metadata.
529    pub fn current_metadata(&self) -> Option<&SessionMetadata> {
530        self.current_session.as_ref().map(|s| &s.metadata)
531    }
532
533    /// Get the history file path (legacy compatibility).
534    pub fn history_path(&self) -> PathBuf {
535        self.base_dir.join("history.txt")
536    }
537}
538
539impl Session {
540    /// Get the session name (user-defined or fallback).
541    pub fn name(&self) -> Option<&str> {
542        self.metadata.name.as_deref()
543    }
544}
545
546use anyhow::Context;
547
548/// Check if a message is too generic to be a good session name.
549fn is_generic_message(msg: &str) -> bool {
550    let generic = [
551        "继续", "好的", "ok", "yes", "no", "是", "否",
552        "嗯", "对", "行", "可以", "好", "谢谢", "thanks",
553        "hi", "hello", "你好", "开始", "start",
554    ];
555    generic.iter().any(|g| msg.eq_ignore_ascii_case(g))
556}