Skip to main content

recall_echo/
jsonl.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! JSONL transcript parsing for Claude Code sessions.
6//!
7//! Parses Claude Code's `.jsonl` transcript files into the universal
8//! `Conversation` format. This is the input adapter for standalone
9//! (non-pulse-null) usage — e.g., when recall-echo is used as a
10//! Claude Code hook.
11
12use serde::Deserialize;
13use std::fs::File;
14use std::io::{BufRead, BufReader, Read};
15
16use crate::conversation::{Conversation, ConversationEntry};
17
18// ---------------------------------------------------------------------------
19// Hook input (stdin from Claude Code)
20// ---------------------------------------------------------------------------
21
22/// What a SessionEnd hook tells us about the session that just ended.
23///
24/// Claude Code's payload is the reference shape. The camelCase aliases and the
25/// defaults are for the other harnesses that can end up invoking this command —
26/// Gemini's `hooks migrate --from-claude` copies our hook straight into its own
27/// settings, and a payload that spells a field differently, or omits it, must
28/// produce a clear message rather than a deserialization error on every single
29/// session. What is missing is reported by
30/// [`crate::archive::run_with_hook_input`], which can say what to do about it.
31#[derive(Deserialize, Debug, Default)]
32pub struct HookInput {
33    #[serde(default, alias = "sessionId")]
34    pub session_id: String,
35    #[serde(
36        default,
37        alias = "transcriptPath",
38        alias = "transcript",
39        alias = "transcript_file"
40    )]
41    pub transcript_path: String,
42    #[serde(rename = "cwd")]
43    pub _cwd: Option<String>,
44    #[serde(rename = "hook_event_name")]
45    pub _hook_event_name: Option<String>,
46}
47
48pub fn read_hook_input() -> Result<HookInput, crate::error::RecallError> {
49    let mut buf = String::new();
50    std::io::stdin().read_to_string(&mut buf)?;
51
52    if buf.trim().is_empty() {
53        return Err(crate::error::RecallError::Other(
54            "No input on stdin. This command is called by the Claude Code SessionEnd hook."
55                .to_string(),
56        ));
57    }
58
59    Ok(serde_json::from_str(&buf)?)
60}
61
62// ---------------------------------------------------------------------------
63// JSONL entry types (deserialization)
64// ---------------------------------------------------------------------------
65
66#[derive(Deserialize)]
67struct JsonlEntry {
68    #[serde(rename = "type")]
69    entry_type: String,
70    timestamp: Option<String>,
71    message: Option<RawMessage>,
72}
73
74#[derive(Deserialize)]
75struct RawMessage {
76    role: Option<String>,
77    content: Option<ContentValue>,
78}
79
80#[derive(Deserialize)]
81#[serde(untagged)]
82enum ContentValue {
83    Text(String),
84    Blocks(Vec<serde_json::Value>),
85}
86
87// ---------------------------------------------------------------------------
88// JSONL parsing
89// ---------------------------------------------------------------------------
90
91/// Whether a file is a JSON *Lines* transcript, as Claude Code writes them.
92///
93/// A JSON document — Gemini's chat sessions, say — is one object, so its first
94/// line either fails to parse (pretty-printed) or parses into something a
95/// transcript entry never is. Only the first non-empty line is read: sniffing
96/// must not cost a pass over a multi-megabyte transcript.
97#[must_use]
98pub fn is_jsonl_transcript(path: &str) -> bool {
99    let Ok(file) = File::open(path) else {
100        return false;
101    };
102    BufReader::new(file)
103        .lines()
104        .map_while(Result::ok)
105        .find(|line| !line.trim().is_empty())
106        .and_then(|line| serde_json::from_str::<serde_json::Value>(line.trim()).ok())
107        .is_some_and(|value| {
108            // A whole session document on one line is not a transcript entry,
109            // however well-formed it is.
110            value.is_object() && !crate::transcript::gemini::is_session_document(&value)
111        })
112}
113
114/// Parse a Claude Code JSONL transcript into a Conversation.
115pub fn parse_transcript(
116    path: &str,
117    session_id: &str,
118) -> Result<Conversation, crate::error::RecallError> {
119    let file = File::open(path)?;
120    let reader = BufReader::new(file);
121
122    let mut conv = Conversation::new(session_id);
123
124    for line in reader.lines() {
125        let line = match line {
126            Ok(l) => l,
127            Err(_) => continue,
128        };
129        if line.trim().is_empty() {
130            continue;
131        }
132
133        let entry: JsonlEntry = match serde_json::from_str(&line) {
134            Ok(e) => e,
135            Err(e) => {
136                eprintln!("recall-echo: skipping malformed JSONL line: {e}");
137                continue;
138            }
139        };
140
141        // Skip system entries
142        if entry.entry_type == "queue-operation" || entry.entry_type == "summary" {
143            continue;
144        }
145
146        // Track timestamps
147        if let Some(ref ts) = entry.timestamp {
148            if conv.first_timestamp.is_none() {
149                conv.first_timestamp = Some(ts.clone());
150            }
151            conv.last_timestamp = Some(ts.clone());
152        }
153
154        // Only process entries with messages
155        let msg = match entry.message {
156            Some(m) => m,
157            None => continue,
158        };
159
160        let role = msg.role.as_deref().unwrap_or("");
161        let content = match msg.content {
162            Some(c) => c,
163            None => continue,
164        };
165
166        match role {
167            "user" => parse_user_content(&mut conv, content),
168            "assistant" => parse_assistant_content(&mut conv, content),
169            _ => {}
170        }
171    }
172
173    Ok(conv)
174}
175
176fn parse_user_content(conv: &mut Conversation, content: ContentValue) {
177    match content {
178        ContentValue::Text(text) => {
179            conv.user_message_count += 1;
180            conv.entries.push(ConversationEntry::UserMessage(text));
181        }
182        ContentValue::Blocks(blocks) => {
183            for block in blocks {
184                let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
185                if block_type == "tool_result" {
186                    let raw_content = block.get("content");
187                    let text = match raw_content {
188                        Some(serde_json::Value::String(s)) => s.clone(),
189                        Some(v) => serde_json::to_string_pretty(v).unwrap_or_default(),
190                        None => String::new(),
191                    };
192                    let is_error = block
193                        .get("is_error")
194                        .and_then(|v| v.as_bool())
195                        .unwrap_or(false);
196                    conv.entries.push(ConversationEntry::ToolResult {
197                        content: crate::conversation::truncate(&text, 2000),
198                        is_error,
199                    });
200                }
201            }
202        }
203    }
204}
205
206fn parse_assistant_content(conv: &mut Conversation, content: ContentValue) {
207    match content {
208        ContentValue::Text(text) => {
209            conv.assistant_message_count += 1;
210            conv.entries.push(ConversationEntry::AssistantText(text));
211        }
212        ContentValue::Blocks(blocks) => {
213            for block in blocks {
214                let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
215                match block_type {
216                    "text" => {
217                        if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
218                            if !text.is_empty() {
219                                conv.assistant_message_count += 1;
220                                conv.entries
221                                    .push(ConversationEntry::AssistantText(text.to_string()));
222                            }
223                        }
224                    }
225                    "tool_use" => {
226                        let name = block
227                            .get("name")
228                            .and_then(|n| n.as_str())
229                            .unwrap_or("unknown")
230                            .to_string();
231                        let input = block.get("input");
232                        let summary = format_tool_input(&name, input);
233                        conv.entries.push(ConversationEntry::ToolUse {
234                            name,
235                            input_summary: summary,
236                        });
237                    }
238                    // Skip thinking blocks entirely (private reasoning + signatures)
239                    "thinking" => {}
240                    _ => {}
241                }
242            }
243        }
244    }
245}
246
247fn format_tool_input(name: &str, input: Option<&serde_json::Value>) -> String {
248    let input = match input {
249        Some(v) => v,
250        None => return String::new(),
251    };
252
253    match name {
254        "Read" => input
255            .get("file_path")
256            .and_then(|v| v.as_str())
257            .map(|p| format!("`{p}`"))
258            .unwrap_or_default(),
259        "Bash" => input
260            .get("command")
261            .and_then(|v| v.as_str())
262            .map(|c| format!("`{}`", crate::conversation::truncate(c, 200)))
263            .unwrap_or_default(),
264        "Edit" | "Write" => input
265            .get("file_path")
266            .and_then(|v| v.as_str())
267            .map(|p| format!("`{p}`"))
268            .unwrap_or_default(),
269        "Grep" => {
270            let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
271            let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
272            format!("`{pattern}` in `{path}`")
273        }
274        "Glob" => input
275            .get("pattern")
276            .and_then(|v| v.as_str())
277            .map(|p| format!("`{p}`"))
278            .unwrap_or_default(),
279        _ => {
280            let s = serde_json::to_string(input).unwrap_or_default();
281            crate::conversation::truncate(&s, 200)
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::io::Write;
290
291    fn write_test_jsonl(dir: &std::path::Path) -> String {
292        let path = dir.join("test-session.jsonl");
293        let mut f = File::create(&path).unwrap();
294        let lines = [
295            r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-03-05T14:30:00.000Z","sessionId":"test-sess-1"}"#,
296            r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-03-05T14:30:00.001Z","sessionId":"test-sess-1"}"#,
297            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?"}}"#,
298            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"}]}}"#,
299            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."}]}}"#,
300            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"}}]}}"#,
301            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}"}]}}"#,
302            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."}]}}"#,
303        ];
304        for line in &lines {
305            writeln!(f, "{}", line).unwrap();
306        }
307        path.to_string_lossy().to_string()
308    }
309
310    #[test]
311    fn parse_transcript_basic() {
312        let dir = tempfile::tempdir().unwrap();
313        let path = write_test_jsonl(dir.path());
314        let conv = parse_transcript(&path, "test-sess-1").unwrap();
315
316        assert_eq!(conv.session_id, "test-sess-1");
317        assert_eq!(conv.user_message_count, 1);
318        assert_eq!(conv.assistant_message_count, 2);
319        assert!(conv.first_timestamp.is_some());
320        assert!(conv.last_timestamp.is_some());
321
322        // Should have: UserMessage, AssistantText, ToolUse, ToolResult, AssistantText
323        assert_eq!(conv.entries.len(), 5);
324    }
325
326    #[test]
327    fn thinking_blocks_omitted() {
328        let dir = tempfile::tempdir().unwrap();
329        let path = write_test_jsonl(dir.path());
330        let conv = parse_transcript(&path, "test-sess-1").unwrap();
331
332        for entry in &conv.entries {
333            if let ConversationEntry::AssistantText(text) = entry {
334                assert!(!text.contains("Let me check the auth module"));
335            }
336        }
337    }
338
339    #[test]
340    fn conversation_to_markdown_output() {
341        let dir = tempfile::tempdir().unwrap();
342        let path = write_test_jsonl(dir.path());
343        let conv = parse_transcript(&path, "test-sess-1").unwrap();
344        let md = crate::conversation::conversation_to_markdown(&conv, 1);
345
346        assert!(md.starts_with("# Conversation 001"));
347        assert!(md.contains("### User"));
348        assert!(md.contains("Can you read the auth module?"));
349        assert!(md.contains("### Assistant"));
350        assert!(md.contains("**Read**"));
351        assert!(md.contains("`/src/auth.rs`"));
352        assert!(md.contains("authenticate"));
353        // Thinking block should NOT appear
354        assert!(!md.contains("Let me check the auth module"));
355    }
356
357    #[test]
358    fn extract_summary_strips_channel_prefix() {
359        let conv = Conversation {
360            session_id: "test".to_string(),
361            first_timestamp: None,
362            last_timestamp: None,
363            user_message_count: 1,
364            assistant_message_count: 0,
365            entries: vec![ConversationEntry::UserMessage(
366                "[Channel: discord | Trust: VERIFIED]\n\nUser message: lets build something"
367                    .to_string(),
368            )],
369        };
370        let summary = crate::conversation::extract_summary(&conv);
371        assert_eq!(summary, "lets build something");
372    }
373
374    #[test]
375    fn extract_topics_basic() {
376        let conv = Conversation {
377            session_id: "test".to_string(),
378            first_timestamp: None,
379            last_timestamp: None,
380            user_message_count: 1,
381            assistant_message_count: 0,
382            entries: vec![ConversationEntry::UserMessage(
383                "Can you refactor the auth module to use JWT tokens instead of sessions?"
384                    .to_string(),
385            )],
386        };
387        let topics = crate::conversation::extract_topics(&conv, 5);
388        assert!(topics.contains(&"auth".to_string()));
389        assert!(topics.contains(&"jwt".to_string()));
390    }
391
392    #[test]
393    fn tool_result_truncation() {
394        let long_content = "x".repeat(3000);
395        let truncated = crate::conversation::truncate(&long_content, 2000);
396        assert!(truncated.len() < 3000);
397        assert!(truncated.contains("[truncated, 3000 chars total]"));
398    }
399
400    /// The sniff that keeps a migrated Gemini hook from feeding a JSON
401    /// document to a JSON Lines parser.
402    #[test]
403    fn only_json_lines_reads_as_a_transcript() {
404        let dir = tempfile::tempdir().unwrap();
405        let path = write_test_jsonl(dir.path());
406        assert!(is_jsonl_transcript(&path));
407
408        let session = serde_json::json!({
409            "sessionId": "s",
410            "messages": [{"type": "user", "content": "hi"}],
411        });
412        let write = |name: &str, body: &str| {
413            let path = dir.path().join(name);
414            std::fs::write(&path, body).unwrap();
415            path.to_string_lossy().to_string()
416        };
417
418        assert!(!is_jsonl_transcript(&write(
419            "one-line.json",
420            &serde_json::to_string(&session).unwrap()
421        )));
422        assert!(!is_jsonl_transcript(&write(
423            "pretty.json",
424            &serde_json::to_string_pretty(&session).unwrap()
425        )));
426        assert!(!is_jsonl_transcript(&write("empty.jsonl", "")));
427        assert!(!is_jsonl_transcript(&write("prose.txt", "hello\nthere")));
428        assert!(!is_jsonl_transcript("/nonexistent/transcript.jsonl"));
429    }
430
431    #[test]
432    fn a_hook_payload_survives_a_renamed_field() {
433        let claude = r#"{"session_id":"a","transcript_path":"/tmp/a.jsonl"}"#;
434        let parsed: HookInput = serde_json::from_str(claude).unwrap();
435        assert_eq!(parsed.session_id, "a");
436        assert_eq!(parsed.transcript_path, "/tmp/a.jsonl");
437
438        // camelCase, and a payload that carries neither: both must parse, so
439        // the command can explain itself instead of dying on every session.
440        let other = r#"{"sessionId":"b","transcriptPath":"/tmp/b.json"}"#;
441        let parsed: HookInput = serde_json::from_str(other).unwrap();
442        assert_eq!(parsed.session_id, "b");
443        assert_eq!(parsed.transcript_path, "/tmp/b.json");
444
445        let bare: HookInput = serde_json::from_str("{}").unwrap();
446        assert!(bare.transcript_path.is_empty());
447    }
448
449    #[test]
450    fn format_tool_input_read() {
451        let input: serde_json::Value = serde_json::json!({"file_path": "/src/main.rs"});
452        assert_eq!(format_tool_input("Read", Some(&input)), "`/src/main.rs`");
453    }
454
455    #[test]
456    fn format_tool_input_grep() {
457        let input: serde_json::Value = serde_json::json!({"pattern": "TODO", "path": "/src/"});
458        assert_eq!(format_tool_input("Grep", Some(&input)), "`TODO` in `/src/`");
459    }
460}