Skip to main content

terraphim_session_analyzer/
parser.rs

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
15/// Claude Code JSONL entry types that carry metadata, not conversational messages.
16/// These are skipped during parsing to avoid false deserialization failures and
17/// reduce log noise (~2003 of ~2430 WARN lines eliminated).
18const 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/// Lightweight struct for peeking at the entry type before full deserialization.
34#[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    /// Parse a single JSONL session file
48    /// Parse a single JSONL session file
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the file cannot be read or contains malformed JSON
53    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                            // Extract session metadata from first entry
77                            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                    // Skip empty lines
99                }
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    /// Find all session files in the default Claude directory
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the Claude directory doesn't exist or cannot be read
124    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    /// Parse all session files in a directory
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the directory cannot be read or contains invalid session files
143    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        // Walk through all project directories
150        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    /// Extract agent invocations from Task tool uses
174    #[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    /// Parse a Task tool invocation into an `AgentInvocation`
194    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(), // Will be populated later
231            tools_used: Vec::new(),     // Will be populated later
232            duration_ms: None,          // Will be calculated later
233            parent_message_id: entry.uuid.clone(),
234            session_id: self.session_id.clone(),
235        })
236    }
237
238    /// Extract file operations from tool uses
239    #[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, // Will be set during analysis
265                                        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    /// Extract tool invocations from Bash commands
279    ///
280    /// # Arguments
281    /// * `matcher` - Pattern matcher for identifying tools in commands
282    ///
283    /// # Returns
284    /// A vector of `ToolInvocation` instances found in Bash tool uses
285    #[must_use]
286    #[allow(dead_code)] // Will be used in Phase 2
287    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    /// Find the active agent context for a given message
301    #[must_use]
302    pub fn find_active_agent(&self, message_id: &str) -> Option<String> {
303        // Look backwards from the given message to find the most recent Task invocation
304        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            // Look for Task tool invocations
317            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    /// Get session metadata
336    #[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    /// Get entry count for statistics
371    /// Used in integration tests
372    #[allow(dead_code)]
373    #[must_use]
374    pub fn entry_count(&self) -> usize {
375        self.entries.len()
376    }
377
378    /// Get all entries
379    #[must_use]
380    pub fn entries(&self) -> &[SessionEntry] {
381        &self.entries
382    }
383
384    /// Find entries within a time window
385    /// Used in integration tests
386    #[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    /// Find all unique agent types used in this session
409    /// Used in integration tests
410    #[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    /// Build a timeline of events for visualization
425    /// Used in integration tests
426    #[allow(dead_code)]
427    #[must_use]
428    pub fn build_timeline(&self) -> Vec<TimelineEvent> {
429        let mut events = Vec::new();
430
431        // Add agent invocations
432        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        // Add file operations
443        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        // Sort by timestamp
454        events.sort_by_key(|e| e.timestamp);
455        events
456    }
457}
458
459/// Helper function to extract tool invocations from Bash command content
460#[allow(dead_code)] // Will be used in Phase 2
461fn 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                // Extract the command from the input
471                let command = input.get("command").and_then(|v| v.as_str())?;
472
473                // Find tool matches using the pattern matcher
474                let matches = matcher.find_matches(command);
475
476                if let Some(tool_match) = matches.first() {
477                    // Parse command context to extract arguments and flags
478                    if let Some((full_cmd, arguments, flags)) =
479                        tool_analyzer::parse_command_context(command, tool_match.start)
480                    {
481                        // Filter out shell built-ins
482                        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,     // Exit code not available from logs
502                            agent_context: None, // Will be populated later
503                            session_id: session_id.to_string(),
504                            message_id: entry.uuid.clone(),
505                        });
506                    }
507                }
508            }
509        }
510    }
511
512    None
513}
514
515/// Used in integration tests and public API
516#[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/// Used in integration tests and public API
527#[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}