1use serde::Deserialize;
9use std::fs::File;
10use std::io::{BufRead, BufReader, Read};
11
12use crate::conversation::{Conversation, ConversationEntry};
13
14#[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#[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
67pub 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 if entry.entry_type == "queue-operation" || entry.entry_type == "summary" {
100 continue;
101 }
102
103 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 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 "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 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 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}