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, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ToolPath {
62 pub path: String,
65 pub access: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub previous_path: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ToolEvent {
75 pub ts_ms: Option<i64>,
76 pub prompt_index: usize,
77 pub tool_name: String,
78 pub category: String,
79 pub command: String,
80 pub command_name: String,
81 pub effect: String,
82 pub process_chain: Vec<String>,
83 pub status: String,
84 pub path_groups: Vec<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub paths: Vec<ToolPath>,
87 pub domains: Vec<String>,
88 pub call_id: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct LlmResponse {
93 pub ts_ms: Option<i64>,
94 pub prompt_index: usize,
95 pub model: String,
96 pub text_hash: String,
97 pub preview: String,
98 pub input_tokens: u64,
99 pub output_tokens: u64,
100 pub cache_tokens: u64,
101 pub total_tokens: u64,
102 #[serde(default, skip_serializing_if = "String::is_empty")]
103 pub tag: String,
104}
105
106impl LlmResponse {
107 pub fn token_components(&self) -> Vec<(&'static str, u64)> {
108 const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
109 const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
110 let mut out = Vec::new();
111 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
112 out.push(("input", self.input_tokens));
113 }
114 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
115 out.push(("output", self.output_tokens));
116 }
117 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
118 out.push(("cache", self.cache_tokens));
119 }
120 if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
121 out.push(("estimate", self.total_tokens));
122 }
123 if out.is_empty() {
124 out.push(("unknown", 1));
125 }
126 out
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct SessionEvents {
133 pub prompts: Vec<UserPrompt>,
134 pub tools: Vec<ToolEvent>,
135 pub llm_responses: Vec<LlmResponse>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct AgentSession {
141 pub agent_type: String,
142 pub session_id: String,
143 pub conversation_id: Option<String>,
144 pub display_id: String,
145 pub path: PathBuf,
146 pub updated: SystemTime,
147 pub start_timestamp_ms: Option<u64>,
148 pub end_timestamp_ms: Option<u64>,
149 pub model: Option<String>,
150 pub usage: TokenUsage,
151 pub model_usage: BTreeMap<String, TokenUsage>,
152 pub tools: BTreeMap<String, usize>,
153 pub files: BTreeMap<String, usize>,
154 pub prompt_preview: Option<String>,
155 pub duration_ms: u64,
156 pub cwd: Option<String>,
157 pub last_message_at: Option<String>,
158 #[serde(default)]
160 pub events: SessionEvents,
161}
162
163#[derive(Debug, Clone)]
165pub struct SessionCandidate {
166 pub agent: &'static str,
167 pub path: PathBuf,
168 pub updated: SystemTime,
169}
170
171#[derive(Debug, Clone)]
173pub struct SessionDirStat {
174 pub agent: &'static str,
175 pub dir: PathBuf,
176 pub sessions: usize,
177 pub bytes: u64,
178}
179
180#[derive(Default)]
182pub struct SessionCache {
183 entries: HashMap<PathBuf, CacheEntry>,
184 cached_sessions: Vec<AgentSession>,
185 last_refresh: Option<Instant>,
186 last_limit: usize,
187 last_excluded_agents: Vec<String>,
188}
189
190struct CacheEntry {
191 mtime: SystemTime,
192 session: Option<AgentSession>,
193}
194
195impl SessionCache {
196 pub fn new() -> Self {
197 Self::default()
198 }
199
200 pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
201 self.discover_cached_excluding(limit, max_age, &[])
202 }
203
204 pub fn discover_cached_excluding(
205 &mut self,
206 limit: usize,
207 max_age: Duration,
208 excluded_agents: &[&str],
209 ) -> Vec<AgentSession> {
210 let target = limit.clamp(1, 25);
211 let mut excluded_agents = excluded_agents
212 .iter()
213 .map(|agent| (*agent).to_string())
214 .collect::<Vec<_>>();
215 excluded_agents.sort();
216 if self.last_limit < target
217 || self.last_excluded_agents != excluded_agents
218 || self
219 .last_refresh
220 .is_none_or(|last| last.elapsed() >= max_age)
221 {
222 self.refresh(target, &excluded_agents);
223 }
224 self.cached_sessions.iter().take(target).cloned().collect()
225 }
226
227 fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
228 let mut candidates = discover_session_files();
229 candidates
230 .retain(|candidate| !excluded_agents.iter().any(|agent| agent == candidate.agent));
231 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
232 let target = limit.clamp(1, 25);
233 let mut live_paths = HashSet::new();
234 let mut sessions = Vec::new();
235 let mut seen = HashSet::new();
236
237 for candidate in candidates
238 .into_iter()
239 .take(target.saturating_mul(3).clamp(10, 75))
240 {
241 live_paths.insert(candidate.path.clone());
242 let session = match self.entries.get(&candidate.path) {
243 Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
244 _ => {
245 let parsed = parse_session_file(&candidate);
246 self.entries.insert(
247 candidate.path.clone(),
248 CacheEntry {
249 mtime: candidate.updated,
250 session: parsed.clone(),
251 },
252 );
253 parsed
254 }
255 };
256 if let Some(session) = session
257 && seen.insert(session.display_id.clone())
258 {
259 sessions.push(session);
260 if sessions.len() >= target {
261 break;
262 }
263 }
264 }
265 self.entries.retain(|path, _| live_paths.contains(path));
266 self.cached_sessions = sessions;
267 self.last_refresh = Some(Instant::now());
268 self.last_limit = target;
269 self.last_excluded_agents = excluded_agents.to_vec();
270 }
271}