use crate::session::{Record, Session, Source};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Assist,
Surfaced,
SelfLoad,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SkillRow {
pub id: String,
pub kind: Kind,
pub confidence: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SessionRow {
pub id: String,
pub updated: u64,
pub skills: Vec<SkillRow>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Report {
pub sessions: Vec<SessionRow>,
pub total_sessions: usize,
pub surfaced: u64,
pub assisted: u64,
pub self_loads: u64,
}
fn classify(r: &Record) -> Kind {
match r.source {
Source::Ski => Kind::Surfaced,
Source::Model if r.confidence > 0.0 => Kind::Assist,
Source::Model => Kind::SelfLoad,
}
}
fn kind_order(k: Kind) -> u8 {
match k {
Kind::Assist => 0,
Kind::Surfaced => 1,
Kind::SelfLoad => 2,
}
}
pub fn summarize(mut sessions: Vec<(String, Session)>, limit: usize) -> Report {
let mut report = Report {
total_sessions: sessions.len(),
..Report::default()
};
for (_, s) in &sessions {
for r in s.loaded.values() {
match classify(r) {
Kind::Assist => {
report.surfaced += 1;
report.assisted += 1;
}
Kind::Surfaced => report.surfaced += 1,
Kind::SelfLoad => report.self_loads += 1,
}
}
}
sessions.sort_by(|a, b| b.1.updated.cmp(&a.1.updated).then(a.0.cmp(&b.0)));
sessions.truncate(limit);
for (id, s) in sessions {
let mut skills: Vec<SkillRow> = s
.loaded
.iter()
.map(|(sid, r)| SkillRow {
id: sid.clone(),
kind: classify(r),
confidence: r.confidence,
})
.collect();
skills.sort_by(|a, b| {
kind_order(a.kind)
.cmp(&kind_order(b.kind))
.then(
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.id.cmp(&b.id))
});
report.sessions.push(SessionRow {
id,
updated: s.updated,
skills,
});
}
report
}
pub fn run(limit: usize) -> anyhow::Result<()> {
let dir = crate::paths::sessions_dir();
let mut sessions: Vec<(String, Session)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let session = Session::load(&path);
if session.loaded.is_empty() {
continue;
}
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
sessions.push((id, session));
}
}
let report = summarize(sessions, limit);
print_report(&report, limit, &dir);
Ok(())
}
fn print_report(report: &Report, limit: usize, dir: &std::path::Path) {
if report.total_sessions == 0 {
println!(
"no conversations on record yet — ski logs activity per session as you \
use it.\ncheck back after a few prompts (state dir: {})",
tilde(dir)
);
return;
}
let convo = if report.total_sessions == 1 {
"conversation"
} else {
"conversations"
};
println!(
"ski activity — {} {} on record ({})\n",
report.total_sessions,
convo,
tilde(dir)
);
println!(
" {:>4} skills ski surfaced that the model then invoked (assists)",
report.assisted
);
println!(
" {:>4} skills ski surfaced the model didn't invoke",
report.surfaced.saturating_sub(report.assisted)
);
println!(
" {:>4} skills the model found itself, ski stayed silent (recall misses)",
report.self_loads
);
if report.sessions.is_empty() {
return;
}
let shown = report.sessions.len();
let more = report.total_sessions.saturating_sub(shown);
println!("\n recent conversations (newest first):");
for s in &report.sessions {
println!("\n {} {}", s.id, ago(s.updated));
for sk in &s.skills {
let (tag, note) = match sk.kind {
Kind::Assist => (
"used",
format!("surfaced at {:.2}, model invoked it", sk.confidence),
),
Kind::Surfaced => (
"sent",
format!("surfaced at {:.2}, not invoked", sk.confidence),
),
Kind::SelfLoad => ("miss", "model loaded it, ski was silent".to_string()),
};
println!(" {tag} {:<26} {note}", sk.id);
}
}
if more > 0 {
let hint = if limit == usize::MAX {
String::new()
} else {
format!(
" (raise --limit, or --limit {} for all)",
report.total_sessions
)
};
println!(
"\n … and {} older conversation{}{}",
more,
if more == 1 { "" } else { "s" },
hint
);
}
println!(
"\n legend: used = ski assist · sent = surfaced, unused · miss = self-load\n \
for prompt-level detail, enable telemetry then see `ski history` / `ski suggest`."
);
}
fn ago(updated: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let secs = now.saturating_sub(updated);
if updated == 0 {
"time unknown".to_string()
} else if secs < 90 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
}
fn tilde(path: &std::path::Path) -> String {
if let Some(home) = std::env::var_os("HOME") {
if let Ok(rest) = path.strip_prefix(&home) {
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn ski(conf: f32) -> Record {
Record {
source: Source::Ski,
confidence: conf,
}
}
fn model(conf: f32) -> Record {
Record {
source: Source::Model,
confidence: conf,
}
}
fn session(updated: u64, loaded: &[(&str, Record)]) -> Session {
Session {
loaded: loaded.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
updated,
..Session::default()
}
}
#[test]
fn classify_distinguishes_assist_from_self_load() {
assert_eq!(classify(&ski(0.7)), Kind::Surfaced);
assert_eq!(classify(&model(0.7)), Kind::Assist);
assert_eq!(classify(&model(0.0)), Kind::SelfLoad);
}
#[test]
fn summarize_counts_across_all_sessions() {
let sessions = vec![
(
"s1".to_string(),
session(100, &[("xlsx", model(0.8)), ("pdf", ski(0.6))]),
),
(
"s2".to_string(),
session(200, &[("git-attribution", model(0.0))]),
),
];
let r = summarize(sessions, 10);
assert_eq!(r.total_sessions, 2);
assert_eq!(r.assisted, 1); assert_eq!(r.surfaced, 2); assert_eq!(r.self_loads, 1); }
#[test]
fn aggregate_spans_all_sessions_even_when_display_is_limited() {
let sessions = vec![
("a".to_string(), session(1, &[("one", model(0.9))])),
("b".to_string(), session(2, &[("two", model(0.9))])),
("c".to_string(), session(3, &[("three", model(0.9))])),
];
let r = summarize(sessions, 1);
assert_eq!(r.sessions.len(), 1);
assert_eq!(r.sessions[0].id, "c");
assert_eq!(r.assisted, 3);
assert_eq!(r.total_sessions, 3);
}
#[test]
fn sessions_are_newest_first() {
let sessions = vec![
("old".to_string(), session(10, &[("x", ski(0.5))])),
("new".to_string(), session(99, &[("y", ski(0.5))])),
];
let r = summarize(sessions, 10);
assert_eq!(r.sessions[0].id, "new");
assert_eq!(r.sessions[1].id, "old");
}
#[test]
fn skills_sorted_assist_then_surfaced_then_self_load() {
let s = session(
1,
&[
("selfload", model(0.0)),
("surfaced", ski(0.9)),
("assist", model(0.5)),
],
);
let r = summarize(vec![("s".to_string(), s)], 10);
let ids: Vec<&str> = r.sessions[0].skills.iter().map(|k| k.id.as_str()).collect();
assert_eq!(ids, ["assist", "surfaced", "selfload"]);
}
#[test]
fn empty_input_is_empty_report() {
assert_eq!(summarize(Vec::new(), 10), Report::default());
}
#[test]
fn ago_handles_zero_and_recent() {
assert_eq!(ago(0), "time unknown");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(ago(now), "just now");
assert_eq!(ago(now.saturating_sub(7200)), "2h ago");
}
}