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.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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112pub struct SessionIndex {
113 pub sessions: Vec<SessionMetadata>,
115 pub last_session_id: Option<String>,
117}
118
119impl SessionIndex {
120 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#[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}