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 if let Some(entry) = self.entries.get_mut(path)
249 && entry.mtime == candidate.updated
250 {
251 entry.pinned = true;
252 let session = entry.session.clone();
253 self.trim_pinned_details();
254 return session;
255 }
256 let parsed = parse_session_file(&candidate);
257 self.entries.insert(
258 path.to_path_buf(),
259 CacheEntry {
260 mtime: candidate.updated,
261 session: parsed.clone(),
262 pinned: true,
263 },
264 );
265 self.trim_pinned_details();
266 parsed
267 }
268
269 fn trim_pinned_details(&mut self) {
270 const MAX_PINNED_DETAILS: usize = 8;
271 let mut pinned = self
272 .entries
273 .iter()
274 .filter(|(_, entry)| entry.pinned)
275 .map(|(path, entry)| (path.clone(), entry.mtime))
276 .collect::<Vec<_>>();
277 if pinned.len() <= MAX_PINNED_DETAILS {
278 return;
279 }
280 let overflow = pinned.len() - MAX_PINNED_DETAILS;
281 pinned.sort_by_key(|(_, mtime)| *mtime);
282 for (path, _) in pinned.into_iter().take(overflow) {
283 self.entries.remove(&path);
284 }
285 }
286
287 pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
288 self.discover_cached_excluding(limit, max_age, &[])
289 }
290
291 pub fn discover_cached_excluding(
292 &mut self,
293 limit: usize,
294 max_age: Duration,
295 excluded_agents: &[&str],
296 ) -> Vec<AgentSession> {
297 let target = limit.clamp(1, 25);
298 let mut excluded_agents = excluded_agents
299 .iter()
300 .map(|agent| (*agent).to_string())
301 .collect::<Vec<_>>();
302 excluded_agents.sort();
303 if self.last_limit < target
304 || self.last_excluded_agents != excluded_agents
305 || self
306 .last_refresh
307 .is_none_or(|last| last.elapsed() >= max_age)
308 {
309 self.refresh(target, &excluded_agents);
310 }
311 self.cached_sessions.iter().take(target).cloned().collect()
312 }
313
314 fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
315 let mut candidates = discover_session_files();
316 candidates
317 .retain(|candidate| !excluded_agents.iter().any(|agent| agent == candidate.agent));
318 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
319 let target = limit.clamp(1, 25);
320 let mut live_paths = HashSet::new();
321 let mut sessions = Vec::new();
322 let mut seen = HashSet::new();
323
324 for candidate in candidates
325 .into_iter()
326 .take(target.saturating_mul(3).clamp(10, 75))
327 {
328 live_paths.insert(candidate.path.clone());
329 let session = match self.entries.get(&candidate.path) {
330 Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
331 _ => {
332 let parsed = parse_session_file(&candidate);
333 self.entries.insert(
334 candidate.path.clone(),
335 CacheEntry {
336 mtime: candidate.updated,
337 session: parsed.clone(),
338 pinned: false,
339 },
340 );
341 parsed
342 }
343 };
344 if let Some(session) = session
345 && seen.insert(session.display_id.clone())
346 {
347 sessions.push(session);
348 if sessions.len() >= target {
349 break;
350 }
351 }
352 }
353 self.entries
354 .retain(|path, entry| live_paths.contains(path) || (entry.pinned && path.is_file()));
355 self.cached_sessions = sessions;
356 self.last_refresh = Some(Instant::now());
357 self.last_limit = target;
358 self.last_excluded_agents = excluded_agents.to_vec();
359 }
360}