1use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::{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 #[serde(default, skip_serializing_if = "String::is_empty")]
51 pub text: String,
52 pub preview: String,
53 #[serde(default, skip_serializing_if = "String::is_empty")]
54 pub tag: String,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub task_path: Vec<String>,
58}
59
60impl UserPrompt {
61 pub fn prompt_key(&self) -> String {
62 format!("{}:{}", self.index, self.text_hash)
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct ToolPath {
68 pub path: String,
71 pub access: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub previous_path: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ToolEvent {
81 pub ts_ms: Option<i64>,
82 pub prompt_index: usize,
83 pub tool_name: String,
84 pub category: String,
85 pub command: String,
86 pub command_name: String,
87 pub effect: String,
88 pub process_chain: Vec<String>,
89 pub status: String,
90 pub path_groups: Vec<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub paths: Vec<ToolPath>,
93 pub domains: Vec<String>,
94 pub call_id: Option<String>,
95 #[serde(default, skip_serializing_if = "String::is_empty")]
97 pub invoked_skill: String,
98 #[serde(default, skip_serializing_if = "String::is_empty")]
100 pub skill: String,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub task_path: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LlmResponse {
108 pub ts_ms: Option<i64>,
109 pub prompt_index: usize,
110 pub model: String,
111 #[serde(default, skip_serializing_if = "String::is_empty")]
113 pub source_id: String,
114 pub text_hash: String,
115 #[serde(default, skip_serializing_if = "String::is_empty")]
117 pub text: String,
118 pub preview: String,
119 pub input_tokens: u64,
120 pub output_tokens: u64,
121 pub cache_tokens: u64,
122 pub total_tokens: u64,
123 #[serde(default, skip_serializing_if = "String::is_empty")]
124 pub tag: String,
125 #[serde(default, skip_serializing_if = "String::is_empty")]
128 pub response_phase: String,
129 #[serde(default, skip_serializing_if = "String::is_empty")]
131 pub skill: String,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub task_path: Vec<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct PlanStep {
140 pub step: String,
141 pub status: String,
142}
143
144impl LlmResponse {
145 pub fn token_components(&self) -> Vec<(&'static str, u64)> {
146 const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
147 const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
148 let mut out = Vec::new();
149 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
150 out.push(("input", self.input_tokens));
151 }
152 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
153 out.push(("output", self.output_tokens));
154 }
155 if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
156 out.push(("cache", self.cache_tokens));
157 }
158 if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
159 out.push(("estimate", self.total_tokens));
160 }
161 if out.is_empty() {
162 out.push(("unknown", 1));
163 }
164 out
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
170pub struct SessionEvents {
171 pub prompts: Vec<UserPrompt>,
172 pub tools: Vec<ToolEvent>,
173 pub llm_responses: Vec<LlmResponse>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub plan: Vec<PlanStep>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct AgentSession {
181 pub agent_type: String,
182 pub session_id: String,
183 pub conversation_id: Option<String>,
184 pub display_id: String,
185 pub path: PathBuf,
186 pub updated: SystemTime,
187 pub start_timestamp_ms: Option<u64>,
188 pub end_timestamp_ms: Option<u64>,
189 pub model: Option<String>,
190 pub usage: TokenUsage,
191 pub model_usage: BTreeMap<String, TokenUsage>,
192 pub tools: BTreeMap<String, usize>,
193 pub files: BTreeMap<String, usize>,
194 pub prompt_preview: Option<String>,
195 pub duration_ms: u64,
196 pub cwd: Option<String>,
197 pub last_message_at: Option<String>,
198 #[serde(default)]
200 pub events: SessionEvents,
201}
202
203#[derive(Debug, Clone)]
205pub struct SessionCandidate {
206 pub agent: &'static str,
207 pub path: PathBuf,
208 pub updated: SystemTime,
209}
210
211#[derive(Debug, Clone)]
213pub struct SessionDirStat {
214 pub agent: &'static str,
215 pub dir: PathBuf,
216 pub sessions: usize,
217 pub bytes: u64,
218}
219
220#[derive(Default)]
222pub struct SessionCache {
223 entries: HashMap<PathBuf, CacheEntry>,
224 cached_sessions: Vec<AgentSession>,
225 last_refresh: Option<Instant>,
226 last_limit: usize,
227 last_excluded_agents: Vec<String>,
228}
229
230struct CacheEntry {
231 mtime: SystemTime,
232 session: Option<AgentSession>,
233 pinned: bool,
234}
235
236impl SessionCache {
237 pub fn new() -> Self {
238 Self::default()
239 }
240
241 pub fn parse_path_cached(&mut self, path: &Path) -> Option<AgentSession> {
244 let Some(candidate) = crate::session_candidate_from_path(path) else {
245 self.entries.remove(path);
246 return None;
247 };
248 self.parse_candidate_cached(&candidate)
249 }
250
251 pub fn parse_candidate_cached(&mut self, candidate: &SessionCandidate) -> Option<AgentSession> {
253 if let Some(entry) = self.entries.get_mut(&candidate.path)
254 && entry.mtime == candidate.updated
255 {
256 entry.pinned = true;
257 let session = entry.session.clone();
258 self.trim_pinned_details();
259 return session;
260 }
261 let parsed = parse_session_file(candidate);
262 self.entries.insert(
263 candidate.path.clone(),
264 CacheEntry {
265 mtime: candidate.updated,
266 session: parsed.clone(),
267 pinned: true,
268 },
269 );
270 self.trim_pinned_details();
271 parsed
272 }
273
274 fn trim_pinned_details(&mut self) {
275 const MAX_PINNED_DETAILS: usize = 8;
276 let mut pinned = self
277 .entries
278 .iter()
279 .filter(|(_, entry)| entry.pinned)
280 .map(|(path, entry)| (path.clone(), entry.mtime))
281 .collect::<Vec<_>>();
282 if pinned.len() <= MAX_PINNED_DETAILS {
283 return;
284 }
285 let overflow = pinned.len() - MAX_PINNED_DETAILS;
286 pinned.sort_by_key(|(_, mtime)| *mtime);
287 for (path, _) in pinned.into_iter().take(overflow) {
288 self.entries.remove(&path);
289 }
290 }
291
292 pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
293 self.discover_cached_excluding(limit, max_age, &[])
294 }
295
296 pub fn discover_cached_excluding(
297 &mut self,
298 limit: usize,
299 max_age: Duration,
300 excluded_agents: &[&str],
301 ) -> Vec<AgentSession> {
302 let target = limit.clamp(1, 25);
303 let mut excluded_agents = excluded_agents
304 .iter()
305 .map(|agent| (*agent).to_string())
306 .collect::<Vec<_>>();
307 excluded_agents.sort();
308 if self.last_limit < target
309 || self.last_excluded_agents != excluded_agents
310 || self
311 .last_refresh
312 .is_none_or(|last| last.elapsed() >= max_age)
313 {
314 self.refresh(target, &excluded_agents);
315 }
316 self.cached_sessions.iter().take(target).cloned().collect()
317 }
318
319 fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
320 let mut candidates = discover_session_files();
321 candidates
322 .retain(|candidate| !excluded_agents.iter().any(|agent| agent == candidate.agent));
323 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
324 let target = limit.clamp(1, 25);
325 let mut live_paths = HashSet::new();
326 let mut sessions = Vec::new();
327 let mut seen = HashSet::new();
328
329 for candidate in candidates
330 .into_iter()
331 .take(target.saturating_mul(3).clamp(10, 75))
332 {
333 live_paths.insert(candidate.path.clone());
334 let session = match self.entries.get(&candidate.path) {
335 Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
336 _ => {
337 let parsed = parse_session_file(&candidate);
338 self.entries.insert(
339 candidate.path.clone(),
340 CacheEntry {
341 mtime: candidate.updated,
342 session: parsed.clone(),
343 pinned: false,
344 },
345 );
346 parsed
347 }
348 };
349 if let Some(session) = session
350 && seen.insert(session.display_id.clone())
351 {
352 sessions.push(session);
353 if sessions.len() >= target {
354 break;
355 }
356 }
357 }
358 self.entries
359 .retain(|path, entry| live_paths.contains(path) || (entry.pinned && path.is_file()));
360 self.cached_sessions = sessions;
361 self.last_refresh = Some(Instant::now());
362 self.last_limit = target;
363 self.last_excluded_agents = excluded_agents.to_vec();
364 }
365}