Skip to main content

agent_session/
types.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4//! Data types for agent session representation.
5
6use 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/// Token usage statistics for a model or session.
14#[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/// A privacy-safe exact path reference recovered from a native tool input.
61///
62/// `path` is a privacy-safe session-cwd-relative candidate. The longitudinal
63/// exporter resolves it against the actual repository root and drops paths
64/// that escape that root. External and unresolved paths are summarized by the
65/// existing `path_groups` field rather than copied here.
66#[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/// Size and fingerprint metadata for a native edit payload.
74///
75/// The source text is intentionally not retained. Hashes permit controlled
76/// hunk comparisons without serializing prompt or code bodies.
77#[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/// Vendor-neutral interaction events extracted from an agent-native transcript.
150#[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/// A parsed agent session with metadata, token usage, and tool invocations.
158#[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    /// Vendor-neutral interaction events extracted from agent-native transcripts.
180    #[serde(default)]
181    pub events: SessionEvents,
182}
183
184/// A candidate session file discovered on disk.
185#[derive(Debug, Clone)]
186pub struct SessionCandidate {
187    pub agent: &'static str,
188    pub path: PathBuf,
189    pub updated: SystemTime,
190}
191
192/// Statistics about a session directory.
193#[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/// Cache for discovered and parsed sessions.
202#[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}