1use std::fs;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use super::sessions::{git_sessions_dir, TokenUsage};
11use crate::util::now_iso8601;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type")]
19pub enum SessionEvent {
20 #[serde(rename = "session_start")]
21 SessionStart {
22 ts: String,
23 session_id: String,
24 agent: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 model: Option<String>,
27 },
28 #[serde(rename = "prompt")]
29 Prompt {
30 ts: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 text: Option<String>,
33 },
34 #[serde(rename = "tool_call")]
35 ToolCall {
36 ts: String,
37 tool: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 input_summary: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 duration_ms: Option<u64>,
42 },
43 #[serde(rename = "tool_result")]
44 ToolResult {
45 ts: String,
46 tool: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 output_size: Option<u64>,
49 },
50 #[serde(rename = "response")]
51 Response {
52 ts: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 tokens: Option<TokenUsage>,
55 },
56 #[serde(rename = "thinking")]
57 Thinking {
58 ts: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 content: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 content_size: Option<u64>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 reasoning_tokens: Option<u64>,
68 },
69 #[serde(rename = "model_update")]
70 ModelUpdate { ts: String, model: String },
71 #[serde(rename = "file_change")]
72 FileChange {
73 ts: String,
74 path: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 action: Option<String>, },
78 #[serde(rename = "session_end")]
79 SessionEnd {
80 ts: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 duration_s: Option<u64>,
83 #[serde(default)]
84 turns: u32,
85 },
86}
87
88fn journal_path(session_id: &str) -> Option<PathBuf> {
93 git_sessions_dir().map(|d| d.join(format!("{}.jsonl", session_id)))
94}
95
96pub fn append_event(session_id: &str, event: &SessionEvent) {
98 let path = match journal_path(session_id) {
99 Some(p) => p,
100 None => return,
101 };
102 let _ = fs::create_dir_all(path.parent().unwrap_or(&PathBuf::from(".")));
103
104 let line = serde_json::to_string(event).unwrap_or_default();
105 let mut file = fs::OpenOptions::new().create(true).append(true).open(&path);
106
107 if let Ok(ref mut f) = file {
108 use std::io::Write;
109 let _ = writeln!(f, "{}", line);
110 }
111}
112
113pub fn load_events(session_id: &str) -> Vec<SessionEvent> {
115 let path = match journal_path(session_id) {
116 Some(p) => p,
117 None => return vec![],
118 };
119 if !path.exists() {
120 return vec![];
121 }
122
123 let content = match fs::read_to_string(&path) {
124 Ok(c) => c,
125 Err(_) => return vec![],
126 };
127
128 content
129 .lines()
130 .filter(|l| !l.trim().is_empty())
131 .filter_map(|l| serde_json::from_str(l).ok())
132 .collect()
133}
134
135pub fn journal_size(session_id: &str) -> u64 {
137 journal_path(session_id)
138 .and_then(|p| fs::metadata(&p).ok())
139 .map(|m| m.len())
140 .unwrap_or(0)
141}
142
143pub fn list_journal_files() -> Vec<String> {
145 let dir = match git_sessions_dir() {
146 Some(d) => d,
147 None => return vec![],
148 };
149 if !dir.is_dir() {
150 return vec![];
151 }
152 fs::read_dir(&dir)
153 .ok()
154 .into_iter()
155 .flatten()
156 .filter_map(|e| e.ok())
157 .filter(|e| {
158 e.path()
159 .extension()
160 .map(|ext| ext == "jsonl")
161 .unwrap_or(false)
162 })
163 .filter_map(|e| {
164 e.path()
165 .file_stem()
166 .map(|s| s.to_string_lossy().to_string())
167 })
168 .collect()
169}
170
171pub fn clear_journals() -> usize {
173 let dir = match git_sessions_dir() {
174 Some(d) => d,
175 None => return 0,
176 };
177 if !dir.is_dir() {
178 return 0;
179 }
180 let mut count = 0;
181 if let Ok(entries) = fs::read_dir(&dir) {
182 for entry in entries.flatten() {
183 let path = entry.path();
184 if path.extension().map(|e| e == "jsonl").unwrap_or(false)
185 && fs::remove_file(&path).is_ok()
186 {
187 count += 1;
188 }
189 }
190 }
191 count
192}
193
194pub fn record_session_start(session_id: &str, agent: &str, model: Option<&str>) {
199 append_event(
200 session_id,
201 &SessionEvent::SessionStart {
202 ts: now_iso8601(),
203 session_id: session_id.to_string(),
204 agent: agent.to_string(),
205 model: model.map(|s| s.to_string()),
206 },
207 );
208}
209
210pub fn record_prompt(session_id: &str, text: Option<&str>) {
211 append_event(
212 session_id,
213 &SessionEvent::Prompt {
214 ts: now_iso8601(),
215 text: text.map(|s| s.to_string()),
216 },
217 );
218}
219
220pub fn record_tool_call(session_id: &str, tool: &str, input_summary: Option<&str>) {
221 append_event(
222 session_id,
223 &SessionEvent::ToolCall {
224 ts: now_iso8601(),
225 tool: tool.to_string(),
226 input_summary: input_summary.map(|s| s.to_string()),
227 duration_ms: None,
228 },
229 );
230}
231
232pub fn record_tool_result(session_id: &str, tool: &str, output_size: Option<u64>) {
233 append_event(
234 session_id,
235 &SessionEvent::ToolResult {
236 ts: now_iso8601(),
237 tool: tool.to_string(),
238 output_size,
239 },
240 );
241}
242
243pub fn record_response(session_id: &str, tokens: Option<TokenUsage>) {
244 append_event(
245 session_id,
246 &SessionEvent::Response {
247 ts: now_iso8601(),
248 tokens,
249 },
250 );
251}
252
253pub fn record_file_change(session_id: &str, path: &str, action: Option<&str>) {
254 append_event(
255 session_id,
256 &SessionEvent::FileChange {
257 ts: now_iso8601(),
258 path: path.to_string(),
259 action: action.map(|s| s.to_string()),
260 },
261 );
262}
263
264pub fn record_thinking(
265 session_id: &str,
266 content: Option<&str>,
267 content_size: Option<u64>,
268 reasoning_tokens: Option<u64>,
269) {
270 let truncated = content.map(|c| {
272 if c.len() <= 500 {
273 c.to_string()
274 } else {
275 format!("{}...", &c[..497])
276 }
277 });
278 append_event(
279 session_id,
280 &SessionEvent::Thinking {
281 ts: now_iso8601(),
282 content: truncated,
283 content_size,
284 reasoning_tokens,
285 },
286 );
287}
288
289pub fn record_session_end(session_id: &str, duration_s: Option<u64>, turns: u32) {
290 append_event(
291 session_id,
292 &SessionEvent::SessionEnd {
293 ts: now_iso8601(),
294 duration_s,
295 turns,
296 },
297 );
298}