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 #[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#[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
74pub 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 if entry.entry_type == "queue-operation" || entry.entry_type == "summary" {
104 continue;
105 }
106
107 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 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 "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 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 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}