notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }