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