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.iter().map(|e| e.tokens_saved).sum()
71 }
72
73 pub fn compression_count(&self) -> usize {
75 self.compression_history.len()
76 }
77
78 pub fn display_name(&self) -> String {
81 if let Some(ref name) = self.name {
82 name.clone()
83 } else {
84 Self::generate_time_name(self.created_at)
86 }
87 }
88
89 pub fn short_id(&self) -> String {
91 self.id[..8].to_string()
92 }
93
94 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct SessionIndex {
124 pub sessions: Vec<SessionMetadata>,
126 pub last_session_id: Option<String>,
128}
129
130impl SessionIndex {
131 pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
133 if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
135 return Some(s);
136 }
137 if let Some(s) = self.sessions.iter().find(|s| s.name.as_deref() == Some(query)) {
139 return Some(s);
140 }
141 if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
143 return Some(s);
144 }
145 None
146 }
147
148 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 pub fn upsert(&mut self, meta: SessionMetadata) {
157 self.sessions.retain(|s| s.id != meta.id);
159 self.sessions.push(meta.clone());
161 self.last_session_id = Some(meta.id);
163 self.sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
165 }
166
167 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct Session {
197 pub metadata: SessionMetadata,
198 pub messages: Vec<Message>,
199}
200
201impl Session {
202 pub fn new(project_path: Option<&Path>) -> Self {
204 Self {
205 metadata: SessionMetadata::new(project_path),
206 messages: Vec::new(),
207 }
208 }
209
210 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 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
229pub struct SessionManager {
231 base_dir: PathBuf,
233 current_session: Option<Session>,
235 index: SessionIndex,
237}
238
239impl SessionManager {
240 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 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 fn sessions_dir(&self) -> PathBuf {
266 self.base_dir.join("sessions")
267 }
268
269 fn index_path(&self) -> PathBuf {
271 self.sessions_dir().join("index.json")
272 }
273
274 fn session_path(&self, id: &str) -> PathBuf {
276 self.sessions_dir().join(format!("{}.json", id))
277 }
278
279 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 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 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 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 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 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 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 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 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.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 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 self.index.upsert(session.metadata.clone());
392 self.save_index()?;
393 }
394 Ok(())
395 }
396
397 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 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 pub fn set_messages(&mut self, messages: Vec<Message>) {
413 if let Some(ref mut session) = self.current_session {
414 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 fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
429 use crate::providers::{Role, MessageContent, ContentBlock};
430
431 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 if cleaned.len() < 5 || is_generic_message(cleaned) {
454 continue;
455 }
456
457 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 pub fn messages(&self) -> Option<&[Message]> {
473 self.current_session.as_ref().map(|s| s.messages.as_slice())
474 }
475
476 pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
478 self.current_session.as_mut().map(|s| &mut s.messages)
479 }
480
481 pub fn current_id(&self) -> Option<&str> {
483 self.current_session.as_ref().map(|s| s.metadata.id.as_str())
484 }
485
486 pub fn current_name(&self) -> Option<&str> {
488 self.current_session.as_ref().and_then(|s| s.name())
489 }
490
491 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 pub fn clear_current(&mut self) -> Result<()> {
506 if let Some(ref session) = self.current_session {
507 let path = self.session_path(&session.metadata.id);
509 let _ = std::fs::remove_file(&path);
510 self.index.remove(&session.metadata.id);
512 self.save_index()?;
513 }
514 self.current_session = None;
515 Ok(())
516 }
517
518 pub fn list_sessions(&self) -> &[SessionMetadata] {
520 &self.index.sessions
521 }
522
523 pub fn has_current(&self) -> bool {
525 self.current_session.is_some()
526 }
527
528 pub fn current_metadata(&self) -> Option<&SessionMetadata> {
530 self.current_session.as_ref().map(|s| &s.metadata)
531 }
532
533 pub fn history_path(&self) -> PathBuf {
535 self.base_dir.join("history.txt")
536 }
537}
538
539impl Session {
540 pub fn name(&self) -> Option<&str> {
542 self.metadata.name.as_deref()
543 }
544}
545
546use anyhow::Context;
547
548fn 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}