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