yoke/src/session_trim.rs

621 lines
24 KiB
Rust
Raw Normal View History

2026-05-19 08:58:57 +00:00
//! Session-JSONL trimming for Claude Code prompt-cache reuse across rounds.
//!
//! Yoke uses `claude --resume <sid>` to carry a worker's conversation across
//! iterations so Anthropic's prefix cache stays warm. Naive resume grows the
//! session monotonically — bash outputs, thinking blocks, intermediate Reads
//! that are no longer relevant — all of it stays in the prefix and is paid
//! for at cache-read rates every round.
//!
//! This module trims the session file between rounds. The agent declares a
//! `KEEP: <path> <path> ...` line in `.loop/notes.md` listing the file Reads
//! whose results should stay in conversation history. Everything else is
//! dropped, the parent-uuid chain is re-linked across the gaps, and the file
//! is atomically rewritten in place.
//!
//! Safety: if the trim's own validation fails (broken parent chain, orphaned
//! tool_use without tool_result, parse error), the original session is kept
//! untouched and we log a warning. `YOKE_DISABLE_SESSION_TRIM=1` skips the
//! whole pass.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::Value;
#[derive(Debug, Default)]
pub struct TrimStats {
pub records_total: usize,
pub records_kept: usize,
pub records_dropped: usize,
pub keep_paths: Vec<String>,
pub skipped: bool,
}
/// Parse a `KEEP:` line from notes.md (or any text). Returns absolute paths
/// resolved against `cwd`. Multiple `KEEP:` lines are unioned. Missing or
/// `*` token is treated as "keep nothing" — the caller decides what that
/// means, but this fn just returns the explicit paths.
pub fn parse_keep_list(notes_text: &str, cwd: &Path) -> HashSet<PathBuf> {
let mut out = HashSet::new();
for line in notes_text.lines() {
let trimmed = line.trim_start();
let rest = match trimmed.strip_prefix("KEEP:") {
Some(r) => r,
None => continue,
};
for tok in rest.split_whitespace() {
if tok == "*" {
continue;
}
let p = PathBuf::from(tok);
let abs = if p.is_absolute() { p } else { cwd.join(p) };
// Best-effort canonicalize so symlinks / .. don't cause mismatches.
let final_path = fs::canonicalize(&abs).unwrap_or(abs);
out.insert(final_path);
}
}
out
}
/// Locate Claude Code's session file on disk.
/// Format: `<home>/.claude/projects/<cwd-with-/-replaced-by->/<sid>.jsonl`.
pub fn session_file_path(home: &Path, cwd: &Path, session_id: &str) -> PathBuf {
let cwd_str = cwd.to_string_lossy();
// Claude Code's slug: replace `/` with `-`. A leading slash becomes a
// leading dash. `.` characters in path components are preserved.
let slug = cwd_str.replace('/', "-");
home.join(".claude")
.join("projects")
.join(slug)
.join(format!("{}.jsonl", session_id))
}
/// Trim a session file in place. Returns stats. If the trim aborts safely
/// (escape hatch / no kept content / validation failure), the original file
/// is untouched.
pub fn trim_session(session_path: &Path, keep_paths: &HashSet<PathBuf>) -> Result<TrimStats, String> {
let mut stats = TrimStats {
keep_paths: keep_paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect(),
..TrimStats::default()
};
if std::env::var_os("YOKE_DISABLE_SESSION_TRIM").is_some() {
stats.skipped = true;
return Ok(stats);
}
if !session_path.exists() {
return Err(format!("session file not found: {}", session_path.display()));
}
let content = fs::read_to_string(session_path)
.map_err(|e| format!("read {}: {}", session_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
stats.records_total = raw_lines.len();
let mut records: Vec<Value> = Vec::with_capacity(raw_lines.len());
for (i, line) in raw_lines.iter().enumerate() {
let v: Value = serde_json::from_str(line)
.map_err(|e| format!("parse line {}: {}", i + 1, e))?;
records.push(v);
}
let decisions = classify(&records, keep_paths);
let trimmed = relink_and_emit(&records, &raw_lines, &decisions)?;
stats.records_kept = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
stats.records_dropped = stats.records_total.saturating_sub(stats.records_kept);
validate(&trimmed)?;
// Atomic write: tmp → rename. Keep one .bak for recovery / debugging.
let bak_path = session_path.with_extension("jsonl.bak");
let _ = fs::copy(session_path, &bak_path);
let tmp_path = session_path.with_extension("jsonl.tmp");
{
let mut f = fs::File::create(&tmp_path)
.map_err(|e| format!("create tmp: {}", e))?;
f.write_all(trimmed.as_bytes())
.map_err(|e| format!("write tmp: {}", e))?;
f.sync_all().ok();
}
fs::rename(&tmp_path, session_path)
.map_err(|e| format!("rename: {}", e))?;
Ok(stats)
}
/// Per-record decision: keep as-is, drop entirely, or keep with a rewritten
/// parentUuid.
#[derive(Debug, Clone)]
enum Decision {
Keep,
Drop,
}
fn classify(records: &[Value], keep_paths: &HashSet<PathBuf>) -> Vec<Decision> {
// Two-pass: first identify which tool_use ids we keep, then decide each record.
let mut kept_tool_use_ids: HashSet<String> = HashSet::new();
for r in records {
if !is_assistant(r) {
continue;
}
let Some(block) = first_content_block(r) else { continue };
if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
continue;
}
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name != "Read" {
continue;
}
let path_str = block
.get("input")
.and_then(|v| v.get("file_path"))
.and_then(|v| v.as_str());
let Some(path_str) = path_str else { continue };
let p = PathBuf::from(path_str);
let canonical = fs::canonicalize(&p).unwrap_or(p);
if keep_paths.contains(&canonical)
&& let Some(id) = block.get("id").and_then(|v| v.as_str())
{
kept_tool_use_ids.insert(id.to_string());
}
}
records
.iter()
.map(|r| decide(r, &kept_tool_use_ids))
.collect()
}
fn decide(record: &Value, kept_tool_use_ids: &HashSet<String>) -> Decision {
// Metadata records (no top-level uuid OR parentUuid is absent and type is bookkeeping):
// always keep.
let typ = record.get("type").and_then(|v| v.as_str()).unwrap_or("");
match typ {
// Non-conversation bookkeeping — keep unchanged.
"permission-mode"
| "file-history-snapshot"
| "queue-operation"
| "ai-title"
| "last-prompt"
| "attachment" => return Decision::Keep,
_ => {}
}
// user / assistant: examine content.
let Some(msg) = record.get("message") else {
return Decision::Keep;
};
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
// Initial user prompt: content is a plain string, not an array. Always keep.
if role == "user" {
match msg.get("content") {
Some(Value::String(_)) => return Decision::Keep,
Some(Value::Array(arr)) => {
// tool_result wrapper. Keep only if its tool_use_id was kept.
if arr.is_empty() {
return Decision::Keep;
}
let block = &arr[0];
let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
if btyp == "tool_result" {
let id = block.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
if kept_tool_use_ids.contains(id) {
return Decision::Keep;
} else {
return Decision::Drop;
}
}
// Other user content (unusual): keep defensively.
return Decision::Keep;
}
_ => return Decision::Keep,
}
}
if role == "assistant" {
let Some(block) = first_content_block(record) else {
return Decision::Drop;
};
let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
match btyp {
"thinking" => Decision::Drop,
"text" => Decision::Drop,
"tool_use" => {
let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
if kept_tool_use_ids.contains(id) {
Decision::Keep
} else {
Decision::Drop
}
}
_ => Decision::Drop,
}
} else {
// Unknown role — keep, don't make things worse.
Decision::Keep
}
}
fn is_assistant(record: &Value) -> bool {
record.get("type").and_then(|v| v.as_str()) == Some("assistant")
}
fn first_content_block(record: &Value) -> Option<&Value> {
record
.get("message")?
.get("content")?
.as_array()?
.first()
}
/// Build the trimmed JSONL output. For surviving records whose parentUuid
/// points to a dropped record, walks up the parent chain to find the nearest
/// surviving ancestor and rewrites the field.
fn relink_and_emit(records: &[Value], raw: &[&str], decisions: &[Decision]) -> Result<String, String> {
// uuid → parentUuid index for ALL records that have a uuid. Used to walk
// up the chain when re-linking.
let mut parent_of: HashMap<String, Option<String>> = HashMap::new();
let mut kept_uuids: HashSet<String> = HashSet::new();
for (i, r) in records.iter().enumerate() {
let Some(uuid) = r.get("uuid").and_then(|v| v.as_str()) else { continue };
let parent = r
.get("parentUuid")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
parent_of.insert(uuid.to_string(), parent);
if matches!(decisions[i], Decision::Keep) {
kept_uuids.insert(uuid.to_string());
}
}
// For each kept uuid, compute the rewritten parentUuid (nearest kept
// ancestor or null).
let mut rewritten_parent: HashMap<String, Option<String>> = HashMap::new();
for uuid in &kept_uuids {
let mut cur = parent_of.get(uuid).cloned().flatten();
while let Some(p) = cur {
if kept_uuids.contains(&p) {
rewritten_parent.insert(uuid.clone(), Some(p));
break;
}
cur = parent_of.get(&p).cloned().flatten();
}
if !rewritten_parent.contains_key(uuid) {
rewritten_parent.insert(uuid.clone(), None);
}
}
let mut out = String::with_capacity(raw.iter().map(|l| l.len() + 1).sum());
for (i, r) in records.iter().enumerate() {
if matches!(decisions[i], Decision::Drop) {
continue;
}
let uuid_opt = r.get("uuid").and_then(|v| v.as_str()).map(|s| s.to_string());
// If this record has a uuid AND its rewritten parent differs from
// the on-disk parent, re-serialize. Otherwise emit raw.
let needs_rewrite = match &uuid_opt {
Some(uuid) => {
let orig = parent_of.get(uuid).cloned().flatten();
let new = rewritten_parent.get(uuid).cloned().flatten();
orig != new
}
None => false,
};
if needs_rewrite {
let mut v = r.clone();
let uuid = uuid_opt.unwrap();
let new_parent = rewritten_parent.get(&uuid).cloned().flatten();
if let Some(obj) = v.as_object_mut() {
match new_parent {
Some(p) => {
obj.insert("parentUuid".to_string(), Value::String(p));
}
None => {
obj.insert("parentUuid".to_string(), Value::Null);
}
}
}
let s = serde_json::to_string(&v)
.map_err(|e| format!("serialize: {}", e))?;
out.push_str(&s);
out.push('\n');
} else {
out.push_str(raw[i]);
out.push('\n');
}
}
Ok(out)
}
/// Validate that the trimmed JSONL is internally consistent:
/// every tool_use has a matching tool_result downstream.
fn validate(trimmed: &str) -> Result<(), String> {
let mut tool_use_ids: HashSet<String> = HashSet::new();
let mut tool_result_ids: HashSet<String> = HashSet::new();
for (i, line) in trimmed.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let v: Value = serde_json::from_str(line)
.map_err(|e| format!("validate parse line {}: {}", i + 1, e))?;
let Some(arr) = v.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) else {
continue;
};
for block in arr {
match block.get("type").and_then(|v| v.as_str()) {
Some("tool_use") => {
if let Some(id) = block.get("id").and_then(|v| v.as_str()) {
tool_use_ids.insert(id.to_string());
}
}
Some("tool_result") => {
if let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) {
tool_result_ids.insert(id.to_string());
}
}
_ => {}
}
}
}
for id in &tool_use_ids {
if !tool_result_ids.contains(id) {
return Err(format!("orphan tool_use {}: no matching tool_result", id));
}
}
for id in &tool_result_ids {
if !tool_use_ids.contains(id) {
return Err(format!("orphan tool_result {}: no matching tool_use", id));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;
/// Serializes tests that read/mutate the `YOKE_DISABLE_SESSION_TRIM` env
/// var. cargo runs tests in parallel within a binary; without this they
/// race on a process-global.
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Clear the env var before running the closure, then drop the guard.
fn with_clean_env<F: FnOnce()>(f: F) {
let _g = ENV_LOCK.lock().unwrap();
// SAFETY: tests are serialized via ENV_LOCK; no other thread will
// observe a partial write to environ.
unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") };
f();
}
#[test]
fn parse_keep_list_basic() {
let cwd = PathBuf::from("/tmp");
let s = "STATUS: IN_PROGRESS\nKEEP: src/a.rs /abs/b.rs\nfoo\n";
let got = parse_keep_list(s, &cwd);
assert!(got.iter().any(|p| p.ends_with("a.rs")));
assert!(got.iter().any(|p| p == &PathBuf::from("/abs/b.rs")));
}
#[test]
fn parse_keep_list_missing() {
let cwd = PathBuf::from("/tmp");
let s = "STATUS: DONE\n";
assert!(parse_keep_list(s, &cwd).is_empty());
}
#[test]
fn parse_keep_list_star_means_nothing() {
let cwd = PathBuf::from("/tmp");
let s = "KEEP: *\n";
assert!(parse_keep_list(s, &cwd).is_empty());
}
#[test]
fn session_file_path_slug() {
let p = session_file_path(
Path::new("/home/u"),
Path::new("/workspace"),
"abc-123",
);
assert_eq!(p, PathBuf::from("/home/u/.claude/projects/-workspace/abc-123.jsonl"));
}
/// Tiny realistic session: bootstrap + initial prompt + 2 Reads + a Bash
/// + a thinking block. Used by scenario tests below.
fn write_fixture(dir: &Path, paths: &[&str]) -> PathBuf {
let mut lines: Vec<String> = Vec::new();
// queue-operation (metadata) — has no uuid/parentUuid.
lines.push(r#"{"type":"queue-operation","sessionId":"s1"}"#.to_string());
// initial user prompt (root of conversation)
lines.push(r#"{"type":"user","uuid":"u-root","parentUuid":null,"message":{"role":"user","content":"Read .loop/protocol.md and follow its instructions."}}"#.to_string());
// assistant thinking — should always be dropped
lines.push(r#"{"type":"assistant","uuid":"u-think","parentUuid":"u-root","message":{"role":"assistant","content":[{"type":"thinking","thinking":"..."}]}}"#.to_string());
// Read tool_use for paths[0]
let p0 = paths.first().copied().unwrap_or("/tmp/a.rs");
lines.push(format!(
r#"{{"type":"assistant","uuid":"u-read-a","parentUuid":"u-think","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-a","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#,
p0
));
// tool_result for the Read
lines.push(r#"{"type":"user","uuid":"u-res-a","parentUuid":"u-read-a","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-a","content":"file A contents"}]}}"#.to_string());
// Bash tool_use — should always be dropped
lines.push(r#"{"type":"assistant","uuid":"u-bash","parentUuid":"u-res-a","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-bash","name":"Bash","input":{"command":"ls"}}]}}"#.to_string());
lines.push(r#"{"type":"user","uuid":"u-res-bash","parentUuid":"u-bash","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-bash","content":"a\nb\nc"}]}}"#.to_string());
// Read tool_use for paths[1] (a second file)
let p1 = paths.get(1).copied().unwrap_or("/tmp/b.rs");
lines.push(format!(
r#"{{"type":"assistant","uuid":"u-read-b","parentUuid":"u-res-bash","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-b","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#,
p1
));
lines.push(r#"{"type":"user","uuid":"u-res-b","parentUuid":"u-read-b","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-b","content":"file B contents"}]}}"#.to_string());
let path = dir.join("s1.jsonl");
let mut f = fs::File::create(&path).unwrap();
for l in &lines {
writeln!(f, "{}", l).unwrap();
}
path
}
fn read_records(path: &Path) -> Vec<Value> {
fs::read_to_string(path)
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn trim_with_one_keep_drops_other_reads_and_bash_and_thinking() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.rs");
let b_path = tmp.path().join("b.rs");
fs::write(&a_path, "fn a(){}").unwrap();
fs::write(&b_path, "fn b(){}").unwrap();
let session = write_fixture(
tmp.path(),
&[a_path.to_str().unwrap(), b_path.to_str().unwrap()],
);
let mut keep = HashSet::new();
keep.insert(fs::canonicalize(&a_path).unwrap());
let mut stats_opt = None;
with_clean_env(|| {
stats_opt = Some(trim_session(&session, &keep).expect("trim ok"));
});
let stats = stats_opt.unwrap();
assert!(!stats.skipped);
assert!(stats.records_dropped >= 4, "should drop thinking + bash pair + b read pair, got {:?}", stats);
let recs = read_records(&session);
// Surviving tool_use ids: only tu-a; tu-b and tu-bash are gone.
let tool_use_ids: Vec<String> = recs
.iter()
.filter_map(|r| {
r.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_use"))
.and_then(|b| b.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
})
.collect();
assert_eq!(tool_use_ids, vec!["tu-a".to_string()]);
// Every tool_use has a paired tool_result (validate() enforces this on write;
// re-check here for the behavior we promise).
let tool_result_ids: Vec<String> = recs
.iter()
.filter_map(|r| {
r.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
.and_then(|b| b.get("tool_use_id").and_then(|v| v.as_str()).map(|s| s.to_string()))
})
.collect();
assert_eq!(tool_result_ids, vec!["tu-a".to_string()]);
// No thinking blocks survive.
for r in &recs {
let typ_opt = r
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|b| b.get("type"))
.and_then(|v| v.as_str());
assert_ne!(typ_opt, Some("thinking"));
}
}
#[test]
fn trim_relinks_parent_uuids_to_nearest_surviving_ancestor() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.rs");
let b_path = tmp.path().join("b.rs");
fs::write(&a_path, "x").unwrap();
fs::write(&b_path, "y").unwrap();
let session = write_fixture(
tmp.path(),
&[a_path.to_str().unwrap(), b_path.to_str().unwrap()],
);
// Keep only b. Records dropped between root and b-read should result
// in u-read-b's new parent being u-root (the only surviving ancestor).
let mut keep = HashSet::new();
keep.insert(fs::canonicalize(&b_path).unwrap());
with_clean_env(|| {
trim_session(&session, &keep).expect("trim ok");
});
let recs = read_records(&session);
let read_b = recs
.iter()
.find(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-read-b"))
.expect("u-read-b survives");
let new_parent = read_b.get("parentUuid").and_then(|v| v.as_str());
// u-think, u-read-a, u-res-a, u-bash, u-res-bash all dropped → parent
// walks up to u-root.
assert_eq!(new_parent, Some("u-root"));
}
#[test]
fn trim_skipped_when_env_var_set() {
let tmp = tempfile::tempdir().unwrap();
let session = write_fixture(tmp.path(), &["/tmp/a.rs"]);
let original = fs::read_to_string(&session).unwrap();
let _g = ENV_LOCK.lock().unwrap();
// SAFETY: serialized by ENV_LOCK above.
unsafe { std::env::set_var("YOKE_DISABLE_SESSION_TRIM", "1") };
let stats = trim_session(&session, &HashSet::new()).expect("trim ok");
unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") };
assert!(stats.skipped);
assert_eq!(original, fs::read_to_string(&session).unwrap());
}
#[test]
fn trim_empty_keep_drops_all_tool_use_pairs() {
let tmp = tempfile::tempdir().unwrap();
let session = write_fixture(tmp.path(), &["/tmp/a.rs", "/tmp/b.rs"]);
with_clean_env(|| {
trim_session(&session, &HashSet::new()).expect("trim ok");
});
let recs = read_records(&session);
for r in &recs {
let block = r
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first());
if let Some(b) = block {
let btyp = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
assert_ne!(btyp, "tool_use", "no tool_use should survive empty keep");
assert_ne!(btyp, "tool_result", "no tool_result should survive empty keep");
assert_ne!(btyp, "thinking", "no thinking should survive");
}
}
// queue-operation, initial user prompt should survive.
assert!(recs.iter().any(|r| r.get("type").and_then(|v| v.as_str()) == Some("queue-operation")));
assert!(recs.iter().any(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-root")));
}
}