session_jsonl.rs (17277B)
1 //! Parse claude-code session JSONL lines with lossless round-trip support. 2 //! 3 //! Follows the `ProfileState` pattern from `enostr::profile` — wraps a 4 //! `serde_json::Value` with typed accessors so we can read the fields we 5 //! need for nostr event construction while preserving the raw JSON for 6 //! the `source-data` tag. 7 8 use serde_json::Value; 9 10 /// A single line from a claude-code session JSONL file. 11 /// 12 /// Wraps the raw JSON value to preserve all fields for lossless round-trip 13 /// via the `source-data` nostr tag. 14 #[derive(Debug, Clone)] 15 pub struct JsonlLine(Value); 16 17 impl JsonlLine { 18 /// Parse a JSONL line from a string. 19 pub fn parse(line: &str) -> Result<Self, serde_json::Error> { 20 let value: Value = serde_json::from_str(line)?; 21 Ok(Self(value)) 22 } 23 24 /// The raw JSON value. 25 pub fn value(&self) -> &Value { 26 &self.0 27 } 28 29 /// Serialize back to a JSON string (lossless). 30 pub fn to_json(&self) -> String { 31 // serde_json::to_string on a Value is infallible 32 serde_json::to_string(&self.0).unwrap() 33 } 34 35 // -- Top-level field accessors -- 36 37 fn get_str(&self, key: &str) -> Option<&str> { 38 self.0.get(key).and_then(|v| v.as_str()) 39 } 40 41 /// The JSONL line type: "user", "assistant", "progress", "queue-operation", 42 /// "file-history-snapshot" 43 pub fn line_type(&self) -> Option<&str> { 44 self.get_str("type") 45 } 46 47 pub fn uuid(&self) -> Option<&str> { 48 self.get_str("uuid") 49 } 50 51 pub fn parent_uuid(&self) -> Option<&str> { 52 self.get_str("parentUuid") 53 } 54 55 pub fn session_id(&self) -> Option<&str> { 56 self.get_str("sessionId") 57 } 58 59 pub fn timestamp(&self) -> Option<&str> { 60 // Top-level timestamp, falling back to snapshot.timestamp 61 // (file-history-snapshot lines nest it there) 62 self.get_str("timestamp").or_else(|| { 63 self.0 64 .get("snapshot") 65 .and_then(|s| s.get("timestamp")) 66 .and_then(|v| v.as_str()) 67 }) 68 } 69 70 /// Parse the timestamp as a unix timestamp (seconds). 71 pub fn timestamp_secs(&self) -> Option<u64> { 72 let ts_str = self.timestamp()?; 73 let dt = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?; 74 Some(dt.timestamp() as u64) 75 } 76 77 pub fn cwd(&self) -> Option<&str> { 78 self.get_str("cwd") 79 } 80 81 pub fn git_branch(&self) -> Option<&str> { 82 self.get_str("gitBranch") 83 } 84 85 pub fn version(&self) -> Option<&str> { 86 self.get_str("version") 87 } 88 89 pub fn slug(&self) -> Option<&str> { 90 self.get_str("slug") 91 } 92 93 /// The `message` object, if present. 94 pub fn message(&self) -> Option<JsonlMessage<'_>> { 95 self.0.get("message").map(JsonlMessage) 96 } 97 98 /// For queue-operation lines: the operation type ("dequeue", etc.) 99 pub fn operation(&self) -> Option<&str> { 100 self.get_str("operation") 101 } 102 103 /// Determine the role string for nostr event tagging. 104 /// 105 /// This maps the JSONL structure to the design spec's role values: 106 /// user (text) → "user", user (tool_result) → "tool_result", 107 /// assistant (text) → "assistant", assistant (tool_use) → "tool_call", 108 /// progress → "progress", etc. 109 pub fn role(&self) -> Option<&str> { 110 match self.line_type()? { 111 "user" => { 112 // Check if the content is a tool_result array 113 if let Some(msg) = self.message() { 114 if msg.has_tool_result_content() { 115 return Some("tool_result"); 116 } 117 } 118 Some("user") 119 } 120 "assistant" => Some("assistant"), 121 "progress" => Some("progress"), 122 "queue-operation" => Some("queue-operation"), 123 "file-history-snapshot" => Some("file-history-snapshot"), 124 _ => None, 125 } 126 } 127 } 128 129 /// A borrowed view into the `message` object of a JSONL line. 130 #[derive(Debug, Clone, Copy)] 131 pub struct JsonlMessage<'a>(&'a Value); 132 133 impl<'a> JsonlMessage<'a> { 134 fn get_str(&self, key: &str) -> Option<&'a str> { 135 self.0.get(key).and_then(|v| v.as_str()) 136 } 137 138 pub fn role(&self) -> Option<&'a str> { 139 self.get_str("role") 140 } 141 142 pub fn model(&self) -> Option<&'a str> { 143 self.get_str("model") 144 } 145 146 /// The raw content value — can be a string or an array of content blocks. 147 pub fn content(&self) -> Option<&'a Value> { 148 self.0.get("content") 149 } 150 151 /// Check if content contains tool_result blocks (user messages with tool results). 152 pub fn has_tool_result_content(&self) -> bool { 153 match self.content() { 154 Some(Value::Array(arr)) => arr 155 .iter() 156 .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")), 157 _ => false, 158 } 159 } 160 161 /// Extract the content blocks as an iterator. 162 pub fn content_blocks(&self) -> Vec<ContentBlock<'a>> { 163 match self.content() { 164 Some(Value::String(s)) => vec![ContentBlock::Text(s.as_str())], 165 Some(Value::Array(arr)) => arr.iter().filter_map(ContentBlock::from_value).collect(), 166 _ => vec![], 167 } 168 } 169 170 /// Extract just the text from text blocks, concatenated. 171 pub fn text_content(&self) -> Option<String> { 172 let blocks = self.content_blocks(); 173 let texts: Vec<&str> = blocks 174 .iter() 175 .filter_map(|b| match b { 176 ContentBlock::Text(t) => Some(*t), 177 _ => None, 178 }) 179 .collect(); 180 181 if texts.is_empty() { 182 None 183 } else { 184 Some(texts.join("")) 185 } 186 } 187 } 188 189 /// A content block from an assistant or user message. 190 #[derive(Debug, Clone)] 191 pub enum ContentBlock<'a> { 192 /// Plain text content. 193 Text(&'a str), 194 /// A tool use request (assistant → tool). 195 ToolUse { 196 id: &'a str, 197 name: &'a str, 198 input: &'a Value, 199 }, 200 /// A tool result (tool → user message). 201 ToolResult { 202 tool_use_id: &'a str, 203 content: &'a Value, 204 }, 205 } 206 207 impl<'a> ContentBlock<'a> { 208 fn from_value(value: &'a Value) -> Option<Self> { 209 let block_type = value.get("type")?.as_str()?; 210 match block_type { 211 "text" => { 212 let text = value.get("text")?.as_str()?; 213 Some(ContentBlock::Text(text)) 214 } 215 "tool_use" => { 216 let id = value.get("id")?.as_str()?; 217 let name = value.get("name")?.as_str()?; 218 let input = value.get("input")?; 219 Some(ContentBlock::ToolUse { id, name, input }) 220 } 221 "tool_result" => { 222 let tool_use_id = value.get("tool_use_id")?.as_str()?; 223 let content = value.get("content")?; 224 Some(ContentBlock::ToolResult { 225 tool_use_id, 226 content, 227 }) 228 } 229 _ => None, 230 } 231 } 232 } 233 234 /// Human-readable content extraction for the nostr event `content` field. 235 /// 236 /// This produces the text that goes into the nostr event content, 237 /// suitable for rendering in any nostr client. 238 pub fn extract_display_content(line: &JsonlLine) -> String { 239 match line.line_type() { 240 Some("user") => { 241 if let Some(msg) = line.message() { 242 if msg.has_tool_result_content() { 243 // Tool result content — summarize 244 let blocks = msg.content_blocks(); 245 let summaries: Vec<String> = blocks 246 .iter() 247 .filter_map(|b| match b { 248 ContentBlock::ToolResult { content, .. } => match content { 249 Value::String(s) => Some(truncate_str(s, 500)), 250 _ => Some("[tool result]".to_string()), 251 }, 252 _ => None, 253 }) 254 .collect(); 255 summaries.join("\n") 256 } else if let Some(text) = msg.text_content() { 257 // Strip "Human: " prefix if present (claude-code adds it) 258 text.strip_prefix("Human: ").unwrap_or(&text).to_string() 259 } else { 260 String::new() 261 } 262 } else { 263 String::new() 264 } 265 } 266 Some("assistant") => { 267 if let Some(msg) = line.message() { 268 // For assistant messages, we'll produce content for each block. 269 // The caller handles splitting into multiple events for mixed content. 270 msg.text_content().unwrap_or_default() 271 } else { 272 String::new() 273 } 274 } 275 Some("progress") => line 276 .message() 277 .and_then(|m| m.text_content()) 278 .unwrap_or_else(|| "[progress]".to_string()), 279 Some("queue-operation") => { 280 let op = line.operation().unwrap_or("unknown"); 281 format!("[queue: {}]", op) 282 } 283 Some("file-history-snapshot") => "[file history snapshot]".to_string(), 284 _ => String::new(), 285 } 286 } 287 288 /// Extract display content for a single content block (for assistant messages 289 /// that need to be split into multiple events). 290 pub fn display_content_for_block(block: &ContentBlock<'_>) -> String { 291 match block { 292 ContentBlock::Text(text) => text.to_string(), 293 ContentBlock::ToolUse { name, input, .. } => { 294 // Compact summary: tool name + truncated input 295 let input_str = serde_json::to_string(input).unwrap_or_default(); 296 let input_preview = truncate_str(&input_str, 200); 297 format!("Tool: {} {}", name, input_preview) 298 } 299 ContentBlock::ToolResult { content, .. } => match content { 300 Value::String(s) => truncate_str(s, 500), 301 _ => "[tool result]".to_string(), 302 }, 303 } 304 } 305 306 use crate::session_loader::truncate as truncate_str; 307 308 #[cfg(test)] 309 mod tests { 310 use super::*; 311 312 #[test] 313 fn test_parse_user_text_message() { 314 let line = r#"{"parentUuid":null,"cwd":"/Users/jb55/dev/notedeck","sessionId":"abc-123","version":"2.0.64","gitBranch":"main","type":"user","message":{"role":"user","content":"Human: Hello world\n\n"},"uuid":"uuid-1","timestamp":"2026-02-09T20:43:35.675Z"}"#; 315 316 let parsed = JsonlLine::parse(line).unwrap(); 317 assert_eq!(parsed.line_type(), Some("user")); 318 assert_eq!(parsed.uuid(), Some("uuid-1")); 319 assert_eq!(parsed.parent_uuid(), None); 320 assert_eq!(parsed.session_id(), Some("abc-123")); 321 assert_eq!(parsed.cwd(), Some("/Users/jb55/dev/notedeck")); 322 assert_eq!(parsed.version(), Some("2.0.64")); 323 assert_eq!(parsed.role(), Some("user")); 324 325 let msg = parsed.message().unwrap(); 326 assert_eq!(msg.role(), Some("user")); 327 assert_eq!( 328 msg.text_content(), 329 Some("Human: Hello world\n\n".to_string()) 330 ); 331 332 let content = extract_display_content(&parsed); 333 assert_eq!(content, "Hello world\n\n"); 334 } 335 336 #[test] 337 fn test_parse_assistant_text_message() { 338 let line = r#"{"parentUuid":"uuid-1","cwd":"/Users/jb55/dev/notedeck","sessionId":"abc-123","version":"2.0.64","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"I can help with that."}]},"type":"assistant","uuid":"uuid-2","timestamp":"2026-02-09T20:43:38.421Z"}"#; 339 340 let parsed = JsonlLine::parse(line).unwrap(); 341 assert_eq!(parsed.line_type(), Some("assistant")); 342 assert_eq!(parsed.role(), Some("assistant")); 343 344 let msg = parsed.message().unwrap(); 345 assert_eq!(msg.model(), Some("claude-opus-4-5-20251101")); 346 assert_eq!( 347 msg.text_content(), 348 Some("I can help with that.".to_string()) 349 ); 350 } 351 352 #[test] 353 fn test_parse_assistant_tool_use() { 354 let line = r#"{"parentUuid":"uuid-1","cwd":"/tmp","sessionId":"abc","version":"2.0.64","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"tool_use","id":"toolu_123","name":"Read","input":{"file_path":"/tmp/test.rs"}}]},"type":"assistant","uuid":"uuid-3","timestamp":"2026-02-09T20:43:38.421Z"}"#; 355 356 let parsed = JsonlLine::parse(line).unwrap(); 357 let msg = parsed.message().unwrap(); 358 let blocks = msg.content_blocks(); 359 assert_eq!(blocks.len(), 1); 360 361 match &blocks[0] { 362 ContentBlock::ToolUse { id, name, input } => { 363 assert_eq!(*id, "toolu_123"); 364 assert_eq!(*name, "Read"); 365 assert_eq!( 366 input.get("file_path").unwrap().as_str(), 367 Some("/tmp/test.rs") 368 ); 369 } 370 _ => panic!("expected ToolUse block"), 371 } 372 } 373 374 #[test] 375 fn test_parse_user_tool_result() { 376 let line = r#"{"parentUuid":"uuid-3","cwd":"/tmp","sessionId":"abc","version":"2.0.64","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_123","type":"tool_result","content":"file contents here"}]},"uuid":"uuid-4","timestamp":"2026-02-09T20:43:38.476Z"}"#; 377 378 let parsed = JsonlLine::parse(line).unwrap(); 379 assert_eq!(parsed.role(), Some("tool_result")); 380 381 let msg = parsed.message().unwrap(); 382 assert!(msg.has_tool_result_content()); 383 384 let blocks = msg.content_blocks(); 385 assert_eq!(blocks.len(), 1); 386 match &blocks[0] { 387 ContentBlock::ToolResult { 388 tool_use_id, 389 content, 390 } => { 391 assert_eq!(*tool_use_id, "toolu_123"); 392 assert_eq!(content.as_str(), Some("file contents here")); 393 } 394 _ => panic!("expected ToolResult block"), 395 } 396 } 397 398 #[test] 399 fn test_parse_queue_operation() { 400 let line = r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:43:35.669Z","sessionId":"abc-123"}"#; 401 402 let parsed = JsonlLine::parse(line).unwrap(); 403 assert_eq!(parsed.line_type(), Some("queue-operation")); 404 assert_eq!(parsed.operation(), Some("dequeue")); 405 assert_eq!(parsed.role(), Some("queue-operation")); 406 407 let content = extract_display_content(&parsed); 408 assert_eq!(content, "[queue: dequeue]"); 409 } 410 411 #[test] 412 fn test_lossless_roundtrip() { 413 // The key property: parse → to_json should preserve all fields 414 let original = r#"{"type":"user","uuid":"abc","parentUuid":null,"sessionId":"sess","timestamp":"2026-02-09T20:43:35.675Z","cwd":"/tmp","gitBranch":"main","version":"2.0.64","isSidechain":false,"userType":"external","message":{"role":"user","content":"hello"},"unknownField":"preserved"}"#; 415 416 let parsed = JsonlLine::parse(original).unwrap(); 417 let roundtripped = parsed.to_json(); 418 419 // Parse both as Value to compare (field order may differ) 420 let orig_val: Value = serde_json::from_str(original).unwrap(); 421 let rt_val: Value = serde_json::from_str(&roundtripped).unwrap(); 422 assert_eq!(orig_val, rt_val); 423 } 424 425 #[test] 426 fn test_timestamp_secs() { 427 let line = r#"{"type":"user","timestamp":"2026-02-09T20:43:35.675Z","sessionId":"abc"}"#; 428 let parsed = JsonlLine::parse(line).unwrap(); 429 assert!(parsed.timestamp_secs().is_some()); 430 } 431 432 #[test] 433 fn test_timestamp_fallback_snapshot() { 434 // file-history-snapshot lines have timestamp nested in snapshot 435 let line = r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#; 436 let parsed = JsonlLine::parse(line).unwrap(); 437 assert_eq!(parsed.timestamp(), Some("2026-02-11T01:29:31.555Z")); 438 assert!(parsed.timestamp_secs().is_some()); 439 } 440 441 #[test] 442 fn test_mixed_assistant_content() { 443 let line = r#"{"type":"assistant","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Here is what I found:"},{"type":"tool_use","id":"t1","name":"Glob","input":{"pattern":"**/*.rs"}}]}}"#; 444 445 let parsed = JsonlLine::parse(line).unwrap(); 446 let msg = parsed.message().unwrap(); 447 let blocks = msg.content_blocks(); 448 assert_eq!(blocks.len(), 2); 449 450 // First block is text 451 assert!(matches!( 452 blocks[0], 453 ContentBlock::Text("Here is what I found:") 454 )); 455 456 // Second block is tool use 457 match &blocks[1] { 458 ContentBlock::ToolUse { name, .. } => assert_eq!(*name, "Glob"), 459 _ => panic!("expected ToolUse"), 460 } 461 462 // display_content_for_block should work on each 463 assert_eq!( 464 display_content_for_block(&blocks[0]), 465 "Here is what I found:" 466 ); 467 assert!(display_content_for_block(&blocks[1]).starts_with("Tool: Glob")); 468 } 469 }