matrixcode_core/session/
metadata.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionMetadata {
15 pub id: String,
17 pub name: Option<String>,
19 pub project_path: Option<String>,
21 pub created_at: DateTime<Utc>,
23 pub updated_at: DateTime<Utc>,
25 pub message_count: usize,
27 pub last_input_tokens: u64,
29 pub total_output_tokens: u64,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub compression_history: Vec<CompressionHistoryEntry>,
34}
35
36impl SessionMetadata {
37 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct SessionIndex {
119 pub sessions: Vec<SessionMetadata>,
121 pub last_session_id: Option<String>,
123}
124
125impl SessionIndex {
126 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#[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}