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#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionMetadata {
13 pub id: String,
15 pub name: Option<String>,
17 pub project_path: Option<String>,
19 pub created_at: DateTime<Utc>,
21 pub updated_at: DateTime<Utc>,
23 pub message_count: usize,
25 pub last_input_tokens: u64,
27 pub total_output_tokens: u64,
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub compression_history: Vec<CompressionHistoryEntry>,
32}
33
34impl SessionMetadata {
35 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, 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 fn generate_time_name(time: DateTime<Utc>) -> String {
54 let local: chrono::DateTime<chrono::Local> = time.with_timezone(&chrono::Local);
56 local.format("%Y-%m-%d %H:%M").to_string()
57 }
58
59 pub fn add_compression_entry(&mut self, entry: CompressionHistoryEntry) {
61 self.compression_history.push(entry);
62 if self.compression_history.len() > 10 {
64 self.compression_history.remove(0);
65 }
66 }
67
68 pub fn total_tokens_saved(&self) -> u32 {
70 self.compression_history
71 .iter()
72 .map(|e| e.tokens_saved)
73 .sum()
74 }
75
76 pub fn compression_count(&self) -> usize {
78 self.compression_history.len()
79 }
80
81 pub fn display_name(&self) -> String {
84 if let Some(ref name) = self.name {
85 name.clone()
86 } else {
87 Self::generate_time_name(self.created_at)
89 }
90 }
91
92 pub fn short_id(&self) -> String {
94 self.id[..8].to_string()
95 }
96
97 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct SessionIndex {
131 pub sessions: Vec<SessionMetadata>,
133 pub last_session_id: Option<String>,
135}
136
137impl SessionIndex {
138 pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
140 if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
142 return Some(s);
143 }
144 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 if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
154 return Some(s);
155 }
156 None
157 }
158
159 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 pub fn upsert(&mut self, meta: SessionMetadata) {
168 self.sessions.retain(|s| s.id != meta.id);
170 self.sessions.push(meta.clone());
172 self.last_session_id = Some(meta.id);
174 self.sessions
176 .sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
177 }
178
179 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct MessageSummary {
210 pub role: String,
212 pub preview: String,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub timestamp: Option<DateTime<Utc>>,
217 pub is_compressed: bool,
219 pub original_index: usize,
221}
222
223impl MessageSummary {
224 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#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Session {
267 pub metadata: SessionMetadata,
268 #[serde(default)]
270 pub full_messages: Vec<Message>,
271 #[serde(default)]
274 pub compressed_messages: Vec<Message>,
275 #[serde(default)]
277 pub message_summaries: Vec<MessageSummary>,
278 #[serde(default, skip_serializing)]
280 pub messages: Vec<Message>,
281}
282
283impl Session {
284 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 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 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 pub fn display_messages(&self) -> &[Message] {
321 &self.full_messages
322 }
323
324 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 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 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
359struct SessionFileLock {
361 lock_path: PathBuf,
363 locked: bool,
365}
366
367impl SessionFileLock {
368 fn new(base_dir: &Path) -> Self {
370 Self {
371 lock_path: base_dir.join("sessions.lock"),
372 locked: false,
373 }
374 }
375
376 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 anyhow::bail!("Failed to acquire session lock after {}ms timeout", timeout_ms)
407 }
408
409 fn is_stale_lock(&self) -> Result<bool> {
411 if !self.lock_path.exists() {
412 return Ok(false);
413 }
414
415 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 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 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 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 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
481pub struct SessionManager {
483 base_dir: PathBuf,
485 current_session: Option<Session>,
487 index: SessionIndex,
489 lock: SessionFileLock,
491}
492
493impl SessionManager {
494 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 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 fn sessions_dir(&self) -> PathBuf {
522 self.base_dir.join("sessions")
523 }
524
525 fn index_path(&self) -> PathBuf {
527 self.sessions_dir().join("index.json")
528 }
529
530 fn session_path(&self, id: &str) -> PathBuf {
532 self.sessions_dir().join(format!("{}.json", id))
533 }
534
535 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 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 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 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 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 Ok(self.current_session.as_ref().unwrap())
588 }
589
590 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 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 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 session.migrate_legacy();
629
630 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 pub fn save_current(&mut self) -> Result<()> {
643 if let Some(ref session) = self.current_session {
644 let session_clone = session.clone();
646
647 self.lock.acquire(5000)?;
649
650 self.index.upsert(session_clone.metadata.clone());
652 self.save_index_locked()?;
653
654 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 self.lock.release()?;
665 }
666 Ok(())
667 }
668
669 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 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 pub fn set_messages(&mut self, messages: Vec<Message>) {
685 if let Some(ref mut session) = self.current_session {
686 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 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 pub fn set_compressed_messages(&mut self, compressed: Vec<Message>) {
706 if let Some(ref mut session) = self.current_session {
707 for summary in &mut session.message_summaries {
709 summary.is_compressed = true;
710 }
711
712 for compressed_msg in &compressed {
715 for (idx, full_msg) in session.full_messages.iter().enumerate() {
716 if session.message_summaries.get(idx).is_some() {
718 let same_role = compressed_msg.role == full_msg.role;
719 if same_role {
720 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 pub fn api_messages(&self) -> Option<&[Message]> {
735 self.current_session.as_ref().map(|s| s.api_messages())
736 }
737
738 pub fn display_messages(&self) -> Option<&[Message]> {
740 self.current_session.as_ref().map(|s| s.display_messages())
741 }
742
743 fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
746 use crate::providers::{ContentBlock, MessageContent, Role};
747
748 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 if cleaned.len() < 5 || is_generic_message(cleaned) {
772 continue;
773 }
774
775 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 pub fn messages(&self) -> Option<&[Message]> {
791 self.current_session.as_ref().map(|s| s.api_messages())
792 }
793
794 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 pub fn full_messages(&self) -> Option<&[Message]> {
801 self.current_session.as_ref().map(|s| s.display_messages())
802 }
803
804 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 pub fn current_name(&self) -> Option<&str> {
813 self.current_session.as_ref().and_then(|s| s.name())
814 }
815
816 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 pub fn clear_current(&mut self) -> Result<()> {
831 if let Some(ref session) = self.current_session {
832 self.lock.acquire(5000)?;
834
835 let path = self.session_path(&session.metadata.id);
837 let _ = std::fs::remove_file(&path);
838 self.index.remove(&session.metadata.id);
840 self.save_index_locked()?;
841
842 self.lock.release()?;
844 }
845 self.current_session = None;
846 Ok(())
847 }
848
849 pub fn list_sessions(&self) -> &[SessionMetadata] {
851 &self.index.sessions
852 }
853
854 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 let path = self.session_path(id);
877 let _ = std::fs::remove_file(&path);
878 self.index.remove(id);
880 }
881
882 self.save_index_locked()?;
883 self.lock.release()?;
884 }
885
886 Ok(removed_count)
887 }
888
889 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 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 pub fn session_count(&self) -> usize {
920 self.index.sessions.len()
921 }
922
923 pub fn has_current(&self) -> bool {
925 self.current_session.is_some()
926 }
927
928 pub fn current_metadata(&self) -> Option<&SessionMetadata> {
930 self.current_session.as_ref().map(|s| &s.metadata)
931 }
932
933 pub fn history_path(&self) -> PathBuf {
935 self.base_dir.join("history.txt")
936 }
937}
938
939impl Session {
940 pub fn name(&self) -> Option<&str> {
942 self.metadata.name.as_deref()
943 }
944}
945
946use anyhow::Context;
947
948fn 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}