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)]
208pub struct Session {
209 pub metadata: SessionMetadata,
210 pub messages: Vec<Message>,
211}
212
213impl Session {
214 pub fn new(project_path: Option<&Path>) -> Self {
216 Self {
217 metadata: SessionMetadata::new(project_path),
218 messages: Vec::new(),
219 }
220 }
221
222 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 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
241pub struct SessionManager {
243 base_dir: PathBuf,
245 current_session: Option<Session>,
247 index: SessionIndex,
249}
250
251impl SessionManager {
252 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 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 fn sessions_dir(&self) -> PathBuf {
278 self.base_dir.join("sessions")
279 }
280
281 fn index_path(&self) -> PathBuf {
283 self.sessions_dir().join("index.json")
284 }
285
286 fn session_path(&self, id: &str) -> PathBuf {
288 self.sessions_dir().join(format!("{}.json", id))
289 }
290
291 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 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 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 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 Ok(self.current_session.as_ref().unwrap())
336 }
337
338 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 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 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 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 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.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 pub fn save_current(&mut self) -> Result<()> {
396 if let Some(ref session) = self.current_session {
397 self.index.upsert(session.metadata.clone());
399 self.save_index()?;
400
401 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 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 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 pub fn set_messages(&mut self, messages: Vec<Message>) {
429 if let Some(ref mut session) = self.current_session {
430 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 fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
447 use crate::providers::{ContentBlock, MessageContent, Role};
448
449 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 if cleaned.len() < 5 || is_generic_message(cleaned) {
473 continue;
474 }
475
476 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 pub fn messages(&self) -> Option<&[Message]> {
492 self.current_session.as_ref().map(|s| s.messages.as_slice())
493 }
494
495 pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
497 self.current_session.as_mut().map(|s| &mut s.messages)
498 }
499
500 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 pub fn current_name(&self) -> Option<&str> {
509 self.current_session.as_ref().and_then(|s| s.name())
510 }
511
512 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 pub fn clear_current(&mut self) -> Result<()> {
527 if let Some(ref session) = self.current_session {
528 let path = self.session_path(&session.metadata.id);
530 let _ = std::fs::remove_file(&path);
531 self.index.remove(&session.metadata.id);
533 self.save_index()?;
534 }
535 self.current_session = None;
536 Ok(())
537 }
538
539 pub fn list_sessions(&self) -> &[SessionMetadata] {
541 &self.index.sessions
542 }
543
544 pub fn has_current(&self) -> bool {
546 self.current_session.is_some()
547 }
548
549 pub fn current_metadata(&self) -> Option<&SessionMetadata> {
551 self.current_session.as_ref().map(|s| &s.metadata)
552 }
553
554 pub fn history_path(&self) -> PathBuf {
556 self.base_dir.join("history.txt")
557 }
558}
559
560impl Session {
561 pub fn name(&self) -> Option<&str> {
563 self.metadata.name.as_deref()
564 }
565}
566
567use anyhow::Context;
568
569fn 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}