use anyhow::Result;
use std::path::{Path, PathBuf};
pub const CLIENT: &str = "claude-code";
pub const FILES: usize = 40;
pub const BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Refund,
Miss,
Sufficed,
Unknown,
}
impl Outcome {
pub fn word(self) -> &'static str {
match self {
Outcome::Refund => "read the whole file",
Outcome::Miss => "grepped anyway",
Outcome::Sufficed => "edited",
Outcome::Unknown => "nothing recorded",
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct Session {
pub id: String,
pub project: String,
pub last: i64,
pub answers: usize,
pub refund: usize,
pub miss: usize,
pub sufficed: usize,
pub unknown: usize,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct Replay {
pub client: String,
pub sessions: Vec<Session>,
pub skipped: usize,
pub from: String,
}
const WINDOW: usize = 3;
pub fn outcome_of(following: &[String]) -> Outcome {
for name in following.iter().take(WINDOW) {
match kind_of(name) {
Some(outcome) => return outcome,
None => continue,
}
}
Outcome::Unknown
}
fn kind_of(name: &str) -> Option<Outcome> {
if is_semlith(name) {
return Some(Outcome::Unknown);
}
match name {
"Read" | "NotebookRead" => Some(Outcome::Refund),
"Grep" | "Glob" => Some(Outcome::Miss),
"Edit" | "Write" | "MultiEdit" | "NotebookEdit" => Some(Outcome::Sufficed),
_ => None,
}
}
pub fn is_semlith(name: &str) -> bool {
name.rsplit("__")
.next()
.is_some_and(|tail| tail.starts_with("semlith_"))
}
pub fn transcripts_dir() -> Result<PathBuf> {
Ok(crate::home::user_home()?.join(".claude").join("projects"))
}
pub fn read(dir: &Path, limit: usize) -> Result<Replay> {
let mut found: Vec<(i64, PathBuf, String)> = Vec::new();
let Ok(projects) = std::fs::read_dir(dir) else {
return Ok(Replay {
client: CLIENT.to_string(),
from: crate::plain(&dir.display().to_string()),
..Default::default()
});
};
for project in projects.flatten() {
let Ok(entries) = std::fs::read_dir(project.path()) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let modified = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let label = project
.file_name()
.to_string_lossy()
.trim_start_matches('-')
.to_string();
found.push((modified, path, label));
}
}
found.sort_by_key(|(at, _, _)| std::cmp::Reverse(*at));
let skipped = found.len().saturating_sub(limit);
found.truncate(limit);
let mut sessions = Vec::new();
for (last, path, project) in found {
let Some(session) = one(&path, &project, last) else {
continue;
};
if session.answers > 0 {
sessions.push(session);
}
}
sessions.sort_by_key(|s| std::cmp::Reverse(s.last));
Ok(Replay {
client: CLIENT.to_string(),
sessions,
skipped,
from: crate::plain(&dir.display().to_string()),
})
}
fn one(path: &Path, project: &str, last: i64) -> Option<Session> {
let text = read_tail(path, BYTES)?;
let mut calls: Vec<String> = Vec::new();
for line in text.lines() {
let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let content = event
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array());
for part in content.into_iter().flatten() {
if part.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
if let Some(name) = part.get("name").and_then(|n| n.as_str()) {
calls.push(name.to_string());
}
}
}
let mut session = Session {
id: path.file_stem()?.to_string_lossy().to_string(),
project: project.to_string(),
last,
..Default::default()
};
for (i, name) in calls.iter().enumerate() {
if !is_semlith(name) {
continue;
}
session.answers += 1;
match outcome_of(&calls[i + 1..]) {
Outcome::Refund => session.refund += 1,
Outcome::Miss => session.miss += 1,
Outcome::Sufficed => session.sufficed += 1,
Outcome::Unknown => session.unknown += 1,
}
}
Some(session)
}
fn read_tail(path: &Path, bytes: u64) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path).ok()?;
let len = file.metadata().ok()?.len();
if len > bytes {
file.seek(SeekFrom::Start(len - bytes)).ok()?;
}
let mut raw = Vec::new();
file.read_to_end(&mut raw).ok()?;
let text = String::from_utf8_lossy(&raw).into_owned();
if len > bytes {
return Some(
text.split_once('\n')
.map(|(_, rest)| rest)
.unwrap_or_default()
.to_string(),
);
}
Some(text)
}
#[cfg(test)]
mod tests {
use super::*;
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_tool_name_is_semlith_whatever_the_server_was_called() {
assert!(is_semlith("mcp__semlith__semlith_search"));
assert!(is_semlith("mcp__my-index__semlith_impact"));
assert!(is_semlith("semlith_trace"));
assert!(!is_semlith("Read"));
assert!(!is_semlith("mcp__other__search"));
}
#[test]
fn what_followed_decides_and_the_first_meaningful_call_wins() {
assert_eq!(outcome_of(&names(&["Read"])), Outcome::Refund);
assert_eq!(outcome_of(&names(&["Grep", "Edit"])), Outcome::Miss);
assert_eq!(
outcome_of(&names(&["TodoWrite", "Edit"])),
Outcome::Sufficed
);
assert_eq!(
outcome_of(&names(&["mcp__semlith__semlith_read", "Edit"])),
Outcome::Unknown
);
assert_eq!(
outcome_of(&names(&["TodoWrite", "TodoWrite", "TodoWrite", "Edit"])),
Outcome::Unknown
);
assert_eq!(outcome_of(&[]), Outcome::Unknown);
}
}