Skip to main content

ski/
status.rs

1//! `ski status` — a plain-language readout of what ski actually did in your
2//! recent conversations. ski's hot path is deliberately silent (it never prints
3//! into your session), so without this the only "is it helping me?" answers are
4//! `ski doctor` ("is it wired?") and `ski history` ("what did telemetry log?" —
5//! empty unless you opted in). This fills the gap in between: it reads the
6//! per-session dedup ledgers ([`crate::session`]) that ski writes on **every**
7//! prompt regardless of telemetry, and turns them into three counts a user
8//! cares about —
9//!
10//! - **assists**: ski surfaced a skill and the model then invoked it (the win);
11//! - **surfaced, unused**: ski put a skill forward the model didn't reach for;
12//! - **self-loads**: the model found a skill on its own while ski stayed silent
13//!   (a recall miss — where ski could do better).
14//!
15//! The classification is a heuristic over the ledger's `(source, confidence)`
16//! record: a `Model` load carries a non-zero confidence only if ski had
17//! recommended it first (see [`crate::session::Session::mark_used`]), so
18//! `Model` + confidence 0 is a genuine self-load, and `Model` + confidence > 0 is
19//! an assist. [`summarize`] is pure and unit-tested; only [`run`] touches disk,
20//! and (like every read path in ski) it fails open — an unreadable or malformed
21//! ledger is skipped, never fatal.
22
23use crate::session::{Record, Session, Source};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26/// How ski's ledger explains one skill's presence in a conversation.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum Kind {
29    /// ski surfaced it and the model then invoked it — the outcome ski exists for.
30    Assist,
31    /// ski surfaced it; the model hasn't invoked it (ignored, or simply not needed).
32    Surfaced,
33    /// The model loaded it on its own while ski stayed silent — a recall miss.
34    SelfLoad,
35}
36
37/// One skill's line in a session summary.
38#[derive(Clone, Debug, PartialEq)]
39pub struct SkillRow {
40    pub id: String,
41    pub kind: Kind,
42    /// The confidence ski last showed for it (0.0 for a pure self-load).
43    pub confidence: f32,
44}
45
46/// One conversation's summary, newest-first in the [`Report`].
47#[derive(Clone, Debug, PartialEq)]
48pub struct SessionRow {
49    pub id: String,
50    /// Unix seconds of the ledger's last write (from [`Session::updated`]).
51    pub updated: u64,
52    pub skills: Vec<SkillRow>,
53}
54
55/// The whole readout: recency-ordered per-session rows plus aggregate counts
56/// taken over *every* session on record (not just the displayed ones).
57#[derive(Clone, Debug, Default, PartialEq)]
58pub struct Report {
59    pub sessions: Vec<SessionRow>,
60    /// Total conversations with a ledger on disk.
61    pub total_sessions: usize,
62    /// Skills ski surfaced (assists + surfaced-unused), across all sessions.
63    pub surfaced: u64,
64    /// Of those, the ones the model then invoked.
65    pub assisted: u64,
66    /// Skills the model loaded itself while ski stayed silent.
67    pub self_loads: u64,
68}
69
70fn classify(r: &Record) -> Kind {
71    match r.source {
72        Source::Ski => Kind::Surfaced,
73        // A model load keeps any confidence ski had shown; a non-zero value means
74        // ski recommended it first (an assist), zero means a genuine self-load.
75        Source::Model if r.confidence > 0.0 => Kind::Assist,
76        Source::Model => Kind::SelfLoad,
77    }
78}
79
80/// Rank kinds for display: assists first (the wins), then surfaced-unused, then
81/// self-loads.
82fn kind_order(k: Kind) -> u8 {
83    match k {
84        Kind::Assist => 0,
85        Kind::Surfaced => 1,
86        Kind::SelfLoad => 2,
87    }
88}
89
90/// Turn loaded ledgers into a [`Report`]. Pure — the caller supplies
91/// `(session_id, Session)` pairs (from disk in [`run`], hand-built in tests).
92/// Sessions are ordered newest-first by `updated`; `limit` caps how many appear
93/// in `sessions`, but the aggregate counts span all of them.
94pub 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    // Aggregate over every session before truncating the displayed set.
100    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    // Newest first; ties broken by id so the order is deterministic.
114    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        // Assists, then surfaced, then self-loads; within a kind, higher
128        // confidence first, then id for stability.
129        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
148/// `ski status`: scan the session ledgers and print the readout. `limit` caps
149/// the number of conversations shown (aggregate counts still span all).
150pub 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            // Skip a session with an empty ledger (e.g. one re-armed on compaction):
160            // it has nothing to report and would just add noise.
161            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    // Aggregate headline. Lead with the win (assists), then the two ways ski and
202    // the model can diverge.
203    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
262/// Coarse "time since" for a Unix-seconds stamp. Diagnostics only, so an
263/// out-of-range or clock-skewed value degrades to "just now" rather than
264/// underflowing.
265fn 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
284/// Shorten a path under `$HOME` to `~/…` for display (mirrors `doctor::tilde`).
285fn 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        // Ski recommendation the model hasn't used -> surfaced.
322        assert_eq!(classify(&ski(0.7)), Kind::Surfaced);
323        // Model load carrying a prior recommendation's confidence -> assist.
324        assert_eq!(classify(&model(0.7)), Kind::Assist);
325        // Model load with no prior recommendation -> genuine self-load.
326        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); // xlsx
344        assert_eq!(r.surfaced, 2); // xlsx (assist) + pdf (surfaced)
345        assert_eq!(r.self_loads, 1); // git-attribution
346    }
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        // Only the newest session is displayed...
357        assert_eq!(r.sessions.len(), 1);
358        assert_eq!(r.sessions[0].id, "c");
359        // ...but the counts still cover all three.
360        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}