1use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::PathBuf;
9use std::time::{Duration, Instant, SystemTime};
10
11use crate::{discover_session_files, parse_session_file};
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
15pub struct TokenUsage {
16 pub input_tokens: i64,
17 pub output_tokens: i64,
18 pub cache_creation_tokens: i64,
19 pub cache_read_tokens: i64,
20 pub total_tokens: i64,
21}
22
23impl TokenUsage {
24 pub(crate) fn add(
25 &mut self,
26 input: i64,
27 output: i64,
28 cache_creation: i64,
29 cache_read: i64,
30 total: i64,
31 ) {
32 self.input_tokens += input;
33 self.output_tokens += output;
34 self.cache_creation_tokens += cache_creation;
35 self.cache_read_tokens += cache_read;
36 self.total_tokens += if total > 0 {
37 total
38 } else {
39 input + output + cache_creation + cache_read
40 };
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct UserPrompt {
46 pub index: usize,
47 pub ts_ms: Option<i64>,
48 pub text_hash: String,
49 pub preview: String,
50 #[serde(default, skip_serializing_if = "String::is_empty")]
51 pub tag: String,
52}
53
54impl UserPrompt {
55 pub fn prompt_key(&self) -> String {
56 format!("{}:{}", self.index, self.text_hash)
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolEvent {
62 pub ts_ms: Option<i64>,
63 pub prompt_index: usize,
64 pub tool_name: String,
65 pub category: String,
66 pub command: String,
67 pub command_name: String,
68 pub effect: String,
69 pub process_chain: Vec<String>,
70 pub status: String,
71 pub path_groups: Vec<String>,
72 pub domains: Vec<String>,
73 pub call_id: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct LlmResponse {
78 pub ts_ms: Option<i64>,
79 pub prompt_index: usize,
80 pub model: String,
81 pub text_hash: String,
82 pub preview: String,
83 pub input_tokens: u64,
84 pub output_tokens: u64,
85 pub cache_tokens: u64,
86 pub total_tokens: u64,
87 #[serde(default, skip_serializing_if = "String::is_empty")]
88 pub tag: String,
89}
90
91impl LlmResponse {
92 pub fn token_components(&self) -> Vec<(&'static str, u64)> {
93 const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
94 const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
95 let mut out = Vec::new();
96 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
97 out.push(("input", self.input_tokens));
98 }
99 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
100 out.push(("output", self.output_tokens));
101 }
102 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
103 out.push(("cache", self.cache_tokens));
104 }
105 if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
106 out.push(("estimate", self.total_tokens));
107 }
108 if out.is_empty() {
109 out.push(("unknown", 1));
110 }
111 out
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117pub struct SessionEvents {
118 pub prompts: Vec<UserPrompt>,
119 pub tools: Vec<ToolEvent>,
120 pub llm_responses: Vec<LlmResponse>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AgentSession {
126 pub agent_type: String,
127 pub session_id: String,
128 pub conversation_id: Option<String>,
129 pub display_id: String,
130 pub path: PathBuf,
131 pub updated: SystemTime,
132 pub start_timestamp_ms: Option<u64>,
133 pub end_timestamp_ms: Option<u64>,
134 pub model: Option<String>,
135 pub usage: TokenUsage,
136 pub model_usage: BTreeMap<String, TokenUsage>,
137 pub tools: BTreeMap<String, usize>,
138 pub files: BTreeMap<String, usize>,
139 pub prompt_preview: Option<String>,
140 pub duration_ms: u64,
141 pub cwd: Option<String>,
142 pub last_message_at: Option<String>,
143 #[serde(default)]
145 pub events: SessionEvents,
146}
147
148#[derive(Debug, Clone)]
150pub struct SessionCandidate {
151 pub agent: &'static str,
152 pub path: PathBuf,
153 pub updated: SystemTime,
154}
155
156#[derive(Debug, Clone)]
158pub struct SessionDirStat {
159 pub agent: &'static str,
160 pub dir: PathBuf,
161 pub sessions: usize,
162 pub bytes: u64,
163}
164
165#[derive(Default)]
167pub struct SessionCache {
168 entries: HashMap<PathBuf, CacheEntry>,
169 cached_sessions: Vec<AgentSession>,
170 last_refresh: Option<Instant>,
171 last_limit: usize,
172 last_excluded_agents: Vec<String>,
173}
174
175struct CacheEntry {
176 mtime: SystemTime,
177 session: Option<AgentSession>,
178}
179
180impl SessionCache {
181 pub fn new() -> Self {
182 Self::default()
183 }
184
185 pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
186 self.discover_cached_excluding(limit, max_age, &[])
187 }
188
189 pub fn discover_cached_excluding(
190 &mut self,
191 limit: usize,
192 max_age: Duration,
193 excluded_agents: &[&str],
194 ) -> Vec<AgentSession> {
195 let target = limit.clamp(1, 25);
196 let mut excluded_agents = excluded_agents
197 .iter()
198 .map(|agent| (*agent).to_string())
199 .collect::<Vec<_>>();
200 excluded_agents.sort();
201 if self.last_limit < target
202 || self.last_excluded_agents != excluded_agents
203 || self
204 .last_refresh
205 .is_none_or(|last| last.elapsed() >= max_age)
206 {
207 self.refresh(target, &excluded_agents);
208 }
209 self.cached_sessions.iter().take(target).cloned().collect()
210 }
211
212 fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
213 let mut candidates = discover_session_files();
214 candidates.retain(|candidate| {
215 !excluded_agents
216 .iter()
217 .any(|agent| agent == candidate.agent)
218 });
219 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
220 let target = limit.clamp(1, 25);
221 let mut live_paths = HashSet::new();
222 let mut sessions = Vec::new();
223 let mut seen = HashSet::new();
224
225 for candidate in candidates
226 .into_iter()
227 .take(target.saturating_mul(3).clamp(10, 75))
228 {
229 live_paths.insert(candidate.path.clone());
230 let session = match self.entries.get(&candidate.path) {
231 Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
232 _ => {
233 let parsed = parse_session_file(&candidate);
234 self.entries.insert(
235 candidate.path.clone(),
236 CacheEntry {
237 mtime: candidate.updated,
238 session: parsed.clone(),
239 },
240 );
241 parsed
242 }
243 };
244 if let Some(session) = session
245 && seen.insert(session.display_id.clone())
246 {
247 sessions.push(session);
248 if sessions.len() >= target {
249 break;
250 }
251 }
252 }
253 self.entries.retain(|path, _| live_paths.contains(path));
254 self.cached_sessions = sessions;
255 self.last_refresh = Some(Instant::now());
256 self.last_limit = target;
257 self.last_excluded_agents = excluded_agents.to_vec();
258 }
259}