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",
}
}
pub fn key(self) -> &'static str {
match self {
Outcome::Refund => "refund",
Outcome::Miss => "miss",
Outcome::Sufficed => "sufficed",
Outcome::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Answer {
pub at: String,
pub query: String,
pub tool: String,
pub outcome: &'static str,
pub word: &'static str,
}
const KEEP: usize = 24;
#[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,
pub recent: Vec<Answer>,
}
#[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,
}
}
fn question_in(input: Option<&serde_json::Value>) -> String {
let Some(input) = input else {
return String::new();
};
for key in ["query", "question", "name", "target", "from", "path"] {
if let Some(text) = input.get(key).and_then(|v| v.as_str())
&& !text.is_empty()
{
return text.to_string();
}
}
input
.as_object()
.and_then(|map| map.values().find_map(|v| v.as_str()))
.unwrap_or_default()
.to_string()
}
fn short_tool(name: &str) -> String {
name.rsplit("__").next().unwrap_or(name).to_string()
}
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();
let mut asked: Vec<Option<(String, String)>> = Vec::new();
for line in text.lines() {
let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let at = event
.get("timestamp")
.and_then(|t| t.as_str())
.unwrap_or_default()
.to_string();
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()) {
asked.push(if is_semlith(name) {
Some((at.clone(), question_in(part.get("input"))))
} else {
None
});
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;
let outcome = outcome_of(&calls[i + 1..]);
match outcome {
Outcome::Refund => session.refund += 1,
Outcome::Miss => session.miss += 1,
Outcome::Sufficed => session.sufficed += 1,
Outcome::Unknown => session.unknown += 1,
}
let (at, query) = asked.get(i).cloned().flatten().unwrap_or_default();
session.recent.push(Answer {
at,
query,
tool: short_tool(name),
outcome: outcome.key(),
word: outcome.word(),
});
}
if session.recent.len() > KEEP {
session.recent.drain(..session.recent.len() - KEEP);
}
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);
}
#[test]
fn the_question_is_read_from_whichever_field_the_tool_names_it() {
let ask = |json: &str| question_in(Some(&serde_json::from_str(json).unwrap()));
assert_eq!(
ask(r#"{"query": "how is the lock taken"}"#),
"how is the lock taken"
);
assert_eq!(ask(r#"{"name": "acquire", "depth": 3}"#), "acquire");
assert_eq!(
ask(r#"{"question": "what writes the ledger"}"#),
"what writes the ledger"
);
assert_eq!(
ask(r#"{"name": "acquire", "query": "the lock"}"#),
"the lock"
);
assert_eq!(ask(r#"{"store": "big"}"#), "big");
assert_eq!(ask(r#"{"k": 8}"#), "");
assert_eq!(question_in(None), "");
}
#[test]
fn a_tool_is_shown_without_the_readers_own_server_name() {
assert_eq!(
short_tool("mcp__my-index__semlith_search"),
"semlith_search"
);
assert_eq!(short_tool("semlith_impact"), "semlith_impact");
}
#[test]
fn every_outcome_has_a_key_the_page_can_style_by() {
for outcome in [
Outcome::Refund,
Outcome::Miss,
Outcome::Sufficed,
Outcome::Unknown,
] {
assert!(!outcome.key().is_empty());
assert!(!outcome.word().is_empty());
assert!(outcome.key().chars().all(|c| c.is_ascii_lowercase()));
}
}
}