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/// Manager for session storage.
360pub struct SessionManager {
361    /// Base directory for session storage (~/.matrix).
362    base_dir: PathBuf,
363    /// Current active session (if any).
364    current_session: Option<Session>,
365    /// Session index.
366    index: SessionIndex,
367}
368
369impl SessionManager {
370    /// Create a new session manager.
371    pub fn new() -> Result<Self> {
372        let base_dir = Self::get_base_dir()?;
373        let manager = Self {
374            base_dir,
375            current_session: None,
376            index: SessionIndex::default(),
377        };
378        manager.ensure_dirs()?;
379        let mut manager = manager;
380        manager.load_index()?;
381        Ok(manager)
382    }
383
384    /// Get the base directory for session storage.
385    fn get_base_dir() -> Result<PathBuf> {
386        let home = std::env::var_os("HOME")
387            .or_else(|| std::env::var_os("USERPROFILE"))
388            .ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE environment variable not set"))?;
389        let mut p = PathBuf::from(home);
390        p.push(".matrix");
391        Ok(p)
392    }
393
394    /// Get the sessions directory.
395    fn sessions_dir(&self) -> PathBuf {
396        self.base_dir.join("sessions")
397    }
398
399    /// Get the index file path.
400    fn index_path(&self) -> PathBuf {
401        self.sessions_dir().join("index.json")
402    }
403
404    /// Get the path for a specific session file.
405    fn session_path(&self, id: &str) -> PathBuf {
406        self.sessions_dir().join(format!("{}.json", id))
407    }
408
409    /// Ensure directories exist.
410    fn ensure_dirs(&self) -> Result<()> {
411        std::fs::create_dir_all(&self.base_dir)
412            .with_context(|| format!("creating base dir {}", self.base_dir.display()))?;
413        std::fs::create_dir_all(self.sessions_dir())
414            .with_context(|| format!("creating sessions dir {}", self.sessions_dir().display()))?;
415        Ok(())
416    }
417
418    /// Load the session index from disk.
419    fn load_index(&mut self) -> Result<()> {
420        let path = self.index_path();
421        if !path.exists() {
422            return Ok(());
423        }
424        let data = std::fs::read_to_string(&path)
425            .with_context(|| format!("reading index file {}", path.display()))?;
426        if data.trim().is_empty() {
427            return Ok(());
428        }
429        self.index = serde_json::from_str(&data)
430            .with_context(|| format!("parsing index file {}", path.display()))?;
431        Ok(())
432    }
433
434    /// Save the session index to disk.
435    fn save_index(&self) -> Result<()> {
436        let path = self.index_path();
437        let json =
438            serde_json::to_string_pretty(&self.index).context("serializing session index")?;
439        let tmp = path.with_extension("json.tmp");
440        std::fs::write(&tmp, json)
441            .with_context(|| format!("writing index tmp file {}", tmp.display()))?;
442        std::fs::rename(&tmp, &path)
443            .with_context(|| format!("renaming index tmp file to {}", path.display()))?;
444        Ok(())
445    }
446
447    /// Start a new session.
448    pub fn start_new(&mut self, project_path: Option<&Path>) -> Result<&Session> {
449        let session = Session::new(project_path);
450        self.current_session = Some(session);
451        self.save_current()?;
452        // SAFETY: current_session was just set and save_current succeeded
453        Ok(self.current_session.as_ref().unwrap())
454    }
455
456    /// Continue the last session (for --continue).
457    pub fn continue_last(&mut self, project_path: Option<&Path>) -> Result<Option<&Session>> {
458        let last_id = self.index.last_session().map(|m| m.id.clone());
459        if let Some(id) = last_id {
460            self.load_session(&id)?;
461            // Update project path if provided and different
462            if let Some(path) = project_path
463                && let Some(ref mut session) = self.current_session
464            {
465                session.metadata.project_path = Some(path.to_string_lossy().to_string());
466            }
467            Ok(self.current_session.as_ref())
468        } else {
469            Ok(None)
470        }
471    }
472
473    /// Resume a specific session by ID or name (for --resume).
474    pub fn resume(&mut self, query: &str, project_path: Option<&Path>) -> Result<Option<&Session>> {
475        let session_id = self.index.find(query).map(|m| m.id.clone());
476        if let Some(id) = session_id {
477            self.load_session(&id)?;
478            // Update project path if provided
479            if let Some(path) = project_path
480                && let Some(ref mut session) = self.current_session
481            {
482                session.metadata.project_path = Some(path.to_string_lossy().to_string());
483            }
484            Ok(self.current_session.as_ref())
485        } else {
486            Ok(None)
487        }
488    }
489
490    /// Load a session from disk by ID.
491    fn load_session(&mut self, id: &str) -> Result<()> {
492        let path = self.session_path(id);
493        if !path.exists() {
494            anyhow::bail!("session file {} not found", path.display());
495        }
496        let data = std::fs::read_to_string(&path)
497            .with_context(|| format!("reading session file {}", path.display()))?;
498        let mut session: Session = serde_json::from_str(&data)
499            .with_context(|| format!("parsing session file {}", path.display()))?;
500
501        // Migrate legacy messages field to full_messages
502        session.migrate_legacy();
503
504        // If session name is null but index has a name, use index's name
505        if session.metadata.name.is_none()
506            && let Some(index_meta) = self.index.find(id)
507        {
508            session.metadata.name = index_meta.name.clone();
509        }
510
511        self.current_session = Some(session);
512        Ok(())
513    }
514
515    /// Save the current session to disk.
516    pub fn save_current(&mut self) -> Result<()> {
517        if let Some(ref session) = self.current_session {
518            // Update index first (if index save fails, session file won't be updated)
519            self.index.upsert(session.metadata.clone());
520            self.save_index()?;
521
522            // Now save session file
523            let path = self.session_path(&session.metadata.id);
524            let json = serde_json::to_string(session).context("serializing session")?;
525            let tmp = path.with_extension("json.tmp");
526            std::fs::write(&tmp, json)
527                .with_context(|| format!("writing session tmp file {}", tmp.display()))?;
528            std::fs::rename(&tmp, &path)
529                .with_context(|| format!("renaming session tmp file to {}", path.display()))?;
530        }
531        Ok(())
532    }
533
534    /// Update current session stats after a turn.
535    pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
536        if let Some(ref mut session) = self.current_session {
537            session.update_stats(last_input_tokens, total_output_tokens);
538        }
539    }
540
541    /// Record a compression event in the session history.
542    pub fn record_compression(&mut self, entry: crate::compress::CompressionHistoryEntry) {
543        if let Some(ref mut session) = self.current_session {
544            session.metadata.add_compression_entry(entry);
545        }
546    }
547
548    /// Set messages for the current session.
549    pub fn set_messages(&mut self, messages: Vec<Message>) {
550        if let Some(ref mut session) = self.current_session {
551            // Auto-generate name from first user message if name is None
552            if session.metadata.name.is_none()
553                && !messages.is_empty()
554                && let Some(name) = Self::generate_name_from_messages(&messages)
555            {
556                session.metadata.name = Some(name);
557            }
558
559            // Update both full_messages and summaries
560            session.full_messages = messages.clone();
561            session.message_summaries = messages.iter().enumerate()
562                .map(|(i, m)| MessageSummary::from_message(m, i))
563                .collect();
564            session.metadata.message_count = session.full_messages.len();
565            session.metadata.updated_at = Utc::now();
566        }
567    }
568
569    /// Set compressed messages for the current session.
570    pub fn set_compressed_messages(&mut self, compressed: Vec<Message>) {
571        if let Some(ref mut session) = self.current_session {
572            // Mark all summaries as compressed first
573            for summary in &mut session.message_summaries {
574                summary.is_compressed = true;
575            }
576
577            // Then mark summaries as NOT compressed if their original message is in compressed version
578            // Compare by role and content preview (since Message doesn't implement PartialEq)
579            for compressed_msg in &compressed {
580                for (idx, full_msg) in session.full_messages.iter().enumerate() {
581                    // Simple comparison: same role and similar content
582                    if session.message_summaries.get(idx).is_some() {
583                        let same_role = compressed_msg.role == full_msg.role;
584                        if same_role {
585                            // Mark as not compressed
586                            if let Some(summary) = session.message_summaries.get_mut(idx) {
587                                summary.is_compressed = false;
588                            }
589                        }
590                    }
591                }
592            }
593
594            session.compressed_messages = compressed;
595        }
596    }
597
598    /// Get messages for API requests (compressed if available).
599    pub fn api_messages(&self) -> Option<&[Message]> {
600        self.current_session.as_ref().map(|s| s.api_messages())
601    }
602
603    /// Get messages for display (always full messages).
604    pub fn display_messages(&self) -> Option<&[Message]> {
605        self.current_session.as_ref().map(|s| s.display_messages())
606    }
607
608    /// Generate a human-readable session name from the first user message.
609    /// Takes the first meaningful user input and truncates it.
610    fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
611        use crate::providers::{ContentBlock, MessageContent, Role};
612
613        // Find first meaningful user message (skip very short/generic ones)
614        let user_messages: Vec<&Message> =
615            messages.iter().filter(|m| m.role == Role::User).collect();
616
617        for msg in user_messages.iter().take(3) {
618            let text = match &msg.content {
619                MessageContent::Text(t) => t.clone(),
620                MessageContent::Blocks(blocks) => blocks
621                    .iter()
622                    .filter_map(|b| {
623                        if let ContentBlock::Text { text } = b {
624                            Some(text.clone())
625                        } else {
626                            None
627                        }
628                    })
629                    .collect::<Vec<_>>()
630                    .join(" "),
631            };
632
633            let cleaned = text.trim().lines().next().unwrap_or("").trim();
634
635            // Skip too short or generic messages
636            if cleaned.len() < 5 || is_generic_message(cleaned) {
637                continue;
638            }
639
640            // Truncate to reasonable length for display
641            let name = if cleaned.chars().count() > 40 {
642                let truncated: String = cleaned.chars().take(37).collect();
643                format!("{}...", truncated)
644            } else {
645                cleaned.to_string()
646            };
647
648            return Some(name);
649        }
650
651        None
652    }
653
654    /// Get the current session's messages (for API - compressed if available).
655    pub fn messages(&self) -> Option<&[Message]> {
656        self.current_session.as_ref().map(|s| s.api_messages())
657    }
658
659    /// Get mutable reference to messages (returns full_messages for editing).
660    pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
661        self.current_session.as_mut().map(|s| &mut s.full_messages)
662    }
663
664    /// Get full messages for display (TUI).
665    pub fn full_messages(&self) -> Option<&[Message]> {
666        self.current_session.as_ref().map(|s| s.display_messages())
667    }
668
669    /// Get the current session ID.
670    pub fn current_id(&self) -> Option<&str> {
671        self.current_session
672            .as_ref()
673            .map(|s| s.metadata.id.as_str())
674    }
675
676    /// Get the current session name.
677    pub fn current_name(&self) -> Option<&str> {
678        self.current_session.as_ref().and_then(|s| s.name())
679    }
680
681    /// Rename the current session.
682    pub fn rename_current(&mut self, new_name: &str) -> Result<()> {
683        if let Some(ref session) = self.current_session {
684            let id = session.metadata.id.clone();
685            self.index.rename(&id, new_name)?;
686            if let Some(ref mut session) = self.current_session {
687                session.metadata.name = Some(new_name.to_string());
688            }
689            self.save_current()?;
690        }
691        Ok(())
692    }
693
694    /// Clear the current session (start fresh).
695    pub fn clear_current(&mut self) -> Result<()> {
696        if let Some(ref session) = self.current_session {
697            // Remove session file
698            let path = self.session_path(&session.metadata.id);
699            let _ = std::fs::remove_file(&path);
700            // Remove from index
701            self.index.remove(&session.metadata.id);
702            self.save_index()?;
703        }
704        self.current_session = None;
705        Ok(())
706    }
707
708    /// List all sessions.
709    pub fn list_sessions(&self) -> &[SessionMetadata] {
710        &self.index.sessions
711    }
712
713    /// Check if there's a current session.
714    pub fn has_current(&self) -> bool {
715        self.current_session.is_some()
716    }
717
718    /// Get current session metadata.
719    pub fn current_metadata(&self) -> Option<&SessionMetadata> {
720        self.current_session.as_ref().map(|s| &s.metadata)
721    }
722
723    /// Get the history file path (legacy compatibility).
724    pub fn history_path(&self) -> PathBuf {
725        self.base_dir.join("history.txt")
726    }
727}
728
729impl Session {
730    /// Get the session name (user-defined or fallback).
731    pub fn name(&self) -> Option<&str> {
732        self.metadata.name.as_deref()
733    }
734}
735
736use anyhow::Context;
737
738/// Check if a message is too generic to be a good session name.
739fn is_generic_message(msg: &str) -> bool {
740    let generic = [
741        "继续", "好的", "ok", "yes", "no", "是", "否", "嗯", "对", "行", "可以", "好", "谢谢",
742        "thanks", "hi", "hello", "你好", "开始", "start",
743    ];
744    generic.iter().any(|g| msg.eq_ignore_ascii_case(g))
745}