Files
huskies/server/src/chat/compact/digest.rs
T

264 lines
10 KiB
Rust
Raw Normal View History

//! 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), "");
}
}