Skip to main content

recall_echo/
jsonl.rs

1//! JSONL transcript parsing for Claude Code sessions.
2//!
3//! Parses Claude Code's `.jsonl` transcript files into the universal
4//! `Conversation` format. This is the input adapter for standalone
5//! (non-pulse-null) usage — e.g., when recall-echo is used as a
6//! Claude Code hook.
7
8use serde::Deserialize;
9use std::fs::File;
10use std::io::{BufRead, BufReader, Read};
11
12use crate::conversation::{Conversation, ConversationEntry};
13
14// ---------------------------------------------------------------------------
15// Hook input (stdin from Claude Code)
16// ---------------------------------------------------------------------------
17
18#[derive(Deserialize, Debug)]
19pub struct HookInput {
20    pub session_id: String,
21    pub transcript_path: String,
22    #[allow(dead_code)]
23    pub cwd: Option<String>,
24    #[allow(dead_code)]
25    pub hook_event_name: Option<String>,
26}
27
28pub fn read_hook_input() -> Result<HookInput, String> {
29    let mut buf = String::new();
30    std::io::stdin()
31        .read_to_string(&mut buf)
32        .map_err(|e| format!("Failed to read stdin: {e}"))?;
33
34    if buf.trim().is_empty() {
35        return Err(
36            "No input on stdin. This command is called by the Claude Code SessionEnd hook."
37                .to_string(),
38        );
39    }
40
41    serde_json::from_str(&buf).map_err(|e| format!("Invalid hook JSON on stdin: {e}"))
42}
43
44// ---------------------------------------------------------------------------
45// JSONL entry types (deserialization)
46// ---------------------------------------------------------------------------
47
48#[derive(Deserialize)]
49struct JsonlEntry {
50    #[serde(rename = "type")]
51    entry_type: String,
52    timestamp: Option<String>,
53    #[serde(rename = "sessionId")]
54    #[allow(dead_code)]
55    session_id: Option<String>,
56    message: Option<RawMessage>,
57}
58
59#[derive(Deserialize)]
60struct RawMessage {
61    role: Option<String>,
62    content: Option<ContentValue>,
63    #[allow(dead_code)]
64    model: Option<String>,
65}
66
67#[derive(Deserialize)]
68#[serde(untagged)]
69enum ContentValue {
70    Text(String),
71    Blocks(Vec<serde_json::Value>),
72}
73
74// ---------------------------------------------------------------------------
75// JSONL parsing
76// ---------------------------------------------------------------------------
77
78/// Parse a Claude Code JSONL transcript into a Conversation.
79pub fn parse_transcript(path: &str, session_id: &str) -> Result<Conversation, String> {
80    let file = File::open(path).map_err(|e| format!("Failed to open transcript {path}: {e}"))?;
81    let reader = BufReader::new(file);
82
83    let mut conv = Conversation::new(session_id);
84
85    for line in reader.lines() {
86        let line = match line {
87            Ok(l) => l,
88            Err(_) => continue,
89        };
90        if line.trim().is_empty() {
91            continue;
92        }
93
94        let entry: JsonlEntry = match serde_json::from_str(&line) {
95            Ok(e) => e,
96            Err(e) => {
97                eprintln!("recall-echo: skipping malformed JSONL line: {e}");
98                continue;
99            }
100        };
101
102        // Skip system entries
103        if entry.entry_type == "queue-operation" || entry.entry_type == "summary" {
104            continue;
105        }
106
107        // Track timestamps
108        if let Some(ref ts) = entry.timestamp {
109            if conv.first_timestamp.is_none() {
110                conv.first_timestamp = Some(ts.clone());
111            }
112            conv.last_timestamp = Some(ts.clone());
113        }
114
115        // Only process entries with messages
116        let msg = match entry.message {
117            Some(m) => m,
118            None => continue,
119        };
120
121        let role = msg.role.as_deref().unwrap_or("");
122        let content = match msg.content {
123            Some(c) => c,
124            None => continue,
125        };
126
127        match role {
128            "user" => parse_user_content(&mut conv, content),
129            "assistant" => parse_assistant_content(&mut conv, content),
130            _ => {}
131        }
132    }
133
134    Ok(conv)
135}
136
137fn parse_user_content(conv: &mut Conversation, content: ContentValue) {
138    match content {
139        ContentValue::Text(text) => {
140            conv.user_message_count += 1;
141            conv.entries.push(ConversationEntry::UserMessage(text));
142        }
143        ContentValue::Blocks(blocks) => {
144            for block in blocks {
145                let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
146                if block_type == "tool_result" {
147                    let raw_content = block.get("content");
148                    let text = match raw_content {
149                        Some(serde_json::Value::String(s)) => s.clone(),
150                        Some(v) => serde_json::to_string_pretty(v).unwrap_or_default(),
151                        None => String::new(),
152                    };
153                    let is_error = block
154                        .get("is_error")
155                        .and_then(|v| v.as_bool())
156                        .unwrap_or(false);
157                    conv.entries.push(ConversationEntry::ToolResult {
158                        content: crate::conversation::truncate(&text, 2000),
159                        is_error,
160                    });
161                }
162            }
163        }
164    }
165}
166
167fn parse_assistant_content(conv: &mut Conversation, content: ContentValue) {
168    match content {
169        ContentValue::Text(text) => {
170            conv.assistant_message_count += 1;
171            conv.entries.push(ConversationEntry::AssistantText(text));
172        }
173        ContentValue::Blocks(blocks) => {
174            for block in blocks {
175                let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
176                match block_type {
177                    "text" => {
178                        if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
179                            if !text.is_empty() {
180                                conv.assistant_message_count += 1;
181                                conv.entries
182                                    .push(ConversationEntry::AssistantText(text.to_string()));
183                            }
184                        }
185                    }
186                    "tool_use" => {
187                        let name = block
188                            .get("name")
189                            .and_then(|n| n.as_str())
190                            .unwrap_or("unknown")
191                            .to_string();
192                        let input = block.get("input");
193                        let summary = format_tool_input(&name, input);
194                        conv.entries.push(ConversationEntry::ToolUse {
195                            name,
196                            input_summary: summary,
197                        });
198                    }
199                    // Skip thinking blocks entirely (private reasoning + signatures)
200                    "thinking" => {}
201                    _ => {}
202                }
203            }
204        }
205    }
206}
207
208fn format_tool_input(name: &str, input: Option<&serde_json::Value>) -> String {
209    let input = match input {
210        Some(v) => v,
211        None => return String::new(),
212    };
213
214    match name {
215        "Read" => input
216            .get("file_path")
217            .and_then(|v| v.as_str())
218            .map(|p| format!("`{p}`"))
219            .unwrap_or_default(),
220        "Bash" => input
221            .get("command")
222            .and_then(|v| v.as_str())
223            .map(|c| format!("`{}`", crate::conversation::truncate(c, 200)))
224            .unwrap_or_default(),
225        "Edit" | "Write" => input
226            .get("file_path")
227            .and_then(|v| v.as_str())
228            .map(|p| format!("`{p}`"))
229            .unwrap_or_default(),
230        "Grep" => {
231            let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
232            let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
233            format!("`{pattern}` in `{path}`")
234        }
235        "Glob" => input
236            .get("pattern")
237            .and_then(|v| v.as_str())
238            .map(|p| format!("`{p}`"))
239            .unwrap_or_default(),
240        _ => {
241            let s = serde_json::to_string(input).unwrap_or_default();
242            crate::conversation::truncate(&s, 200)
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use std::io::Write;
251
252    fn write_test_jsonl(dir: &std::path::Path) -> String {
253        let path = dir.join("test-session.jsonl");
254        let mut f = File::create(&path).unwrap();
255        let lines = [
256            r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-03-05T14:30:00.000Z","sessionId":"test-sess-1"}"#,
257            r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-03-05T14:30:00.001Z","sessionId":"test-sess-1"}"#,
258            r#"{"parentUuid":null,"type":"user","sessionId":"test-sess-1","timestamp":"2026-03-05T14:30:00.100Z","message":{"role":"user","content":"Can you read the auth module?"}}"#,
259            r#"{"parentUuid":"aaa","type":"assistant","sessionId":"test-sess-1","timestamp":"2026-03-05T14:30:05.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me check the auth module.","signature":"sig123"}]}}"#,
260            r#"{"parentUuid":"bbb","type":"assistant","sessionId":"test-sess-1","timestamp":"2026-03-05T14:30:06.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Let me read the auth module."}]}}"#,
261            r#"{"parentUuid":"ccc","type":"assistant","sessionId":"test-sess-1","timestamp":"2026-03-05T14:30:07.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_abc","name":"Read","input":{"file_path":"/src/auth.rs"}}]}}"#,
262            r#"{"parentUuid":"ddd","type":"user","sessionId":"test-sess-1","timestamp":"2026-03-05T14:30:08.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc","content":"pub fn authenticate() {\n    // auth logic\n}"}]}}"#,
263            r#"{"parentUuid":"eee","type":"assistant","sessionId":"test-sess-1","timestamp":"2026-03-05T14:31:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"The auth module has a single authenticate function."}]}}"#,
264        ];
265        for line in &lines {
266            writeln!(f, "{}", line).unwrap();
267        }
268        path.to_string_lossy().to_string()
269    }
270
271    #[test]
272    fn parse_transcript_basic() {
273        let dir = tempfile::tempdir().unwrap();
274        let path = write_test_jsonl(dir.path());
275        let conv = parse_transcript(&path, "test-sess-1").unwrap();
276
277        assert_eq!(conv.session_id, "test-sess-1");
278        assert_eq!(conv.user_message_count, 1);
279        assert_eq!(conv.assistant_message_count, 2);
280        assert!(conv.first_timestamp.is_some());
281        assert!(conv.last_timestamp.is_some());
282
283        // Should have: UserMessage, AssistantText, ToolUse, ToolResult, AssistantText
284        assert_eq!(conv.entries.len(), 5);
285    }
286
287    #[test]
288    fn thinking_blocks_omitted() {
289        let dir = tempfile::tempdir().unwrap();
290        let path = write_test_jsonl(dir.path());
291        let conv = parse_transcript(&path, "test-sess-1").unwrap();
292
293        for entry in &conv.entries {
294            if let ConversationEntry::AssistantText(text) = entry {
295                assert!(!text.contains("Let me check the auth module"));
296            }
297        }
298    }
299
300    #[test]
301    fn conversation_to_markdown_output() {
302        let dir = tempfile::tempdir().unwrap();
303        let path = write_test_jsonl(dir.path());
304        let conv = parse_transcript(&path, "test-sess-1").unwrap();
305        let md = crate::conversation::conversation_to_markdown(&conv, 1);
306
307        assert!(md.starts_with("# Conversation 001"));
308        assert!(md.contains("### User"));
309        assert!(md.contains("Can you read the auth module?"));
310        assert!(md.contains("### Assistant"));
311        assert!(md.contains("**Read**"));
312        assert!(md.contains("`/src/auth.rs`"));
313        assert!(md.contains("authenticate"));
314        // Thinking block should NOT appear
315        assert!(!md.contains("Let me check the auth module"));
316    }
317
318    #[test]
319    fn extract_summary_strips_channel_prefix() {
320        let conv = Conversation {
321            session_id: "test".to_string(),
322            first_timestamp: None,
323            last_timestamp: None,
324            user_message_count: 1,
325            assistant_message_count: 0,
326            entries: vec![ConversationEntry::UserMessage(
327                "[Channel: discord | Trust: VERIFIED]\n\nUser message: lets build something"
328                    .to_string(),
329            )],
330        };
331        let summary = crate::conversation::extract_summary(&conv);
332        assert_eq!(summary, "lets build something");
333    }
334
335    #[test]
336    fn extract_topics_basic() {
337        let conv = Conversation {
338            session_id: "test".to_string(),
339            first_timestamp: None,
340            last_timestamp: None,
341            user_message_count: 1,
342            assistant_message_count: 0,
343            entries: vec![ConversationEntry::UserMessage(
344                "Can you refactor the auth module to use JWT tokens instead of sessions?"
345                    .to_string(),
346            )],
347        };
348        let topics = crate::conversation::extract_topics(&conv, 5);
349        assert!(topics.contains(&"auth".to_string()));
350        assert!(topics.contains(&"jwt".to_string()));
351    }
352
353    #[test]
354    fn tool_result_truncation() {
355        let long_content = "x".repeat(3000);
356        let truncated = crate::conversation::truncate(&long_content, 2000);
357        assert!(truncated.len() < 3000);
358        assert!(truncated.contains("[truncated, 3000 chars total]"));
359    }
360
361    #[test]
362    fn format_tool_input_read() {
363        let input: serde_json::Value = serde_json::json!({"file_path": "/src/main.rs"});
364        assert_eq!(format_tool_input("Read", Some(&input)), "`/src/main.rs`");
365    }
366
367    #[test]
368    fn format_tool_input_grep() {
369        let input: serde_json::Value = serde_json::json!({"pattern": "TODO", "path": "/src/"});
370        assert_eq!(format_tool_input("Grep", Some(&input)), "`TODO` in `/src/`");
371    }
372}