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