1use serde::Deserialize;
13use std::fs::File;
14use std::io::{BufRead, BufReader, Read};
15
16use crate::conversation::{Conversation, ConversationEntry};
17
18#[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#[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#[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 value.is_object() && !crate::transcript::gemini::is_session_document(&value)
111 })
112}
113
114pub 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 if entry.entry_type == "queue-operation" || entry.entry_type == "summary" {
143 continue;
144 }
145
146 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 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 "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 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 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 #[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 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}