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.iter().map(|e| e.tokens_saved).sum()
67    }
68
69    pub fn compression_count(&self) -> usize {
70        self.compression_history.len()
71    }
72
73    pub fn display_name(&self) -> String {
74        if let Some(ref name) = self.name {
75            name.clone()
76        } else {
77            Self::generate_time_name(self.created_at)
78        }
79    }
80
81    pub fn short_id(&self) -> String {
82        self.id[..8].to_string()
83    }
84
85    pub fn format_line(&self, is_current: bool) -> String {
86        let marker = if is_current { "*" } else { " " };
87        let name = self.display_name();
88        let msgs = self.message_count;
89        let project = self
90            .project_path
91            .as_ref()
92            .map(|p| {
93                PathBuf::from(p)
94                    .file_name()
95                    .map(|n| n.to_string_lossy().to_string())
96                    .unwrap_or_else(|| p.clone())
97            })
98            .unwrap_or_else(|| "-".to_string());
99
100        let compression_info = if self.compression_count() > 0 {
101            format!("  💾 {} comps", self.compression_count())
102        } else {
103            "".to_string()
104        };
105
106        format!("{} {}  {} msgs  {}{}", marker, name, msgs, project, compression_info)
107    }
108}
109
110/// Index of all sessions, stored in ~/.matrix/sessions/index.json
111#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112pub struct SessionIndex {
113    /// All known sessions.
114    pub sessions: Vec<SessionMetadata>,
115    /// ID of the most recently active session (for --continue).
116    pub last_session_id: Option<String>,
117}
118
119impl SessionIndex {
120    /// Find a session by ID or name.
121    pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
122        if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
123            return Some(s);
124        }
125        if let Some(s) = self.sessions.iter().find(|s| s.name.as_deref() == Some(query)) {
126            return Some(s);
127        }
128        if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
129            return Some(s);
130        }
131        None
132    }
133
134    pub fn last_session(&self) -> Option<&SessionMetadata> {
135        self.last_session_id
136            .as_ref()
137            .and_then(|id| self.sessions.iter().find(|s| s.id == *id))
138    }
139
140    pub fn upsert(&mut self, meta: SessionMetadata) {
141        self.sessions.retain(|s| s.id != meta.id);
142        self.sessions.push(meta.clone());
143        self.last_session_id = Some(meta.id);
144        self.sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
145    }
146
147    pub fn remove(&mut self, id: &str) -> Option<SessionMetadata> {
148        let removed = self.sessions.iter().position(|s| s.id == id);
149        if let Some(idx) = removed {
150            let meta = self.sessions.remove(idx);
151            if self.last_session_id.as_deref() == Some(id) {
152                self.last_session_id = self.sessions.first().map(|s| s.id.clone());
153            }
154            Some(meta)
155        } else {
156            None
157        }
158    }
159
160    pub fn rename(&mut self, id: &str, new_name: &str) -> Result<()> {
161        let session = self.sessions.iter_mut().find(|s| s.id == id);
162        if let Some(s) = session {
163            s.name = Some(new_name.to_string());
164            s.updated_at = Utc::now();
165            Ok(())
166        } else {
167            anyhow::bail!("session {} not found", id)
168        }
169    }
170}
171
172/// Message summary for display (lightweight version).
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct MessageSummary {
175    pub role: String,
176    pub preview: String,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub timestamp: Option<DateTime<Utc>>,
179    pub is_compressed: bool,
180    pub original_index: usize,
181}
182
183impl MessageSummary {
184    pub fn from_message(msg: &Message, index: usize) -> Self {
185        use crate::providers::{ContentBlock, MessageContent, Role};
186        use crate::truncate::truncate_chars;
187
188        let role = match msg.role {
189            Role::User => "user",
190            Role::Assistant => "assistant",
191            Role::Tool => "tool",
192            Role::System => "system",
193        };
194
195        let preview = match &msg.content {
196            MessageContent::Text(t) => truncate_chars(t, 100),
197            MessageContent::Blocks(blocks) => {
198                let parts: Vec<String> = blocks
199                    .iter()
200                    .take(3)
201                    .map(|b| match b {
202                        ContentBlock::Text { text } => truncate_chars(text, 50),
203                        ContentBlock::ToolUse { name, .. } => format!("[{}]", name),
204                        ContentBlock::ToolResult { content, .. } => truncate_chars(content, 50),
205                        ContentBlock::Thinking { thinking, .. } => format!("💭 {}", truncate_chars(thinking, 30)),
206                        _ => "...".to_string(),
207                    })
208                    .collect();
209                parts.join(" ")
210            }
211        };
212
213        Self {
214            role: role.to_string(),
215            preview,
216            timestamp: None,
217            is_compressed: false,
218            original_index: index,
219        }
220    }
221}