Skip to main content

matrixcode_core/session/
metadata.rs

1//! Session metadata types
2
3use anyhow::Result;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use uuid::Uuid;
8
9use crate::compress::CompressionHistoryEntry;
10use crate::providers::Message;
11
12/// Session metadata stored in the index.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionMetadata {
15    /// Unique session identifier (UUID).
16    pub id: String,
17    /// User-defined session name (optional).
18    pub name: Option<String>,
19    /// Project path this session is associated with (optional).
20    pub project_path: Option<String>,
21    /// When the session was created.
22    pub created_at: DateTime<Utc>,
23    /// When the session was last updated.
24    pub updated_at: DateTime<Utc>,
25    /// Number of messages in the session.
26    pub message_count: usize,
27    /// Last input tokens reported.
28    pub last_input_tokens: u64,
29    /// Cumulative output tokens.
30    pub total_output_tokens: u64,
31    /// Compression history entries.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub compression_history: Vec<CompressionHistoryEntry>,
34}
35
36impl SessionMetadata {
37    /// Create a new session metadata with a fresh UUID and auto-generated name.
38    pub fn new(project_path: Option<&Path>) -> Self {
39        let now = Utc::now();
40        Self {
41            id: Uuid::new_v4().to_string(),
42            name: None,
43            project_path: project_path.map(|p| p.to_string_lossy().to_string()),
44            created_at: now,
45            updated_at: now,
46            message_count: 0,
47            last_input_tokens: 0,
48            total_output_tokens: 0,
49            compression_history: Vec::new(),
50        }
51    }
52
53    fn generate_time_name(time: DateTime<Utc>) -> String {
54        let local: chrono::DateTime<chrono::Local> = time.with_timezone(&chrono::Local);
55        local.format("%Y-%m-%d %H:%M").to_string()
56    }
57
58    pub fn add_compression_entry(&mut self, entry: CompressionHistoryEntry) {
59        self.compression_history.push(entry);
60        if self.compression_history.len() > 10 {
61            self.compression_history.remove(0);
62        }
63    }
64
65    pub fn total_tokens_saved(&self) -> u32 {
66        self.compression_history
67            .iter()
68            .map(|e| e.tokens_saved)
69            .sum()
70    }
71
72    pub fn compression_count(&self) -> usize {
73        self.compression_history.len()
74    }
75
76    pub fn display_name(&self) -> String {
77        if let Some(ref name) = self.name {
78            name.clone()
79        } else {
80            Self::generate_time_name(self.created_at)
81        }
82    }
83
84    pub fn short_id(&self) -> String {
85        self.id[..8].to_string()
86    }
87
88    pub fn format_line(&self, is_current: bool) -> String {
89        let marker = if is_current { "*" } else { " " };
90        let name = self.display_name();
91        let msgs = self.message_count;
92        let project = self
93            .project_path
94            .as_ref()
95            .map(|p| {
96                PathBuf::from(p)
97                    .file_name()
98                    .map(|n| n.to_string_lossy().to_string())
99                    .unwrap_or_else(|| p.clone())
100            })
101            .unwrap_or_else(|| "-".to_string());
102
103        let compression_info = if self.compression_count() > 0 {
104            format!("  💾 {} comps", self.compression_count())
105        } else {
106            "".to_string()
107        };
108
109        format!(
110            "{} {}  {} msgs  {}{}",
111            marker, name, msgs, project, compression_info
112        )
113    }
114}
115
116/// Index of all sessions, stored in ~/.matrix/sessions/index.json
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct SessionIndex {
119    /// All known sessions.
120    pub sessions: Vec<SessionMetadata>,
121    /// ID of the most recently active session (for --continue).
122    pub last_session_id: Option<String>,
123}
124
125impl SessionIndex {
126    /// Find a session by ID or name.
127    pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
128        if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
129            return Some(s);
130        }
131        if let Some(s) = self
132            .sessions
133            .iter()
134            .find(|s| s.name.as_deref() == Some(query))
135        {
136            return Some(s);
137        }
138        if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
139            return Some(s);
140        }
141        None
142    }
143
144    pub fn last_session(&self) -> Option<&SessionMetadata> {
145        self.last_session_id
146            .as_ref()
147            .and_then(|id| self.sessions.iter().find(|s| s.id == *id))
148    }
149
150    pub fn upsert(&mut self, meta: SessionMetadata) {
151        self.sessions.retain(|s| s.id != meta.id);
152        self.sessions.push(meta.clone());
153        self.last_session_id = Some(meta.id);
154        self.sessions
155            .sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
156    }
157
158    pub fn remove(&mut self, id: &str) -> Option<SessionMetadata> {
159        let removed = self.sessions.iter().position(|s| s.id == id);
160        if let Some(idx) = removed {
161            let meta = self.sessions.remove(idx);
162            if self.last_session_id.as_deref() == Some(id) {
163                self.last_session_id = self.sessions.first().map(|s| s.id.clone());
164            }
165            Some(meta)
166        } else {
167            None
168        }
169    }
170
171    pub fn rename(&mut self, id: &str, new_name: &str) -> Result<()> {
172        let session = self.sessions.iter_mut().find(|s| s.id == id);
173        if let Some(s) = session {
174            s.name = Some(new_name.to_string());
175            s.updated_at = Utc::now();
176            Ok(())
177        } else {
178            anyhow::bail!("session {} not found", id)
179        }
180    }
181}
182
183/// Message summary for display (lightweight version).
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct MessageSummary {
186    pub role: String,
187    pub preview: String,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub timestamp: Option<DateTime<Utc>>,
190    pub is_compressed: bool,
191    pub original_index: usize,
192}
193
194impl MessageSummary {
195    pub fn from_message(msg: &Message, index: usize) -> Self {
196        use crate::providers::{ContentBlock, MessageContent, Role};
197        use crate::truncate::truncate_chars;
198
199        let role = match msg.role {
200            Role::User => "user",
201            Role::Assistant => "assistant",
202            Role::Tool => "tool",
203            Role::System => "system",
204        };
205
206        let preview = match &msg.content {
207            MessageContent::Text(t) => truncate_chars(t, 100),
208            MessageContent::Blocks(blocks) => {
209                let parts: Vec<String> = blocks
210                    .iter()
211                    .take(3)
212                    .map(|b| match b {
213                        ContentBlock::Text { text } => truncate_chars(text, 50),
214                        ContentBlock::ToolUse { name, .. } => format!("[{}]", name),
215                        ContentBlock::ToolResult { content, .. } => truncate_chars(content, 50),
216                        ContentBlock::Thinking { thinking, .. } => {
217                            format!("💭 {}", truncate_chars(thinking, 30))
218                        }
219                        _ => "...".to_string(),
220                    })
221                    .collect();
222                parts.join(" ")
223            }
224        };
225
226        Self {
227            role: role.to_string(),
228            preview,
229            timestamp: None,
230            is_compressed: false,
231            original_index: index,
232        }
233    }
234}