1use crate::models::{
2 AgentInvocation, ContentBlock, FileOpType, FileOperation, Message, SessionEntry, ToolCategory,
3 ToolInvocation, extract_file_path, parse_timestamp,
4};
5use crate::patterns::PatternMatcher;
6use crate::tool_analyzer;
7use anyhow::{Context, Result};
8use rayon::prelude::*;
9use serde::Deserialize;
10use std::fs::File;
11use std::io::{BufRead, BufReader};
12use std::path::Path;
13use tracing::{debug, info, warn};
14
15const SKIP_ENTRY_TYPES: &[&str] = &[
19 "last-prompt",
20 "mode",
21 "permission-mode",
22 "ai-title",
23 "file-history-snapshot",
24 "queue-operation",
25 "agent-name",
26 "pr-link",
27 "tool_reference",
28 "text",
29 "attachment",
30 "system",
31];
32
33#[derive(Deserialize)]
35struct EntryTypePeek {
36 #[serde(rename = "type")]
37 entry_type: String,
38}
39
40pub struct SessionParser {
41 entries: Vec<SessionEntry>,
42 session_id: String,
43 project_path: String,
44}
45
46impl SessionParser {
47 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
54 let path = path.as_ref();
55 info!("Parsing session file: {}", path.display());
56
57 let file = File::open(path)
58 .with_context(|| format!("Failed to open session file: {}", path.display()))?;
59 let reader = BufReader::new(file);
60
61 let mut entries = Vec::new();
62 let mut session_id = String::new();
63 let mut project_path = String::new();
64
65 for (line_num, line) in reader.lines().enumerate() {
66 match line {
67 Ok(line) if !line.trim().is_empty() => {
68 if let Ok(peek) = serde_json::from_str::<EntryTypePeek>(&line)
69 && SKIP_ENTRY_TYPES.contains(&peek.entry_type.as_str())
70 {
71 debug!("Skipping metadata entry of type: {}", peek.entry_type);
72 continue;
73 }
74 match serde_json::from_str::<SessionEntry>(&line) {
75 Ok(entry) => {
76 if session_id.is_empty() {
78 session_id.clone_from(&entry.session_id);
79 }
80 if project_path.is_empty() {
81 if let Some(cwd) = &entry.cwd {
82 project_path.clone_from(cwd);
83 }
84 }
85 entries.push(entry);
86 }
87 Err(e) => {
88 warn!(
89 "Failed to parse line {}: {} - Error: {}",
90 line_num + 1,
91 line,
92 e
93 );
94 }
95 }
96 }
97 Ok(_) => {
98 }
100 Err(e) => {
101 warn!("Failed to read line {}: {}", line_num + 1, e);
102 }
103 }
104 }
105
106 info!(
107 "Parsed {} entries from session {}",
108 entries.len(),
109 session_id
110 );
111
112 Ok(Self {
113 entries,
114 session_id,
115 project_path,
116 })
117 }
118
119 pub fn from_default_location() -> Result<Vec<Self>> {
125 let home = home::home_dir().context("Could not find home directory")?;
126 let claude_dir = home.join(".claude").join("projects");
127
128 if !claude_dir.exists() {
129 return Err(anyhow::anyhow!(
130 "Claude projects directory not found at: {}",
131 claude_dir.display()
132 ));
133 }
134
135 Self::from_directory(claude_dir)
136 }
137
138 pub fn from_directory<P: AsRef<Path>>(dir: P) -> Result<Vec<Self>> {
144 let dir = dir.as_ref();
145 info!("Scanning for session files in: {}", dir.display());
146
147 let mut parsers = Vec::new();
148
149 for entry in walkdir::WalkDir::new(dir)
151 .max_depth(2)
152 .into_iter()
153 .filter_map(std::result::Result::ok)
154 {
155 let path = entry.path();
156 if path.extension() == Some("jsonl".as_ref()) {
157 match Self::from_file(path) {
158 Ok(parser) => {
159 debug!("Successfully parsed session: {}", parser.session_id);
160 parsers.push(parser);
161 }
162 Err(e) => {
163 warn!("Failed to parse session file {}: {}", path.display(), e);
164 }
165 }
166 }
167 }
168
169 info!("Found {} valid session files", parsers.len());
170 Ok(parsers)
171 }
172
173 #[must_use]
175 pub fn extract_agent_invocations(&self) -> Vec<AgentInvocation> {
176 self.entries
177 .par_iter()
178 .filter_map(|entry| {
179 if let Message::Assistant { content, .. } = &entry.message {
180 for block in content {
181 if let ContentBlock::ToolUse { name, input, id } = block {
182 if name == "Task" {
183 return self.parse_task_invocation(entry, input, id);
184 }
185 }
186 }
187 }
188 None
189 })
190 .collect()
191 }
192
193 fn parse_task_invocation(
195 &self,
196 entry: &SessionEntry,
197 input: &serde_json::Value,
198 _tool_id: &str,
199 ) -> Option<AgentInvocation> {
200 let agent_type = input
201 .get("subagent_type")
202 .and_then(|v| v.as_str())?
203 .to_string();
204
205 let task_description = input
206 .get("description")
207 .and_then(|v| v.as_str())
208 .unwrap_or("")
209 .to_string();
210
211 let prompt = input
212 .get("prompt")
213 .and_then(|v| v.as_str())
214 .unwrap_or("")
215 .to_string();
216
217 let timestamp = match parse_timestamp(&entry.timestamp) {
218 Ok(ts) => ts,
219 Err(e) => {
220 warn!("Failed to parse timestamp '{}': {}", entry.timestamp, e);
221 return None;
222 }
223 };
224
225 Some(AgentInvocation {
226 timestamp,
227 agent_type,
228 task_description,
229 prompt,
230 files_modified: Vec::new(), tools_used: Vec::new(), duration_ms: None, parent_message_id: entry.uuid.clone(),
234 session_id: self.session_id.clone(),
235 })
236 }
237
238 #[must_use]
240 pub fn extract_file_operations(&self) -> Vec<FileOperation> {
241 self.entries
242 .par_iter()
243 .filter_map(|entry| {
244 if let Message::Assistant { content, .. } = &entry.message {
245 for block in content {
246 if let ContentBlock::ToolUse { name, input, .. } = block {
247 if let Ok(op_type) = name.parse::<FileOpType>() {
248 if let Some(file_path) = extract_file_path(input) {
249 let timestamp = match parse_timestamp(&entry.timestamp) {
250 Ok(ts) => ts,
251 Err(e) => {
252 warn!(
253 "Failed to parse timestamp '{}': {}",
254 entry.timestamp, e
255 );
256 continue;
257 }
258 };
259
260 return Some(FileOperation {
261 timestamp,
262 operation: op_type,
263 file_path,
264 agent_context: None, session_id: self.session_id.clone(),
266 message_id: entry.uuid.clone(),
267 });
268 }
269 }
270 }
271 }
272 }
273 None
274 })
275 .collect()
276 }
277
278 #[must_use]
286 #[allow(dead_code)] pub fn extract_tool_invocations(&self, matcher: &dyn PatternMatcher) -> Vec<ToolInvocation> {
288 self.entries
289 .par_iter()
290 .filter_map(|entry| {
291 if let Message::Assistant { content, .. } = &entry.message {
292 extract_from_bash_command(entry, content, matcher, &self.session_id)
293 } else {
294 None
295 }
296 })
297 .collect()
298 }
299
300 #[must_use]
302 pub fn find_active_agent(&self, message_id: &str) -> Option<String> {
303 let mut found_message = false;
305
306 for entry in self.entries.iter().rev() {
307 if entry.uuid == message_id {
308 found_message = true;
309 continue;
310 }
311
312 if !found_message {
313 continue;
314 }
315
316 if let Message::Assistant { content, .. } = &entry.message {
318 for block in content {
319 if let ContentBlock::ToolUse { name, input, .. } = block {
320 if name == "Task" {
321 if let Some(agent_type) =
322 input.get("subagent_type").and_then(|v| v.as_str())
323 {
324 return Some(agent_type.to_string());
325 }
326 }
327 }
328 }
329 }
330 }
331
332 None
333 }
334
335 #[must_use]
337 pub fn get_session_info(
338 &self,
339 ) -> (
340 String,
341 String,
342 Option<jiff::Timestamp>,
343 Option<jiff::Timestamp>,
344 ) {
345 let start_time = self.entries.first().and_then(|e| {
346 parse_timestamp(&e.timestamp)
347 .map_err(|err| {
348 debug!("Could not parse start timestamp '{}': {}", e.timestamp, err);
349 err
350 })
351 .ok()
352 });
353 let end_time = self.entries.last().and_then(|e| {
354 parse_timestamp(&e.timestamp)
355 .map_err(|err| {
356 debug!("Could not parse end timestamp '{}': {}", e.timestamp, err);
357 err
358 })
359 .ok()
360 });
361
362 (
363 self.session_id.clone(),
364 self.project_path.clone(),
365 start_time,
366 end_time,
367 )
368 }
369
370 #[allow(dead_code)]
373 #[must_use]
374 pub fn entry_count(&self) -> usize {
375 self.entries.len()
376 }
377
378 #[must_use]
380 pub fn entries(&self) -> &[SessionEntry] {
381 &self.entries
382 }
383
384 #[allow(dead_code)]
387 #[must_use]
388 pub fn entries_in_window(
389 &self,
390 start: jiff::Timestamp,
391 end: jiff::Timestamp,
392 ) -> Vec<&SessionEntry> {
393 self.entries
394 .iter()
395 .filter(|entry| match parse_timestamp(&entry.timestamp) {
396 Ok(timestamp) => timestamp >= start && timestamp <= end,
397 Err(e) => {
398 debug!(
399 "Skipping entry with invalid timestamp '{}': {}",
400 entry.timestamp, e
401 );
402 false
403 }
404 })
405 .collect()
406 }
407
408 #[allow(dead_code)]
411 #[must_use]
412 pub fn get_agent_types(&self) -> Vec<String> {
413 let agents = self.extract_agent_invocations();
414 let mut agent_types: Vec<String> = agents
415 .into_iter()
416 .map(|a| a.agent_type)
417 .collect::<std::collections::HashSet<_>>()
418 .into_iter()
419 .collect();
420 agent_types.sort();
421 agent_types
422 }
423
424 #[allow(dead_code)]
427 #[must_use]
428 pub fn build_timeline(&self) -> Vec<TimelineEvent> {
429 let mut events = Vec::new();
430
431 for agent in self.extract_agent_invocations() {
433 events.push(TimelineEvent {
434 timestamp: agent.timestamp,
435 event_type: TimelineEventType::AgentInvocation,
436 description: format!("{}: {}", agent.agent_type, agent.task_description),
437 agent: Some(agent.agent_type),
438 file: None,
439 });
440 }
441
442 for file_op in self.extract_file_operations() {
444 events.push(TimelineEvent {
445 timestamp: file_op.timestamp,
446 event_type: TimelineEventType::FileOperation,
447 description: format!("{:?}: {}", file_op.operation, file_op.file_path),
448 agent: file_op.agent_context,
449 file: Some(file_op.file_path),
450 });
451 }
452
453 events.sort_by_key(|e| e.timestamp);
455 events
456 }
457}
458
459#[allow(dead_code)] fn extract_from_bash_command(
462 entry: &SessionEntry,
463 content: &[ContentBlock],
464 matcher: &dyn PatternMatcher,
465 session_id: &str,
466) -> Option<ToolInvocation> {
467 for block in content {
468 if let ContentBlock::ToolUse { name, input, .. } = block {
469 if name == "Bash" {
470 let command = input.get("command").and_then(|v| v.as_str())?;
472
473 let matches = matcher.find_matches(command);
475
476 if let Some(tool_match) = matches.first() {
477 if let Some((full_cmd, arguments, flags)) =
479 tool_analyzer::parse_command_context(command, tool_match.start)
480 {
481 if !tool_analyzer::is_actual_tool(&tool_match.tool_name) {
483 continue;
484 }
485
486 let timestamp = match parse_timestamp(&entry.timestamp) {
487 Ok(ts) => ts,
488 Err(e) => {
489 warn!("Failed to parse timestamp '{}': {}", entry.timestamp, e);
490 continue;
491 }
492 };
493
494 return Some(ToolInvocation {
495 timestamp,
496 tool_name: tool_match.tool_name.clone(),
497 tool_category: ToolCategory::from_string(&tool_match.category),
498 command_line: full_cmd,
499 arguments,
500 flags,
501 exit_code: None, agent_context: None, session_id: session_id.to_string(),
504 message_id: entry.uuid.clone(),
505 });
506 }
507 }
508 }
509 }
510 }
511
512 None
513}
514
515#[allow(dead_code)]
517#[derive(Debug, Clone)]
518pub struct TimelineEvent {
519 pub timestamp: jiff::Timestamp,
520 pub event_type: TimelineEventType,
521 pub description: String,
522 pub agent: Option<String>,
523 pub file: Option<String>,
524}
525
526#[allow(dead_code)]
528#[derive(Debug, Clone)]
529pub enum TimelineEventType {
530 AgentInvocation,
531 FileOperation,
532 UserMessage,
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn test_parse_session_entry() {
541 let json_line = r#"{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/home/alex/projects/zestic-at/charm","sessionId":"b325985c-5c1c-48f1-97e2-e3185bb55886","version":"1.0.111","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"ab88a3b0-544a-411a-a8a4-92b142e21472","timestamp":"2025-10-01T09:05:21.902Z"}"#;
542
543 let entry: SessionEntry = serde_json::from_str(json_line).unwrap();
544 assert_eq!(entry.session_id, "b325985c-5c1c-48f1-97e2-e3185bb55886");
545 assert_eq!(entry.uuid, "ab88a3b0-544a-411a-a8a4-92b142e21472");
546 }
547
548 #[test]
549 fn test_parse_task_invocation() {
550 let json_line = r#"{"parentUuid":"parent-uuid","isSidechain":false,"userType":"external","cwd":"/home/alex/projects","sessionId":"test-session","version":"1.0.111","gitBranch":"","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-id","name":"Task","input":{"subagent_type":"architect","description":"Design system architecture","prompt":"Please design the architecture"}}]},"requestId":"req-123","type":"assistant","uuid":"msg-uuid","timestamp":"2025-10-01T09:05:21.902Z"}"#;
551
552 let entry: SessionEntry = serde_json::from_str(json_line).unwrap();
553
554 let parser = SessionParser {
555 entries: vec![entry.clone()],
556 session_id: "test-session".to_string(),
557 project_path: "/home/alex/projects".to_string(),
558 };
559
560 let agents = parser.extract_agent_invocations();
561 assert_eq!(agents.len(), 1);
562 assert_eq!(agents[0].agent_type, "architect");
563 assert_eq!(agents[0].task_description, "Design system architecture");
564 }
565
566 #[test]
567 fn test_extract_file_operations() {
568 let json_line = r#"{"parentUuid":"parent-uuid","isSidechain":false,"userType":"external","cwd":"/home/alex/projects","sessionId":"test-session","version":"1.0.111","gitBranch":"","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-id","name":"Write","input":{"file_path":"/path/to/file.rs","content":"test content"}}]},"type":"assistant","uuid":"msg-uuid","timestamp":"2025-10-01T09:05:21.902Z"}"#;
569
570 let entry: SessionEntry = serde_json::from_str(json_line).unwrap();
571
572 let parser = SessionParser {
573 entries: vec![entry],
574 session_id: "test-session".to_string(),
575 project_path: "/home/alex/projects".to_string(),
576 };
577
578 let file_ops = parser.extract_file_operations();
579 assert_eq!(file_ops.len(), 1);
580 assert_eq!(file_ops[0].file_path, "/path/to/file.rs");
581 assert!(matches!(file_ops[0].operation, FileOpType::Write));
582 }
583
584 #[test]
585 fn test_assistant_with_thinking_block_parses() {
586 let json_line = r#"{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/tmp","sessionId":"s1","version":"1.0","gitBranch":"","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me analyze this..."},{"type":"text","text":"Here is my answer"}]},"type":"assistant","uuid":"u1","timestamp":"2025-01-01T09:00:00.000Z"}"#;
587 let entry: SessionEntry = serde_json::from_str(json_line).unwrap();
588 assert_eq!(entry.entry_type, "assistant");
589 if let crate::models::Message::Assistant { content, .. } = &entry.message {
590 assert_eq!(content.len(), 2);
591 } else {
592 panic!("Expected Assistant message");
593 }
594 }
595
596 #[test]
597 fn test_assistant_with_unknown_content_block_parses() {
598 let json_line = r#"{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/tmp","sessionId":"s1","version":"1.0","gitBranch":"","message":{"role":"assistant","content":[{"type":"some_future_block_type","data":"whatever"}]},"type":"assistant","uuid":"u1","timestamp":"2025-01-01T09:00:00.000Z"}"#;
599 let entry: SessionEntry = serde_json::from_str(json_line).unwrap();
600 assert_eq!(entry.entry_type, "assistant");
601 }
602
603 #[test]
604 fn test_entry_type_peek_extracts_type() {
605 let json = r#"{"type":"last-prompt","lastPrompt":"echo hello","sessionId":"abc"}"#;
606 let peek: EntryTypePeek = serde_json::from_str(json).unwrap();
607 assert_eq!(peek.entry_type, "last-prompt");
608 }
609
610 #[test]
611 fn test_skip_set_contains_metadata_types() {
612 assert!(SKIP_ENTRY_TYPES.contains(&"last-prompt"));
613 assert!(SKIP_ENTRY_TYPES.contains(&"mode"));
614 assert!(SKIP_ENTRY_TYPES.contains(&"permission-mode"));
615 assert!(SKIP_ENTRY_TYPES.contains(&"ai-title"));
616 assert!(SKIP_ENTRY_TYPES.contains(&"file-history-snapshot"));
617 assert!(SKIP_ENTRY_TYPES.contains(&"queue-operation"));
618 assert!(SKIP_ENTRY_TYPES.contains(&"agent-name"));
619 assert!(SKIP_ENTRY_TYPES.contains(&"pr-link"));
620 assert!(SKIP_ENTRY_TYPES.contains(&"tool_reference"));
621 assert!(SKIP_ENTRY_TYPES.contains(&"text"));
622 assert!(SKIP_ENTRY_TYPES.contains(&"attachment"));
623 assert!(SKIP_ENTRY_TYPES.contains(&"system"));
624 }
625
626 #[test]
627 fn test_skip_set_excludes_message_types() {
628 assert!(!SKIP_ENTRY_TYPES.contains(&"user"));
629 assert!(!SKIP_ENTRY_TYPES.contains(&"assistant"));
630 }
631
632 #[test]
633 fn test_metadata_entries_skipped_in_from_file() {
634 let dir = tempfile::TempDir::new().unwrap();
635 let path = dir.path().join("metadata-only.jsonl");
636 std::fs::write(
637 &path,
638 concat!(
639 r#"{"type":"last-prompt","lastPrompt":"hi","leafUuid":"a","sessionId":"s1"}"#,
640 "\n",
641 r#"{"type":"mode","mode":"normal","sessionId":"s1"}"#,
642 "\n",
643 r#"{"type":"permission-mode","permissionMode":"auto","sessionId":"s1"}"#,
644 "\n",
645 r#"{"type":"ai-title","title":"Test","sessionId":"s1"}"#,
646 "\n",
647 r#"{"type":"system","content":"system init","sessionId":"s1"}"#,
648 "\n",
649 r#"{"type":"attachment","attachment":{"type":"deferred_tools_delta"},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:00:00.000Z"}"#,
650 "\n",
651 ),
652 )
653 .unwrap();
654
655 let parser = SessionParser::from_file(&path).unwrap();
656 assert_eq!(
657 parser.entries.len(),
658 0,
659 "All metadata entries should be skipped"
660 );
661 }
662
663 #[test]
664 fn test_user_and_assistant_entries_still_parsed() {
665 let dir = tempfile::TempDir::new().unwrap();
666 let path = dir.path().join("messages.jsonl");
667 std::fs::write(
668 &path,
669 concat!(
670 r#"{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/tmp","sessionId":"s1","version":"1.0","gitBranch":"","type":"user","message":{"role":"user","content":"hello"},"uuid":"u1","timestamp":"2025-01-01T09:00:00.000Z"}"#,
671 "\n",
672 r#"{"parentUuid":"u1","isSidechain":false,"userType":"external","cwd":"/tmp","sessionId":"s1","version":"1.0","gitBranch":"","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"type":"assistant","uuid":"u2","timestamp":"2025-01-01T09:00:01.000Z"}"#,
673 "\n",
674 r#"{"type":"mode","mode":"normal","sessionId":"s1"}"#,
675 "\n",
676 ),
677 )
678 .unwrap();
679
680 let parser = SessionParser::from_file(&path).unwrap();
681 assert_eq!(
682 parser.entries.len(),
683 2,
684 "user + assistant entries should parse, mode should be skipped"
685 );
686 assert_eq!(parser.entries[0].entry_type, "user");
687 assert_eq!(parser.entries[1].entry_type, "assistant");
688 }
689}