//! Deterministic, pure extraction of a size-capped digest from a Claude Code //! session transcript (JSONL), with no LLM call involved. /// Build a deterministic digest of a Claude Code session transcript. /// /// Reads NDJSON `jsonl` (one Claude Code transcript event per line) and keeps /// only: /// - `user` entries whose `message.content` is a plain string (real user /// text), included verbatim. /// - `assistant` entries' final text content, included verbatim, plus each /// `tool_use` block reduced to `name(one-line json args)`. /// /// Excluded: `tool_result` content (found in `user` entries whose content is /// an array), `thinking` blocks, and any event whose top-level `type` is not /// `user` or `assistant` (e.g. `queue-operation`, `attachment`, `summary`, /// `system`, `stream_event`). Sidechain entries (subagent turns) are also /// excluded so the digest reflects only the main conversation thread. /// /// Lines that fail to parse as JSON are skipped rather than treated as fatal. /// /// When the joined digest exceeds `max_bytes`, entries are dropped from the /// oldest end first so the most recent content survives the cap. pub fn build_digest(jsonl: &str, max_bytes: usize) -> String { let entries = extract_entries(jsonl); cap_to_bytes(&entries, max_bytes) } /// Parse `jsonl` into an ordered list of digest lines, applying the /// extraction/exclusion rules. Pure — no truncation is applied here. fn extract_entries(jsonl: &str) -> Vec { let mut entries = Vec::new(); for line in jsonl.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } let Ok(value) = serde_json::from_str::(trimmed) else { continue; }; if value.get("isSidechain").and_then(|v| v.as_bool()) == Some(true) { continue; } match value.get("type").and_then(|t| t.as_str()) { Some("user") => { if let Some(text) = extract_user_text(&value) { entries.push(format!("User: {text}")); } } Some("assistant") => { entries.extend(extract_assistant_lines(&value)); } _ => {} } } entries } /// Extract verbatim user text from a `user`-typed transcript entry. /// /// Returns `None` when the message content is an array (tool results) rather /// than a plain string — tool results are excluded from the digest. fn extract_user_text(value: &serde_json::Value) -> Option { let content = value.get("message")?.get("content")?; content.as_str().map(str::to_string) } /// Extract digest lines from an `assistant`-typed transcript entry: the final /// text reply verbatim, followed by one line per `tool_use` block. `thinking` /// blocks are skipped. fn extract_assistant_lines(value: &serde_json::Value) -> Vec { let mut lines = Vec::new(); let Some(content) = value .get("message") .and_then(|m| m.get("content")) .and_then(|c| c.as_array()) else { return lines; }; let mut text_parts = Vec::new(); for block in content { match block.get("type").and_then(|t| t.as_str()) { Some("text") => { if let Some(text) = block.get("text").and_then(|t| t.as_str()) { text_parts.push(text); } } Some("tool_use") => { let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool"); let args = block .get("input") .map(serde_json::Value::to_string) .unwrap_or_default(); lines.push(format!("Tool: {name}({args})")); } // "thinking" and any other block types are excluded. _ => {} } } if !text_parts.is_empty() { lines.insert(0, format!("Assistant: {}", text_parts.join("\n"))); } lines } /// Join `entries` with newlines, dropping the oldest entries first so the /// digest stays within `max_bytes`. Always keeps at least the single newest /// entry, even if it alone exceeds the cap. fn cap_to_bytes(entries: &[String], max_bytes: usize) -> String { let joined = entries.join("\n"); if joined.len() <= max_bytes { return joined; } let mut kept: Vec<&str> = Vec::new(); let mut total = 0usize; for entry in entries.iter().rev() { let addition = entry.len() + if kept.is_empty() { 0 } else { 1 }; if total + addition > max_bytes && !kept.is_empty() { break; } total += addition; kept.push(entry); } kept.reverse(); kept.join("\n") } #[cfg(test)] mod tests { use super::*; // -- extraction / exclusion rules --------------------------------------- #[test] fn extracts_plain_user_text_verbatim() { let jsonl = r#"{"type":"user","message":{"role":"user","content":"hello there"}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "User: hello there"); } #[test] fn extracts_assistant_final_text_verbatim() { let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"here is my answer"}]}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "Assistant: here is my answer"); } #[test] fn extracts_tool_use_as_name_and_one_line_args() { let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}]}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, r#"Tool: Bash({"command":"ls -la"})"#); assert!(!digest.contains('\n'), "tool args must be one line"); } #[test] fn excludes_tool_result_user_entries() { // A user entry carrying a tool_result array (not plain string content) // must be excluded entirely from the digest. let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"abc","content":"file contents..."}]}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "", "tool_result entries must be excluded"); } #[test] fn excludes_thinking_blocks() { let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"let me consider..."},{"type":"text","text":"final answer"}]}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "Assistant: final answer"); assert!(!digest.contains("let me consider")); } #[test] fn excludes_non_user_assistant_event_types() { let jsonl = "\ {\"type\":\"queue-operation\",\"operation\":\"enqueue\",\"content\":\"noise\"} {\"type\":\"attachment\",\"attachment\":{\"type\":\"skill_listing\"}} {\"type\":\"summary\",\"summary\":\"irrelevant\"} {\"type\":\"system\",\"content\":\"system noise\"} {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"real message\"}}"; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "User: real message"); } #[test] fn excludes_sidechain_entries() { let jsonl = r#"{"type":"user","isSidechain":true,"message":{"role":"user","content":"subagent chatter"}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "", "sidechain (subagent) entries must be excluded"); } #[test] fn deterministic_across_repeated_calls() { let jsonl = "\ {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"a\"}} {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"b\"}]}}"; let first = build_digest(jsonl, 10_000); let second = build_digest(jsonl, 10_000); assert_eq!(first, second); } // -- cap-truncation keeping newest --------------------------------------- #[test] fn cap_truncation_keeps_newest_entries() { let jsonl = "\ {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"oldest message\"}} {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"middle reply\"}]}} {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"newest message\"}}"; // Cap small enough to only fit the last entry. let digest = build_digest(jsonl, 20); assert_eq!(digest, "User: newest message"); assert!(!digest.contains("oldest")); } #[test] fn cap_truncation_keeps_at_least_one_entry_even_if_oversized() { let jsonl = r#"{"type":"user","message":{"role":"user","content":"this single message is longer than the cap"}}"#; let digest = build_digest(jsonl, 5); assert!( digest.contains("this single message"), "must keep the single newest entry even if it exceeds max_bytes: {digest}" ); } #[test] fn no_truncation_when_under_cap() { let jsonl = r#"{"type":"user","message":{"role":"user","content":"short"}}"#; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "User: short"); } // -- malformed-line tolerance --------------------------------------------- #[test] fn tolerates_malformed_lines_between_valid_ones() { let jsonl = "\ {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"first\"}} not json at all {{{ {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}"; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "User: first\nAssistant: second"); } #[test] fn tolerates_empty_lines() { let jsonl = "\n{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n\n"; let digest = build_digest(jsonl, 10_000); assert_eq!(digest, "User: hi"); } #[test] fn empty_input_yields_empty_digest() { assert_eq!(build_digest("", 10_000), ""); } #[test] fn all_malformed_yields_empty_digest() { let jsonl = "garbage\nmore garbage\n{not valid"; assert_eq!(build_digest(jsonl, 10_000), ""); } }