264 lines
10 KiB
Rust
264 lines
10 KiB
Rust
//! 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<String> {
|
||
|
|
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::<serde_json::Value>(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<String> {
|
||
|
|
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<String> {
|
||
|
|
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), "");
|
||
|
|
}
|
||
|
|
}
|