use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
use crate::{discover_session_files, parse_session_file};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct TokenUsage {
pub input_tokens: i64,
pub output_tokens: i64,
pub cache_creation_tokens: i64,
pub cache_read_tokens: i64,
pub total_tokens: i64,
}
impl TokenUsage {
pub(crate) fn add(
&mut self,
input: i64,
output: i64,
cache_creation: i64,
cache_read: i64,
total: i64,
) {
self.input_tokens += input;
self.output_tokens += output;
self.cache_creation_tokens += cache_creation;
self.cache_read_tokens += cache_read;
self.total_tokens += if total > 0 {
total
} else {
input + output + cache_creation + cache_read
};
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPrompt {
pub index: usize,
pub ts_ms: Option<i64>,
pub text_hash: String,
pub preview: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
}
impl UserPrompt {
pub fn prompt_key(&self) -> String {
format!("{}:{}", self.index, self.text_hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolEvent {
pub ts_ms: Option<i64>,
pub prompt_index: usize,
pub tool_name: String,
pub category: String,
pub command: String,
pub command_name: String,
pub effect: String,
pub process_chain: Vec<String>,
pub status: String,
pub path_groups: Vec<String>,
pub domains: Vec<String>,
pub call_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmResponse {
pub ts_ms: Option<i64>,
pub prompt_index: usize,
pub model: String,
pub text_hash: String,
pub preview: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_tokens: u64,
pub total_tokens: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
}
impl LlmResponse {
pub fn token_components(&self) -> Vec<(&'static str, u64)> {
const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
let mut out = Vec::new();
if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
out.push(("input", self.input_tokens));
}
if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
out.push(("output", self.output_tokens));
}
if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
out.push(("cache", self.cache_tokens));
}
if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
out.push(("estimate", self.total_tokens));
}
if out.is_empty() {
out.push(("unknown", 1));
}
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionEvents {
pub prompts: Vec<UserPrompt>,
pub tools: Vec<ToolEvent>,
pub llm_responses: Vec<LlmResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSession {
pub agent_type: String,
pub session_id: String,
pub conversation_id: Option<String>,
pub display_id: String,
pub path: PathBuf,
pub updated: SystemTime,
pub start_timestamp_ms: Option<u64>,
pub end_timestamp_ms: Option<u64>,
pub model: Option<String>,
pub usage: TokenUsage,
pub model_usage: BTreeMap<String, TokenUsage>,
pub tools: BTreeMap<String, usize>,
pub files: BTreeMap<String, usize>,
pub prompt_preview: Option<String>,
pub duration_ms: u64,
pub cwd: Option<String>,
pub last_message_at: Option<String>,
#[serde(default)]
pub events: SessionEvents,
}
#[derive(Debug, Clone)]
pub struct SessionCandidate {
pub agent: &'static str,
pub path: PathBuf,
pub updated: SystemTime,
}
#[derive(Debug, Clone)]
pub struct SessionDirStat {
pub agent: &'static str,
pub dir: PathBuf,
pub sessions: usize,
pub bytes: u64,
}
#[derive(Default)]
pub struct SessionCache {
entries: HashMap<PathBuf, CacheEntry>,
cached_sessions: Vec<AgentSession>,
last_refresh: Option<Instant>,
last_limit: usize,
}
struct CacheEntry {
mtime: SystemTime,
session: Option<AgentSession>,
}
impl SessionCache {
pub fn new() -> Self {
Self::default()
}
pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
let target = limit.clamp(1, 25);
if self.last_limit < target
|| self
.last_refresh
.is_none_or(|last| last.elapsed() >= max_age)
{
self.refresh(target);
}
self.cached_sessions.iter().take(target).cloned().collect()
}
fn refresh(&mut self, limit: usize) {
let mut candidates = discover_session_files();
candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
let target = limit.clamp(1, 25);
let mut live_paths = HashSet::new();
let mut sessions = Vec::new();
let mut seen = HashSet::new();
for candidate in candidates
.into_iter()
.take(target.saturating_mul(3).clamp(10, 75))
{
live_paths.insert(candidate.path.clone());
let session = match self.entries.get(&candidate.path) {
Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
_ => {
let parsed = parse_session_file(&candidate);
self.entries.insert(
candidate.path.clone(),
CacheEntry {
mtime: candidate.updated,
session: parsed.clone(),
},
);
parsed
}
};
if let Some(session) = session
&& seen.insert(session.display_id.clone())
{
sessions.push(session);
if sessions.len() >= target {
break;
}
}
}
self.entries.retain(|path, _| live_paths.contains(path));
self.cached_sessions = sessions;
self.last_refresh = Some(Instant::now());
self.last_limit = target;
}
}