1use crate::session::{Record, Session, Source};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum Kind {
29 Assist,
31 Surfaced,
33 SelfLoad,
35}
36
37#[derive(Clone, Debug, PartialEq)]
39pub struct SkillRow {
40 pub id: String,
41 pub kind: Kind,
42 pub confidence: f32,
44}
45
46#[derive(Clone, Debug, PartialEq)]
48pub struct SessionRow {
49 pub id: String,
50 pub updated: u64,
52 pub skills: Vec<SkillRow>,
53}
54
55#[derive(Clone, Debug, Default, PartialEq)]
58pub struct Report {
59 pub sessions: Vec<SessionRow>,
60 pub total_sessions: usize,
62 pub surfaced: u64,
64 pub assisted: u64,
66 pub self_loads: u64,
68}
69
70fn classify(r: &Record) -> Kind {
71 match r.source {
72 Source::Ski => Kind::Surfaced,
73 Source::Model if r.confidence > 0.0 => Kind::Assist,
76 Source::Model => Kind::SelfLoad,
77 }
78}
79
80fn kind_order(k: Kind) -> u8 {
83 match k {
84 Kind::Assist => 0,
85 Kind::Surfaced => 1,
86 Kind::SelfLoad => 2,
87 }
88}
89
90pub fn summarize(mut sessions: Vec<(String, Session)>, limit: usize) -> Report {
95 let mut report = Report {
96 total_sessions: sessions.len(),
97 ..Report::default()
98 };
99 for (_, s) in &sessions {
101 for r in s.loaded.values() {
102 match classify(r) {
103 Kind::Assist => {
104 report.surfaced += 1;
105 report.assisted += 1;
106 }
107 Kind::Surfaced => report.surfaced += 1,
108 Kind::SelfLoad => report.self_loads += 1,
109 }
110 }
111 }
112
113 sessions.sort_by(|a, b| b.1.updated.cmp(&a.1.updated).then(a.0.cmp(&b.0)));
115 sessions.truncate(limit);
116
117 for (id, s) in sessions {
118 let mut skills: Vec<SkillRow> = s
119 .loaded
120 .iter()
121 .map(|(sid, r)| SkillRow {
122 id: sid.clone(),
123 kind: classify(r),
124 confidence: r.confidence,
125 })
126 .collect();
127 skills.sort_by(|a, b| {
130 kind_order(a.kind)
131 .cmp(&kind_order(b.kind))
132 .then(
133 b.confidence
134 .partial_cmp(&a.confidence)
135 .unwrap_or(std::cmp::Ordering::Equal),
136 )
137 .then(a.id.cmp(&b.id))
138 });
139 report.sessions.push(SessionRow {
140 id,
141 updated: s.updated,
142 skills,
143 });
144 }
145 report
146}
147
148pub fn run(limit: usize) -> anyhow::Result<()> {
151 let dir = crate::paths::sessions_dir();
152 let mut sessions: Vec<(String, Session)> = Vec::new();
153 if let Ok(entries) = std::fs::read_dir(&dir) {
154 for entry in entries.flatten() {
155 let path = entry.path();
156 if path.extension().and_then(|e| e.to_str()) != Some("json") {
157 continue;
158 }
159 let session = Session::load(&path);
162 if session.loaded.is_empty() {
163 continue;
164 }
165 let id = path
166 .file_stem()
167 .and_then(|s| s.to_str())
168 .unwrap_or("?")
169 .to_string();
170 sessions.push((id, session));
171 }
172 }
173
174 let report = summarize(sessions, limit);
175 print_report(&report, limit, &dir);
176 Ok(())
177}
178
179fn print_report(report: &Report, limit: usize, dir: &std::path::Path) {
180 if report.total_sessions == 0 {
181 println!(
182 "no conversations on record yet — ski logs activity per session as you \
183 use it.\ncheck back after a few prompts (state dir: {})",
184 tilde(dir)
185 );
186 return;
187 }
188
189 let convo = if report.total_sessions == 1 {
190 "conversation"
191 } else {
192 "conversations"
193 };
194 println!(
195 "ski activity — {} {} on record ({})\n",
196 report.total_sessions,
197 convo,
198 tilde(dir)
199 );
200
201 println!(
204 " {:>4} skills ski surfaced that the model then invoked (assists)",
205 report.assisted
206 );
207 println!(
208 " {:>4} skills ski surfaced the model didn't invoke",
209 report.surfaced.saturating_sub(report.assisted)
210 );
211 println!(
212 " {:>4} skills the model found itself, ski stayed silent (recall misses)",
213 report.self_loads
214 );
215
216 if report.sessions.is_empty() {
217 return;
218 }
219
220 let shown = report.sessions.len();
221 let more = report.total_sessions.saturating_sub(shown);
222 println!("\n recent conversations (newest first):");
223 for s in &report.sessions {
224 println!("\n {} {}", s.id, ago(s.updated));
225 for sk in &s.skills {
226 let (tag, note) = match sk.kind {
227 Kind::Assist => (
228 "used",
229 format!("surfaced at {:.2}, model invoked it", sk.confidence),
230 ),
231 Kind::Surfaced => (
232 "sent",
233 format!("surfaced at {:.2}, not invoked", sk.confidence),
234 ),
235 Kind::SelfLoad => ("miss", "model loaded it, ski was silent".to_string()),
236 };
237 println!(" {tag} {:<26} {note}", sk.id);
238 }
239 }
240 if more > 0 {
241 let hint = if limit == usize::MAX {
242 String::new()
243 } else {
244 format!(
245 " (raise --limit, or --limit {} for all)",
246 report.total_sessions
247 )
248 };
249 println!(
250 "\n … and {} older conversation{}{}",
251 more,
252 if more == 1 { "" } else { "s" },
253 hint
254 );
255 }
256 println!(
257 "\n legend: used = ski assist · sent = surfaced, unused · miss = self-load\n \
258 for prompt-level detail, enable telemetry then see `ski history` / `ski suggest`."
259 );
260}
261
262fn ago(updated: u64) -> String {
266 let now = SystemTime::now()
267 .duration_since(UNIX_EPOCH)
268 .map(|d| d.as_secs())
269 .unwrap_or(0);
270 let secs = now.saturating_sub(updated);
271 if updated == 0 {
272 "time unknown".to_string()
273 } else if secs < 90 {
274 "just now".to_string()
275 } else if secs < 3600 {
276 format!("{}m ago", secs / 60)
277 } else if secs < 86_400 {
278 format!("{}h ago", secs / 3600)
279 } else {
280 format!("{}d ago", secs / 86_400)
281 }
282}
283
284fn tilde(path: &std::path::Path) -> String {
286 if let Some(home) = std::env::var_os("HOME") {
287 if let Ok(rest) = path.strip_prefix(&home) {
288 return format!("~/{}", rest.display());
289 }
290 }
291 path.display().to_string()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn ski(conf: f32) -> Record {
299 Record {
300 source: Source::Ski,
301 confidence: conf,
302 }
303 }
304 fn model(conf: f32) -> Record {
305 Record {
306 source: Source::Model,
307 confidence: conf,
308 }
309 }
310
311 fn session(updated: u64, loaded: &[(&str, Record)]) -> Session {
312 Session {
313 loaded: loaded.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
314 updated,
315 ..Session::default()
316 }
317 }
318
319 #[test]
320 fn classify_distinguishes_assist_from_self_load() {
321 assert_eq!(classify(&ski(0.7)), Kind::Surfaced);
323 assert_eq!(classify(&model(0.7)), Kind::Assist);
325 assert_eq!(classify(&model(0.0)), Kind::SelfLoad);
327 }
328
329 #[test]
330 fn summarize_counts_across_all_sessions() {
331 let sessions = vec![
332 (
333 "s1".to_string(),
334 session(100, &[("xlsx", model(0.8)), ("pdf", ski(0.6))]),
335 ),
336 (
337 "s2".to_string(),
338 session(200, &[("git-attribution", model(0.0))]),
339 ),
340 ];
341 let r = summarize(sessions, 10);
342 assert_eq!(r.total_sessions, 2);
343 assert_eq!(r.assisted, 1); assert_eq!(r.surfaced, 2); assert_eq!(r.self_loads, 1); }
347
348 #[test]
349 fn aggregate_spans_all_sessions_even_when_display_is_limited() {
350 let sessions = vec![
351 ("a".to_string(), session(1, &[("one", model(0.9))])),
352 ("b".to_string(), session(2, &[("two", model(0.9))])),
353 ("c".to_string(), session(3, &[("three", model(0.9))])),
354 ];
355 let r = summarize(sessions, 1);
356 assert_eq!(r.sessions.len(), 1);
358 assert_eq!(r.sessions[0].id, "c");
359 assert_eq!(r.assisted, 3);
361 assert_eq!(r.total_sessions, 3);
362 }
363
364 #[test]
365 fn sessions_are_newest_first() {
366 let sessions = vec![
367 ("old".to_string(), session(10, &[("x", ski(0.5))])),
368 ("new".to_string(), session(99, &[("y", ski(0.5))])),
369 ];
370 let r = summarize(sessions, 10);
371 assert_eq!(r.sessions[0].id, "new");
372 assert_eq!(r.sessions[1].id, "old");
373 }
374
375 #[test]
376 fn skills_sorted_assist_then_surfaced_then_self_load() {
377 let s = session(
378 1,
379 &[
380 ("selfload", model(0.0)),
381 ("surfaced", ski(0.9)),
382 ("assist", model(0.5)),
383 ],
384 );
385 let r = summarize(vec![("s".to_string(), s)], 10);
386 let ids: Vec<&str> = r.sessions[0].skills.iter().map(|k| k.id.as_str()).collect();
387 assert_eq!(ids, ["assist", "surfaced", "selfload"]);
388 }
389
390 #[test]
391 fn empty_input_is_empty_report() {
392 assert_eq!(summarize(Vec::new(), 10), Report::default());
393 }
394
395 #[test]
396 fn ago_handles_zero_and_recent() {
397 assert_eq!(ago(0), "time unknown");
398 let now = SystemTime::now()
399 .duration_since(UNIX_EPOCH)
400 .unwrap()
401 .as_secs();
402 assert_eq!(ago(now), "just now");
403 assert_eq!(ago(now.saturating_sub(7200)), "2h ago");
404 }
405}