use crate::util::{dim_cwd, parse_harness, session_row, short_id};
use anyhow::Result;
use cv_core::ir::{truncate, SessionRef};
use cv_core::sanitize::sanitize_line;
fn apply_query(refs: &mut Vec<SessionRef>, query: &Option<cv_core::SessionQuery>) {
let Some(q) = query else { return };
crate::cmd::query::filter_refs(refs, q, &crate::cmd::query::text_sets(q));
}
fn json_rows<'a>(refs: impl Iterator<Item = &'a SessionRef>, limit: usize) -> Vec<(&'a SessionRef, serde_json::Value)> {
refs.filter_map(|r| std::fs::metadata(&r.path).ok().map(|m| (r, m.len())))
.take(limit)
.map(|(r, size)| (r, session_row(r, Some(size))))
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_ls(
harness: Option<String>,
cwd: Option<String>,
query: Option<String>,
limit: usize,
sort_by: &str,
fresh: bool, json: bool, enrich: bool, ) -> Result<()> {
let want = parse_harness(&harness)?;
let query = crate::cmd::query::build(query)?;
let all = if fresh {
cv_core::discover_all()
} else {
cv_core::sessions()
};
let discovered = all.len();
let mut refs: Vec<SessionRef> = all
.into_iter()
.filter(|r| want.is_none_or(|h| r.harness == h))
.filter(|r| match &cwd {
None => true,
Some(c) => r.cwd.as_ref().is_some_and(|p| p.to_string_lossy().contains(c)),
})
.collect();
apply_query(&mut refs, &query);
match sort_by {
"created" => refs.sort_by_key(|r| std::cmp::Reverse(r.created_at.or(r.updated_at))),
"messages" => refs.sort_by_key(|r| std::cmp::Reverse(r.message_count)),
_ => refs.sort_by_key(|r| std::cmp::Reverse(r.updated_at.or(r.created_at))),
}
let total = refs.len();
if json {
let rows: Vec<serde_json::Value> = json_rows(refs.iter(), limit)
.into_iter()
.map(|(r, mut obj)| {
if enrich {
if let Some(session) = cv_core::harness::for_harness(r.harness)
.and_then(|a| cv_core::stream::collect_with(a.as_ref(), r, &cv_core::ParseOptions::lazy()).ok())
{
let map = obj.as_object_mut().expect("json object");
if let Some(git) = &session.git {
if let Ok(g) = serde_json::to_value(git) {
map.insert("git".into(), g);
}
}
map.insert("display_title".into(), serde_json::json!(session.synth_title()));
}
}
obj
})
.collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if total < discovered {
println!("{total} session(s) (of {discovered} discovered; filtered)\n");
} else {
println!("{total} session(s)\n");
}
for r in refs.iter().filter(|r| r.path.exists()).take(limit) {
println!(
"{:8} {:8} {} {:>4} msg {}",
r.harness.as_str(),
short_id(&r.id),
crate::util::fmt_span(r.created_at, r.updated_at),
r.message_count,
r.title
.as_deref()
.map(|t| truncate(&sanitize_line(t), 60))
.unwrap_or_else(|| sanitize_line(&dim_cwd(r.cwd.as_deref())).into_owned()),
);
}
if total > limit {
println!("\n… {} more (use --limit)", total - limit);
}
Ok(())
}
pub(crate) fn cmd_timeline(
harness: Option<String>,
cwd: Option<String>,
query: Option<String>,
limit: usize,
json: bool, ) -> Result<()> {
let want = parse_harness(&harness)?;
let query = crate::cmd::query::build(query)?;
let mut refs: Vec<SessionRef> = cv_core::sessions()
.into_iter()
.filter(|r| want.is_none_or(|h| r.harness == h))
.filter(|r| match &cwd {
None => true,
Some(c) => r.cwd.as_ref().map(|p| p.to_string_lossy().contains(c)).unwrap_or(false),
})
.collect();
apply_query(&mut refs, &query);
let key = |r: &SessionRef| r.updated_at.or(r.created_at);
refs.sort_by_key(key);
let total = refs.len();
let shown = if total > limit {
&refs[total - limit..]
} else {
&refs[..]
};
if json {
let rows: Vec<serde_json::Value> = json_rows(shown.iter(), limit).into_iter().map(|(_, v)| v).collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if total > limit {
println!("… {} older (use --limit)\n", total - limit);
}
let mut last_day: Option<String> = None;
for r in shown.iter().filter(|r| r.path.exists()) {
let when = key(r);
let day = when
.map(|d| crate::util::fmt_local(d, "%Y-%m-%d"))
.unwrap_or_else(|| "----------".into());
if last_day.as_deref() != Some(day.as_str()) {
println!("── {day} ──");
last_day = Some(day.clone());
}
let time = when
.map(|d| crate::util::fmt_local(d, "%H:%M"))
.unwrap_or_else(|| "--:--".into());
let title = r
.title
.as_deref()
.map(|t| truncate(&sanitize_line(t), 50))
.unwrap_or_else(|| sanitize_line(&dim_cwd(r.cwd.as_deref())).into_owned());
let since = match (r.created_at, when) {
(Some(c), Some(u))
if c.with_timezone(&chrono::Local).date_naive() != u.with_timezone(&chrono::Local).date_naive() =>
{
format!(" ⇠ since {}", crate::util::fmt_local(c, "%m-%d"))
}
_ => String::new(),
};
println!(
" {} {:8} {:8} {:24} {}{}",
time,
r.harness.as_str(),
short_id(&r.id),
truncate(&sanitize_line(&dim_cwd(r.cwd.as_deref())), 24),
title,
since,
);
}
println!("\n{total} session(s)");
Ok(())
}
pub(crate) fn cmd_stats(query: Option<String>, json: bool, tokens: bool) -> Result<()> {
let query = crate::cmd::query::build(query)?;
let mut refs = cv_core::sessions();
apply_query(&mut refs, &query);
let s = cv_core::stats::CorpusStats::compute(&refs);
let total = s.sessions;
let t = tokens.then(|| cv_core::stats::TokenStats::compute(&refs));
if json {
let mut v = s.to_json();
if let Some(t) = &t {
v["tokens"] = t.to_json();
}
println!("{}", serde_json::to_string_pretty(&v)?);
return Ok(());
}
if total == 0 {
let scope = if query.is_some() {
" match the query"
} else {
" discovered"
};
println!("no sessions{scope}.");
return Ok(());
}
println!("✦ clustervision fleet stats\n");
println!("{total} session(s) · {} message(s)\n", s.messages);
println!("by harness:");
for (h, n) in &s.by_harness {
println!(" {h:12} {n:>5}");
}
println!("\ntop cwds:");
for (c, n) in s.top_cwds.iter().take(cv_core::stats::TOP_CWDS) {
println!(" {n:>5} {}", truncate(c, 70));
}
println!("\ndate range:");
println!(
" earliest created: {}",
s.earliest_created
.map(|d| crate::util::fmt_local(d, "%Y-%m-%d %H:%M"))
.unwrap_or_else(|| "?".into())
);
println!(
" latest updated: {}",
s.latest_updated
.map(|d| crate::util::fmt_local(d, "%Y-%m-%d %H:%M"))
.unwrap_or_else(|| "?".into())
);
if let Some(t) = &t {
print_token_stats(t);
}
Ok(())
}
fn si(n: u64) -> String {
let f = n as f64;
match n {
1_000_000_000.. => format!("{:.2}B", f / 1e9),
1_000_000.. => format!("{:.1}M", f / 1e6),
1_000.. => format!("{:.1}K", f / 1e3),
_ => n.to_string(),
}
}
fn print_token_stats(t: &cv_core::stats::TokenStats) {
use cv_core::stats::TokenRow;
println!(
"
tokens ({} session(s) + {} sub-agent transcript(s){}{}):",
t.sessions,
t.subagents,
if t.failed > 0 {
format!(", {} unparseable", t.failed)
} else {
String::new()
},
if t.duplicates > 0 {
format!(", {} repeated usage record(s) skipped", t.duplicates)
} else {
String::new()
},
);
println!(
" {:8} {:30} {:>8} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
"harness", "model", "calls", "input", "c.write", "c.read", "output", "uncached", "total"
);
let line = |r: &TokenRow| {
let cost = r.cost_usd.map(|c| format!(" ${c:.2}")).unwrap_or_default();
println!(
" {:8} {:30} {:>8} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}{cost}",
r.harness,
truncate(&r.model, 30),
r.calls,
si(r.input),
si(r.cache_write),
si(r.cache_read),
si(r.output),
si(r.uncached()),
si(r.total()),
)
};
for r in &t.rows {
line(r);
}
let by_h = t.by_harness();
if by_h.len() > 1 {
println!();
for r in &by_h {
line(r);
}
}
println!();
line(&t.total());
println!(" (input excludes cache; uncached = input + cache writes + output)");
}