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/// Full session data including messages.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Session {
209    pub metadata: SessionMetadata,
210    pub messages: Vec<Message>,
211}
212
213impl Session {
214    /// Create a new empty session.
215    pub fn new(project_path: Option<&Path>) -> Self {
216        Self {
217            metadata: SessionMetadata::new(project_path),
218            messages: Vec::new(),
219        }
220    }
221
222    /// Create a session from existing messages.
223    pub fn from_messages(messages: Vec<Message>, project_path: Option<&Path>) -> Self {
224        let mut meta = SessionMetadata::new(project_path);
225        meta.message_count = messages.len();
226        Self {
227            metadata: meta,
228            messages,
229        }
230    }
231
232    /// Update metadata after a turn.
233    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
234        self.metadata.message_count = self.messages.len();
235        self.metadata.last_input_tokens = last_input_tokens as u64;
236        self.metadata.total_output_tokens = total_output_tokens;
237        self.metadata.updated_at = Utc::now();
238    }
239}
240
241/// Manager for session storage.
242pub struct SessionManager {
243    /// Base directory for session storage (~/.matrix).
244    base_dir: PathBuf,
245    /// Current active session (if any).
246    current_session: Option<Session>,
247    /// Session index.
248    index: SessionIndex,
249}
250
251impl SessionManager {
252    /// Create a new session manager.
253    pub fn new() -> Result<Self> {
254        let base_dir = Self::get_base_dir()?;
255        let manager = Self {
256            base_dir,
257            current_session: None,
258            index: SessionIndex::default(),
259        };
260        manager.ensure_dirs()?;
261        let mut manager = manager;
262        manager.load_index()?;
263        Ok(manager)
264    }
265
266    /// Get the base directory for session storage.
267    fn get_base_dir() -> Result<PathBuf> {
268        let home = std::env::var_os("HOME")
269            .or_else(|| std::env::var_os("USERPROFILE"))
270            .ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE environment variable not set"))?;
271        let mut p = PathBuf::from(home);
272        p.push(".matrix");
273        Ok(p)
274    }
275
276    /// Get the sessions directory.
277    fn sessions_dir(&self) -> PathBuf {
278        self.base_dir.join("sessions")
279    }
280
281    /// Get the index file path.
282    fn index_path(&self) -> PathBuf {
283        self.sessions_dir().join("index.json")
284    }
285
286    /// Get the path for a specific session file.
287    fn session_path(&self, id: &str) -> PathBuf {
288        self.sessions_dir().join(format!("{}.json", id))
289    }
290
291    /// Ensure directories exist.
292    fn ensure_dirs(&self) -> Result<()> {
293        std::fs::create_dir_all(&self.base_dir)
294            .with_context(|| format!("creating base dir {}", self.base_dir.display()))?;
295        std::fs::create_dir_all(self.sessions_dir())
296            .with_context(|| format!("creating sessions dir {}", self.sessions_dir().display()))?;
297        Ok(())
298    }
299
300    /// Load the session index from disk.
301    fn load_index(&mut self) -> Result<()> {
302        let path = self.index_path();
303        if !path.exists() {
304            return Ok(());
305        }
306        let data = std::fs::read_to_string(&path)
307            .with_context(|| format!("reading index file {}", path.display()))?;
308        if data.trim().is_empty() {
309            return Ok(());
310        }
311        self.index = serde_json::from_str(&data)
312            .with_context(|| format!("parsing index file {}", path.display()))?;
313        Ok(())
314    }
315
316    /// Save the session index to disk.
317    fn save_index(&self) -> Result<()> {
318        let path = self.index_path();
319        let json =
320            serde_json::to_string_pretty(&self.index).context("serializing session index")?;
321        let tmp = path.with_extension("json.tmp");
322        std::fs::write(&tmp, json)
323            .with_context(|| format!("writing index tmp file {}", tmp.display()))?;
324        std::fs::rename(&tmp, &path)
325            .with_context(|| format!("renaming index tmp file to {}", path.display()))?;
326        Ok(())
327    }
328
329    /// Start a new session.
330    pub fn start_new(&mut self, project_path: Option<&Path>) -> Result<&Session> {
331        let session = Session::new(project_path);
332        self.current_session = Some(session);
333        self.save_current()?;
334        // SAFETY: current_session was just set and save_current succeeded
335        Ok(self.current_session.as_ref().unwrap())
336    }
337
338    /// Continue the last session (for --continue).
339    pub fn continue_last(&mut self, project_path: Option<&Path>) -> Result<Option<&Session>> {
340        let last_id = self.index.last_session().map(|m| m.id.clone());
341        if let Some(id) = last_id {
342            self.load_session(&id)?;
343            // Update project path if provided and different
344            if let Some(path) = project_path
345                && let Some(ref mut session) = self.current_session
346            {
347                session.metadata.project_path = Some(path.to_string_lossy().to_string());
348            }
349            Ok(self.current_session.as_ref())
350        } else {
351            Ok(None)
352        }
353    }
354
355    /// Resume a specific session by ID or name (for --resume).
356    pub fn resume(&mut self, query: &str, project_path: Option<&Path>) -> Result<Option<&Session>> {
357        let session_id = self.index.find(query).map(|m| m.id.clone());
358        if let Some(id) = session_id {
359            self.load_session(&id)?;
360            // Update project path if provided
361            if let Some(path) = project_path
362                && let Some(ref mut session) = self.current_session
363            {
364                session.metadata.project_path = Some(path.to_string_lossy().to_string());
365            }
366            Ok(self.current_session.as_ref())
367        } else {
368            Ok(None)
369        }
370    }
371
372    /// Load a session from disk by ID.
373    fn load_session(&mut self, id: &str) -> Result<()> {
374        let path = self.session_path(id);
375        if !path.exists() {
376            anyhow::bail!("session file {} not found", path.display());
377        }
378        let data = std::fs::read_to_string(&path)
379            .with_context(|| format!("reading session file {}", path.display()))?;
380        let mut session: Session = serde_json::from_str(&data)
381            .with_context(|| format!("parsing session file {}", path.display()))?;
382
383        // If session name is null but index has a name, use index's name
384        if session.metadata.name.is_none()
385            && let Some(index_meta) = self.index.find(id)
386        {
387            session.metadata.name = index_meta.name.clone();
388        }
389
390        self.current_session = Some(session);
391        Ok(())
392    }
393
394    /// Save the current session to disk.
395    pub fn save_current(&mut self) -> Result<()> {
396        if let Some(ref session) = self.current_session {
397            // Update index first (if index save fails, session file won't be updated)
398            self.index.upsert(session.metadata.clone());
399            self.save_index()?;
400
401            // Now save session file
402            let path = self.session_path(&session.metadata.id);
403            let json = serde_json::to_string(session).context("serializing session")?;
404            let tmp = path.with_extension("json.tmp");
405            std::fs::write(&tmp, json)
406                .with_context(|| format!("writing session tmp file {}", tmp.display()))?;
407            std::fs::rename(&tmp, &path)
408                .with_context(|| format!("renaming session tmp file to {}", path.display()))?;
409        }
410        Ok(())
411    }
412
413    /// Update current session stats after a turn.
414    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
415        if let Some(ref mut session) = self.current_session {
416            session.update_stats(last_input_tokens, total_output_tokens);
417        }
418    }
419
420    /// Record a compression event in the session history.
421    pub fn record_compression(&mut self, entry: crate::compress::CompressionHistoryEntry) {
422        if let Some(ref mut session) = self.current_session {
423            session.metadata.add_compression_entry(entry);
424        }
425    }
426
427    /// Set messages for the current session.
428    pub fn set_messages(&mut self, messages: Vec<Message>) {
429        if let Some(ref mut session) = self.current_session {
430            // Auto-generate name from first user message if name is None
431            if session.metadata.name.is_none()
432                && !messages.is_empty()
433                && let Some(name) = Self::generate_name_from_messages(&messages)
434            {
435                session.metadata.name = Some(name);
436            }
437
438            session.messages = messages;
439            session.metadata.message_count = session.messages.len();
440            session.metadata.updated_at = Utc::now();
441        }
442    }
443
444    /// Generate a human-readable session name from the first user message.
445    /// Takes the first meaningful user input and truncates it.
446    fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
447        use crate::providers::{ContentBlock, MessageContent, Role};
448
449        // Find first meaningful user message (skip very short/generic ones)
450        let user_messages: Vec<&Message> =
451            messages.iter().filter(|m| m.role == Role::User).collect();
452
453        for msg in user_messages.iter().take(3) {
454            let text = match &msg.content {
455                MessageContent::Text(t) => t.clone(),
456                MessageContent::Blocks(blocks) => blocks
457                    .iter()
458                    .filter_map(|b| {
459                        if let ContentBlock::Text { text } = b {
460                            Some(text.clone())
461                        } else {
462                            None
463                        }
464                    })
465                    .collect::<Vec<_>>()
466                    .join(" "),
467            };
468
469            let cleaned = text.trim().lines().next().unwrap_or("").trim();
470
471            // Skip too short or generic messages
472            if cleaned.len() < 5 || is_generic_message(cleaned) {
473                continue;
474            }
475
476            // Truncate to reasonable length for display
477            let name = if cleaned.chars().count() > 40 {
478                let truncated: String = cleaned.chars().take(37).collect();
479                format!("{}...", truncated)
480            } else {
481                cleaned.to_string()
482            };
483
484            return Some(name);
485        }
486
487        None
488    }
489
490    /// Get the current session's messages.
491    pub fn messages(&self) -> Option<&[Message]> {
492        self.current_session.as_ref().map(|s| s.messages.as_slice())
493    }
494
495    /// Get mutable reference to messages.
496    pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
497        self.current_session.as_mut().map(|s| &mut s.messages)
498    }
499
500    /// Get the current session ID.
501    pub fn current_id(&self) -> Option<&str> {
502        self.current_session
503            .as_ref()
504            .map(|s| s.metadata.id.as_str())
505    }
506
507    /// Get the current session name.
508    pub fn current_name(&self) -> Option<&str> {
509        self.current_session.as_ref().and_then(|s| s.name())
510    }
511
512    /// Rename the current session.
513    pub fn rename_current(&mut self, new_name: &str) -> Result<()> {
514        if let Some(ref session) = self.current_session {
515            let id = session.metadata.id.clone();
516            self.index.rename(&id, new_name)?;
517            if let Some(ref mut session) = self.current_session {
518                session.metadata.name = Some(new_name.to_string());
519            }
520            self.save_current()?;
521        }
522        Ok(())
523    }
524
525    /// Clear the current session (start fresh).
526    pub fn clear_current(&mut self) -> Result<()> {
527        if let Some(ref session) = self.current_session {
528            // Remove session file
529            let path = self.session_path(&session.metadata.id);
530            let _ = std::fs::remove_file(&path);
531            // Remove from index
532            self.index.remove(&session.metadata.id);
533            self.save_index()?;
534        }
535        self.current_session = None;
536        Ok(())
537    }
538
539    /// List all sessions.
540    pub fn list_sessions(&self) -> &[SessionMetadata] {
541        &self.index.sessions
542    }
543
544    /// Check if there's a current session.
545    pub fn has_current(&self) -> bool {
546        self.current_session.is_some()
547    }
548
549    /// Get current session metadata.
550    pub fn current_metadata(&self) -> Option<&SessionMetadata> {
551        self.current_session.as_ref().map(|s| &s.metadata)
552    }
553
554    /// Get the history file path (legacy compatibility).
555    pub fn history_path(&self) -> PathBuf {
556        self.base_dir.join("history.txt")
557    }
558}
559
560impl Session {
561    /// Get the session name (user-defined or fallback).
562    pub fn name(&self) -> Option<&str> {
563        self.metadata.name.as_deref()
564    }
565}
566
567use anyhow::Context;
568
569/// Check if a message is too generic to be a good session name.
570fn is_generic_message(msg: &str) -> bool {
571    let generic = [
572        "继续", "好的", "ok", "yes", "no", "是", "否", "嗯", "对", "行", "可以", "好", "谢谢",
573        "thanks", "hi", "hello", "你好", "开始", "start",
574    ];
575    generic.iter().any(|g| msg.eq_ignore_ascii_case(g))
576}