use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde_json::Value;
const PROMPT_FRESH_WINDOW: Duration = Duration::from_secs(5 * 60);
use crate::discovery::{encode_cwd, projects_dir};
use crate::models::{Provider, Session};
#[allow(dead_code)]
#[derive(Clone)]
pub struct TranscriptDigest {
pub path: PathBuf,
pub headline: Option<String>,
pub headline_at: Option<SystemTime>,
pub last_prompt: Option<String>,
pub last_prompt_at: Option<SystemTime>,
pub last_turn_duration_ms: Option<u64>,
pub last_turn_msg_count: Option<u64>,
pub last_event_at: Option<SystemTime>,
pub last_stop_at: Option<SystemTime>,
pub user_prompt_count: u64,
pub last_stop_had_errors: bool,
pub last_tool_use: Option<(String, String)>,
pub total_cost_usd: f64,
pub total_tokens_in: u64,
pub total_tokens_out: u64,
pub total_tokens_cache_write: u64,
pub total_tokens_cache_read: u64,
pub latest_context_tokens: u64,
pub peak_context_tokens: u64,
pub latest_model: Option<String>,
pub latest_assistant_text: Option<String>,
}
struct Rates {
input: f64,
output: f64,
cache_write: f64, cache_read: f64,
}
const OPUS_RATES: Rates = Rates {
input: 15.0,
output: 75.0,
cache_write: 18.75,
cache_read: 1.50,
};
const SONNET_RATES: Rates = Rates {
input: 3.0,
output: 15.0,
cache_write: 3.75,
cache_read: 0.30,
};
const HAIKU_RATES: Rates = Rates {
input: 1.0,
output: 5.0,
cache_write: 1.25,
cache_read: 0.10,
};
fn rates_for_model(model: &str) -> &'static Rates {
let m = model.to_lowercase();
if m.contains("opus") {
&OPUS_RATES
} else if m.contains("haiku") {
&HAIKU_RATES
} else {
&SONNET_RATES
}
}
#[derive(Clone, Debug)]
pub struct MessageUsage {
pub cost_usd: f64,
pub model: String,
pub in_tok: u64,
pub out_tok: u64,
pub cw_tok: u64,
pub cr_tok: u64,
}
pub fn score_message(msg: &Value, counted_msg_ids: &mut HashSet<String>) -> Option<MessageUsage> {
let usage = msg.get("usage")?;
let msg_id = msg
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
if !(msg_id.is_empty() || counted_msg_ids.insert(msg_id)) {
return None;
}
let model = msg
.get("model")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let r = rates_for_model(&model);
let in_tok = usage
.get("input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let out_tok = usage
.get("output_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cw_tok = usage
.get("cache_creation_input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cr_tok = usage
.get("cache_read_input_tokens")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cost_usd = (in_tok as f64 * r.input
+ out_tok as f64 * r.output
+ cw_tok as f64 * r.cache_write
+ cr_tok as f64 * r.cache_read)
/ 1_000_000.0;
Some(MessageUsage {
cost_usd,
model,
in_tok,
out_tok,
cw_tok,
cr_tok,
})
}
pub fn parse_timestamp(s: &str) -> Option<SystemTime> {
parse_ts(s)
}
#[derive(Default)]
pub struct DigestCache {
entries: HashMap<PathBuf, (SystemTime, TranscriptDigest)>,
}
impl DigestCache {
pub fn new() -> Self {
Self::default()
}
fn get(&mut self, path: &Path) -> Option<TranscriptDigest> {
let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
if let Some((cached_mtime, d)) = self.entries.get(path)
&& *cached_mtime == mtime
{
return Some(d.clone());
}
let d = digest(path)?;
self.entries.insert(path.to_path_buf(), (mtime, d.clone()));
Some(d)
}
pub fn evict_missing(&mut self) {
self.entries.retain(|p, _| p.exists());
}
}
pub fn assign_transcripts(sessions: &mut [Session], cache: &mut DigestCache) {
let mut by_cwd: HashMap<PathBuf, Vec<usize>> = HashMap::new();
for (i, s) in sessions.iter().enumerate() {
if s.provider != Provider::Claude {
continue;
}
by_cwd.entry(s.cwd.clone()).or_default().push(i);
}
for (cwd, mut idxs) in by_cwd {
let dir = projects_dir().join(encode_cwd(&cwd));
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
let mut jsonls: Vec<(SystemTime, SystemTime, PathBuf)> = Vec::new();
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if meta.len() == 0 {
continue;
}
let Ok(mtime) = meta.modified() else { continue };
let user_ts = cache
.get(&path)
.and_then(|d| d.last_prompt_at)
.unwrap_or(mtime);
jsonls.push((mtime, user_ts, path));
}
let mut matched: Vec<usize> = Vec::new();
for (pos, &si) in idxs.iter().enumerate() {
let sid = &sessions[si].session_id;
if sid.is_empty() {
continue;
}
let target_name = format!("{sid}.jsonl");
if let Some(j) = jsonls.iter().position(|(_, _, p)| {
p.file_name()
.map(|f| f == target_name.as_str())
.unwrap_or(false)
}) {
let (_, _, path) = jsonls.swap_remove(j);
sessions[si].transcript_path = Some(path);
matched.push(pos);
}
}
matched.sort_unstable();
for pos in matched.into_iter().rev() {
idxs.swap_remove(pos);
}
let active_idx = idxs
.iter()
.position(|&i| sessions[i].pane.as_ref().is_some_and(|p| p.active));
if let (Some(pos), false) = (active_idx, jsonls.is_empty()) {
let pick = jsonls
.iter()
.enumerate()
.max_by_key(|(_, (_, uts, _))| *uts)
.map(|(i, _)| i);
if let Some(j) = pick {
let (_, _, path) = jsonls.swap_remove(j);
let session_idx = idxs.swap_remove(pos);
sessions[session_idx].transcript_path = Some(path);
}
}
jsonls.sort_by(|a, b| b.0.cmp(&a.0));
idxs.sort_by_key(|&i| std::cmp::Reverse(sessions[i].updated_at_ms));
for (k, &si) in idxs.iter().enumerate() {
if k >= jsonls.len() {
break;
}
sessions[si].transcript_path = Some(jsonls[k].2.clone());
}
}
}
pub fn locate_transcript(cwd: &Path, session_id: &str) -> Option<PathBuf> {
let dir = projects_dir().join(encode_cwd(cwd));
let by_id = dir.join(format!("{session_id}.jsonl"));
if let Ok(meta) = fs::metadata(&by_id)
&& meta.len() > 0
{
return Some(by_id);
}
let entries = fs::read_dir(&dir).ok()?;
let mut best: Option<(SystemTime, PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(mtime) = meta.modified() else { continue };
if best.as_ref().is_none_or(|(t, _)| mtime > *t) {
best = Some((mtime, path));
}
}
best.map(|(_, p)| p)
}
pub fn digest(path: &Path) -> Option<TranscriptDigest> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8_lossy(&bytes);
let mut headline: Option<(SystemTime, String)> = None;
let mut last_prompt: Option<String> = None;
let mut last_prompt_at: Option<SystemTime> = None;
let mut last_turn_duration_ms: Option<u64> = None;
let mut last_turn_msg_count: Option<u64> = None;
let mut last_event_at: Option<SystemTime> = None;
let mut last_stop_at: Option<SystemTime> = None;
let mut user_prompt_count: u64 = 0;
let mut last_stop_had_errors = false;
let mut tool_uses: Vec<(String, String, String)> = Vec::new();
let mut completed_tool_ids: HashSet<String> = HashSet::new();
let mut total_cost_usd: f64 = 0.0;
let mut total_tokens_in: u64 = 0;
let mut total_tokens_out: u64 = 0;
let mut total_tokens_cache_write: u64 = 0;
let mut total_tokens_cache_read: u64 = 0;
let mut counted_msg_ids: HashSet<String> = HashSet::new();
let mut latest_context_tokens: u64 = 0;
let mut peak_context_tokens: u64 = 0;
let mut latest_model: Option<String> = None;
let mut latest_assistant_text: Option<(SystemTime, String)> = None;
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
let ts = v
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(parse_ts);
if let Some(t) = ts
&& last_event_at.is_none_or(|prev| t > prev)
{
last_event_at = Some(t);
}
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
"last-prompt" => {
if let Some(p) = v.get("lastPrompt").and_then(|p| p.as_str()) {
last_prompt = Some(p.to_string());
}
}
"user" => {
if let Some(content) = v.get("message").and_then(|m| m.get("content")) {
if let Some(text) = extract_user_text(content)
&& let Some(t) = ts
{
last_prompt = Some(text);
last_prompt_at = Some(t);
user_prompt_count += 1;
}
if let Some(arr) = content.as_array() {
for block in arr {
if block.get("type").and_then(|t| t.as_str()) == Some("tool_result")
&& let Some(id) = block.get("tool_use_id").and_then(|i| i.as_str())
{
completed_tool_ids.insert(id.to_string());
}
}
}
}
}
"assistant" => {
if let Some(content) = v.get("message").and_then(|m| m.get("content"))
&& let Some(arr) = content.as_array()
{
let mut text_parts: Vec<String> = Vec::new();
for block in arr {
let block_type = block.get("type").and_then(|t| t.as_str());
if block_type == Some("tool_use") {
let id = block
.get("id")
.and_then(|i| i.as_str())
.unwrap_or_default()
.to_string();
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("?")
.to_string();
let brief = crate::approval::brief_tool_input(block.get("input"));
tool_uses.push((id, name, brief));
} else if block_type == Some("text")
&& let Some(t) = block.get("text").and_then(|t| t.as_str())
&& !t.trim().is_empty()
{
text_parts.push(t.to_string());
}
}
if !text_parts.is_empty()
&& let Some(t) = ts
&& latest_assistant_text
.as_ref()
.is_none_or(|(prev, _)| t >= *prev)
{
latest_assistant_text = Some((t, text_parts.join("\n\n")));
}
}
if let Some(msg) = v.get("message")
&& let Some(u) = score_message(msg, &mut counted_msg_ids)
{
total_cost_usd += u.cost_usd;
total_tokens_in += u.in_tok;
total_tokens_out += u.out_tok;
total_tokens_cache_write += u.cw_tok;
total_tokens_cache_read += u.cr_tok;
let ctx = u.in_tok + u.cw_tok + u.cr_tok;
latest_context_tokens = ctx;
if ctx > peak_context_tokens {
peak_context_tokens = ctx;
}
if !u.model.is_empty() {
latest_model = Some(u.model);
}
}
}
"system" => {
let sub = v.get("subtype").and_then(|s| s.as_str()).unwrap_or("");
match sub {
"away_summary" => {
if let (Some(c), Some(t)) = (v.get("content").and_then(|c| c.as_str()), ts)
&& headline.as_ref().is_none_or(|(prev, _)| t > *prev)
{
headline = Some((t, c.to_string()));
}
}
"turn_duration" => {
if let Some(ms) = v.get("durationMs").and_then(|d| d.as_u64()) {
last_turn_duration_ms = Some(ms);
}
if let Some(mc) = v.get("messageCount").and_then(|m| m.as_u64()) {
last_turn_msg_count = Some(mc);
}
}
"stop_hook_summary" => {
if let Some(t) = ts {
last_stop_at = Some(t);
}
let errs = v
.get("hookErrors")
.and_then(|e| e.as_array())
.map(|a| !a.is_empty())
.unwrap_or(false);
last_stop_had_errors = errs;
}
_ => {}
}
}
_ => {}
}
}
let (headline_at, headline_text) = match headline {
Some((t, s)) => (Some(t), Some(s)),
None => (None, None),
};
let last_tool_use = tool_uses
.into_iter()
.rev()
.find(|(id, _, _)| id.is_empty() || !completed_tool_ids.contains(id))
.map(|(_, name, brief)| (name, brief));
Some(TranscriptDigest {
path: path.to_path_buf(),
headline: headline_text,
headline_at,
last_prompt,
last_prompt_at,
last_turn_duration_ms,
last_turn_msg_count,
last_event_at,
last_stop_at,
user_prompt_count,
last_stop_had_errors,
last_tool_use,
total_cost_usd,
total_tokens_in,
total_tokens_out,
total_tokens_cache_write,
total_tokens_cache_read,
latest_context_tokens,
peak_context_tokens,
latest_model,
latest_assistant_text: latest_assistant_text.map(|(_, t)| t),
})
}
pub fn enrich(session: &mut Session, now: SystemTime, cache: &mut DigestCache) {
if session.provider != Provider::Claude {
return;
}
let path = match &session.transcript_path {
Some(p) => p.clone(),
None => match locate_transcript(&session.cwd, &session.session_id) {
Some(p) => p,
None => return,
},
};
session.transcript_path = Some(path.clone());
let Some(d) = cache.get(&path) else { return };
let transcript_fresh = d
.last_event_at
.and_then(|t| now.duration_since(t).ok())
.map(|age| age <= PROMPT_FRESH_WINDOW)
.unwrap_or(false);
let session_is_active = transcript_fresh || session.status == "busy";
let prompt_supersedes = match (d.headline_at, d.last_prompt_at) {
(Some(h), Some(p)) => p > h && session_is_active,
(None, Some(_)) => session_is_active,
_ => false,
};
session.headline = if prompt_supersedes {
d.last_prompt.clone().map(|p| format!("→ {p}"))
} else {
d.headline.clone()
}
.or_else(|| d.headline.clone())
.or_else(|| d.last_prompt.clone());
session.last_prompt = d.last_prompt;
session.last_prompt_at = d.last_prompt_at;
session.last_turn_duration_ms = d.last_turn_duration_ms;
session.last_turn_msg_count = d.last_turn_msg_count;
session.last_event_at = d.last_event_at;
session.last_stop_at = d.last_stop_at;
session.user_prompt_count = d.user_prompt_count;
session.last_stop_had_errors = d.last_stop_had_errors;
session.last_tool_use = d.last_tool_use;
session.total_cost_usd = d.total_cost_usd;
session.total_tokens_in = d.total_tokens_in;
session.total_tokens_out = d.total_tokens_out;
session.total_tokens_cache_write = d.total_tokens_cache_write;
session.total_tokens_cache_read = d.total_tokens_cache_read;
session.latest_context_tokens = d.latest_context_tokens;
session.peak_context_tokens = d.peak_context_tokens;
session.latest_model = d.latest_model;
session.latest_assistant_text = d.latest_assistant_text;
}
fn extract_user_text(content: &Value) -> Option<String> {
if let Some(s) = content.as_str() {
return clean_prompt_text(s);
}
let arr = content.as_array()?;
let mut parts: Vec<String> = Vec::new();
for block in arr {
if block.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(t) = block.get("text").and_then(|t| t.as_str())
&& let Some(cleaned) = clean_prompt_text(t)
{
parts.push(cleaned);
}
}
if parts.is_empty() {
return None;
}
Some(parts.join(" "))
}
fn clean_prompt_text(s: &str) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with("[Image: source:") {
return None;
}
if trimmed == "[Request interrupted by user]" {
return None;
}
if trimmed.starts_with("<command-") || trimmed.starts_with("<local-command-") {
return None;
}
Some(trimmed.to_string())
}
fn parse_ts(s: &str) -> Option<SystemTime> {
let bytes = s.as_bytes();
if bytes.len() < 20 || bytes[10] != b'T' || *bytes.last().unwrap() != b'Z' {
return None;
}
let year: i64 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
let hour: u32 = std::str::from_utf8(&bytes[11..13]).ok()?.parse().ok()?;
let min: u32 = std::str::from_utf8(&bytes[14..16]).ok()?.parse().ok()?;
let sec: u32 = std::str::from_utf8(&bytes[17..19]).ok()?.parse().ok()?;
let secs = days_from_civil(year, month, day) * 86400
+ hour as i64 * 3600
+ min as i64 * 60
+ sec as i64;
if secs < 0 {
return None;
}
Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe as i64 - 719468
}