Skip to main content

lean_ctx/core/
stats.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Serialize, Deserialize, Default)]
6pub struct StatsStore {
7    pub total_commands: u64,
8    pub total_input_tokens: u64,
9    pub total_output_tokens: u64,
10    pub first_use: Option<String>,
11    pub last_use: Option<String>,
12    pub commands: HashMap<String, CommandStats>,
13    pub daily: Vec<DayStats>,
14    #[serde(default)]
15    pub cep: CepStats,
16}
17
18#[derive(Serialize, Deserialize, Clone, Default)]
19pub struct CepStats {
20    pub sessions: u64,
21    pub total_cache_hits: u64,
22    pub total_cache_reads: u64,
23    pub total_tokens_original: u64,
24    pub total_tokens_compressed: u64,
25    pub modes: HashMap<String, u64>,
26    pub scores: Vec<CepSessionSnapshot>,
27}
28
29#[derive(Serialize, Deserialize, Clone)]
30pub struct CepSessionSnapshot {
31    pub timestamp: String,
32    pub score: u32,
33    pub cache_hit_rate: u32,
34    pub mode_diversity: u32,
35    pub compression_rate: u32,
36    pub tool_calls: u64,
37    pub tokens_saved: u64,
38    pub complexity: String,
39}
40
41#[derive(Serialize, Deserialize, Clone, Default)]
42pub struct CommandStats {
43    pub count: u64,
44    pub input_tokens: u64,
45    pub output_tokens: u64,
46}
47
48#[derive(Serialize, Deserialize, Clone)]
49pub struct DayStats {
50    pub date: String,
51    pub commands: u64,
52    pub input_tokens: u64,
53    pub output_tokens: u64,
54}
55
56fn stats_dir() -> Option<PathBuf> {
57    dirs::home_dir().map(|h| h.join(".lean-ctx"))
58}
59
60fn stats_path() -> Option<PathBuf> {
61    stats_dir().map(|d| d.join("stats.json"))
62}
63
64pub fn load() -> StatsStore {
65    let path = match stats_path() {
66        Some(p) => p,
67        None => return StatsStore::default(),
68    };
69
70    match std::fs::read_to_string(&path) {
71        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
72        Err(_) => StatsStore::default(),
73    }
74}
75
76pub fn save(store: &StatsStore) {
77    let dir = match stats_dir() {
78        Some(d) => d,
79        None => return,
80    };
81
82    if !dir.exists() {
83        let _ = std::fs::create_dir_all(&dir);
84    }
85
86    let path = dir.join("stats.json");
87    if let Ok(json) = serde_json::to_string(store) {
88        let tmp = dir.join(".stats.json.tmp");
89        if std::fs::write(&tmp, &json).is_ok() {
90            let _ = std::fs::rename(&tmp, &path);
91        }
92    }
93}
94
95pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
96    let mut store = load();
97    let now = chrono::Local::now();
98    let today = now.format("%Y-%m-%d").to_string();
99    let timestamp = now.to_rfc3339();
100
101    store.total_commands += 1;
102    store.total_input_tokens += input_tokens as u64;
103    store.total_output_tokens += output_tokens as u64;
104
105    if store.first_use.is_none() {
106        store.first_use = Some(timestamp.clone());
107    }
108    store.last_use = Some(timestamp);
109
110    let cmd_key = normalize_command(command);
111    let entry = store.commands.entry(cmd_key).or_default();
112    entry.count += 1;
113    entry.input_tokens += input_tokens as u64;
114    entry.output_tokens += output_tokens as u64;
115
116    if let Some(day) = store.daily.last_mut() {
117        if day.date == today {
118            day.commands += 1;
119            day.input_tokens += input_tokens as u64;
120            day.output_tokens += output_tokens as u64;
121        } else {
122            store.daily.push(DayStats {
123                date: today,
124                commands: 1,
125                input_tokens: input_tokens as u64,
126                output_tokens: output_tokens as u64,
127            });
128        }
129    } else {
130        store.daily.push(DayStats {
131            date: today,
132            commands: 1,
133            input_tokens: input_tokens as u64,
134            output_tokens: output_tokens as u64,
135        });
136    }
137
138    if store.daily.len() > 90 {
139        store.daily.drain(..store.daily.len() - 90);
140    }
141
142    save(&store);
143}
144
145fn normalize_command(command: &str) -> String {
146    let parts: Vec<&str> = command.split_whitespace().collect();
147    if parts.is_empty() {
148        return command.to_string();
149    }
150
151    let base = std::path::Path::new(parts[0])
152        .file_name()
153        .and_then(|n| n.to_str())
154        .unwrap_or(parts[0]);
155
156    match base {
157        "git" => {
158            if parts.len() > 1 {
159                format!("git {}", parts[1])
160            } else {
161                "git".to_string()
162            }
163        }
164        "cargo" => {
165            if parts.len() > 1 {
166                format!("cargo {}", parts[1])
167            } else {
168                "cargo".to_string()
169            }
170        }
171        "npm" | "yarn" | "pnpm" => {
172            if parts.len() > 1 {
173                format!("{} {}", base, parts[1])
174            } else {
175                base.to_string()
176            }
177        }
178        "docker" => {
179            if parts.len() > 1 {
180                format!("docker {}", parts[1])
181            } else {
182                "docker".to_string()
183            }
184        }
185        _ => base.to_string(),
186    }
187}
188
189pub struct GainSummary {
190    pub total_saved: u64,
191    pub total_calls: u64,
192}
193
194pub fn load_stats() -> GainSummary {
195    let store = load();
196    let cm = CostModel::default();
197    let input_saved = store
198        .total_input_tokens
199        .saturating_sub(store.total_output_tokens);
200    let output_saved =
201        store.total_commands * (cm.avg_verbose_output_per_call - cm.avg_concise_output_per_call);
202    GainSummary {
203        total_saved: input_saved + output_saved,
204        total_calls: store.total_commands,
205    }
206}
207
208fn cmd_total_saved(s: &CommandStats, cm: &CostModel) -> u64 {
209    let input_saved = s.input_tokens.saturating_sub(s.output_tokens);
210    let output_saved = s.count * (cm.avg_verbose_output_per_call - cm.avg_concise_output_per_call);
211    input_saved + output_saved
212}
213
214fn day_total_saved(d: &DayStats, cm: &CostModel) -> u64 {
215    let input_saved = d.input_tokens.saturating_sub(d.output_tokens);
216    let output_saved =
217        d.commands * (cm.avg_verbose_output_per_call - cm.avg_concise_output_per_call);
218    input_saved + output_saved
219}
220
221#[allow(clippy::too_many_arguments)]
222pub fn record_cep_session(
223    score: u32,
224    cache_hits: u64,
225    cache_reads: u64,
226    tokens_original: u64,
227    tokens_compressed: u64,
228    modes: &HashMap<String, u64>,
229    tool_calls: u64,
230    complexity: &str,
231) {
232    let mut store = load();
233    let cep = &mut store.cep;
234
235    cep.sessions += 1;
236    cep.total_cache_hits += cache_hits;
237    cep.total_cache_reads += cache_reads;
238    cep.total_tokens_original += tokens_original;
239    cep.total_tokens_compressed += tokens_compressed;
240
241    for (mode, count) in modes {
242        *cep.modes.entry(mode.clone()).or_insert(0) += count;
243    }
244
245    let cache_hit_rate = if cache_reads > 0 {
246        (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
247    } else {
248        0
249    };
250
251    let compression_rate = if tokens_original > 0 {
252        ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
253            as u32
254    } else {
255        0
256    };
257
258    let total_modes = 6u32;
259    let mode_diversity =
260        ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
261
262    let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
263
264    cep.scores.push(CepSessionSnapshot {
265        timestamp: chrono::Local::now().to_rfc3339(),
266        score,
267        cache_hit_rate,
268        mode_diversity,
269        compression_rate,
270        tool_calls,
271        tokens_saved,
272        complexity: complexity.to_string(),
273    });
274
275    if cep.scores.len() > 100 {
276        cep.scores.drain(..cep.scores.len() - 100);
277    }
278
279    save(&store);
280}
281
282const RST: &str = "\x1b[0m";
283const BOLD: &str = "\x1b[1m";
284const DIM: &str = "\x1b[2m";
285const GREEN: &str = "\x1b[32m";
286const CYAN: &str = "\x1b[36m";
287const YELLOW: &str = "\x1b[33m";
288const MAGENTA: &str = "\x1b[35m";
289const WHITE: &str = "\x1b[97m";
290const GRAY: &str = "\x1b[90m";
291fn line(ch: char, n: usize) -> String {
292    std::iter::repeat_n(ch, n).collect()
293}
294
295fn pct_color(pct: f64) -> &'static str {
296    if pct >= 90.0 {
297        "\x1b[32m"
298    } else if pct >= 70.0 {
299        "\x1b[36m"
300    } else if pct >= 50.0 {
301        "\x1b[33m"
302    } else if pct >= 30.0 {
303        "\x1b[35m"
304    } else {
305        "\x1b[37m"
306    }
307}
308
309fn bar_block(ratio: f64, width: usize) -> String {
310    let blocks = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
311    let full = (ratio * width as f64).max(0.0);
312    let whole = full as usize;
313    let frac = ((full - whole as f64) * 8.0) as usize;
314    let mut s = "█".repeat(whole);
315    if whole < width && frac > 0 {
316        s.push_str(blocks[frac]);
317    }
318    if s.is_empty() && ratio > 0.0 {
319        s.push('▏');
320    }
321    s
322}
323
324fn sparkline(values: &[u64]) -> String {
325    let ticks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
326    let max = *values.iter().max().unwrap_or(&1) as f64;
327    if max == 0.0 {
328        return " ".repeat(values.len());
329    }
330    values
331        .iter()
332        .map(|v| {
333            let idx = ((*v as f64 / max) * 7.0).round() as usize;
334            ticks[idx.min(7)]
335        })
336        .collect()
337}
338
339/// Average LLM pricing per 1M tokens (blended across Claude, GPT, Gemini).
340pub const DEFAULT_INPUT_PRICE_PER_M: f64 = 2.50;
341pub const DEFAULT_OUTPUT_PRICE_PER_M: f64 = 10.0;
342
343pub struct CostModel {
344    pub input_price_per_m: f64,
345    pub output_price_per_m: f64,
346    pub avg_verbose_output_per_call: u64,
347    pub avg_concise_output_per_call: u64,
348}
349
350impl Default for CostModel {
351    fn default() -> Self {
352        Self {
353            input_price_per_m: DEFAULT_INPUT_PRICE_PER_M,
354            output_price_per_m: DEFAULT_OUTPUT_PRICE_PER_M,
355            avg_verbose_output_per_call: 450,
356            avg_concise_output_per_call: 120,
357        }
358    }
359}
360
361pub struct CostBreakdown {
362    pub input_cost_without: f64,
363    pub input_cost_with: f64,
364    pub output_cost_without: f64,
365    pub output_cost_with: f64,
366    pub total_cost_without: f64,
367    pub total_cost_with: f64,
368    pub total_saved: f64,
369    pub estimated_output_tokens_without: u64,
370    pub estimated_output_tokens_with: u64,
371    pub output_tokens_saved: u64,
372}
373
374impl CostModel {
375    pub fn calculate(&self, store: &StatsStore) -> CostBreakdown {
376        let input_cost_without =
377            store.total_input_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
378        let input_cost_with =
379            store.total_output_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
380
381        let est_output_without = store.total_commands * self.avg_verbose_output_per_call;
382        let est_output_with = store.total_commands * self.avg_concise_output_per_call;
383        let output_saved = est_output_without.saturating_sub(est_output_with);
384
385        let output_cost_without = est_output_without as f64 / 1_000_000.0 * self.output_price_per_m;
386        let output_cost_with = est_output_with as f64 / 1_000_000.0 * self.output_price_per_m;
387
388        let total_without = input_cost_without + output_cost_without;
389        let total_with = input_cost_with + output_cost_with;
390
391        CostBreakdown {
392            input_cost_without,
393            input_cost_with,
394            output_cost_without,
395            output_cost_with,
396            total_cost_without: total_without,
397            total_cost_with: total_with,
398            total_saved: total_without - total_with,
399            estimated_output_tokens_without: est_output_without,
400            estimated_output_tokens_with: est_output_with,
401            output_tokens_saved: output_saved,
402        }
403    }
404}
405
406fn format_usd(amount: f64) -> String {
407    if amount >= 0.01 {
408        format!("${amount:.2}")
409    } else {
410        format!("${amount:.3}")
411    }
412}
413
414fn usd_estimate(tokens: u64) -> String {
415    let cost = tokens as f64 * DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
416    format_usd(cost)
417}
418
419fn format_big(n: u64) -> String {
420    if n >= 1_000_000 {
421        format!("{:.1}M", n as f64 / 1_000_000.0)
422    } else if n >= 1_000 {
423        format!("{:.1}K", n as f64 / 1_000.0)
424    } else {
425        format!("{n}")
426    }
427}
428
429fn format_num(n: u64) -> String {
430    if n >= 1_000_000 {
431        format!("{:.1}M", n as f64 / 1_000_000.0)
432    } else if n >= 1_000 {
433        format!("{},{:03}", n / 1_000, n % 1_000)
434    } else {
435        format!("{n}")
436    }
437}
438
439fn truncate_cmd(cmd: &str, max: usize) -> String {
440    if cmd.len() <= max {
441        cmd.to_string()
442    } else {
443        format!("{}…", &cmd[..max - 1])
444    }
445}
446
447fn format_cep_live(lv: &serde_json::Value) -> String {
448    let mut o = Vec::new();
449    let ln56 = line('─', 56);
450
451    let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
452    let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
453    let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
454    let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
455    let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
456    let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
457    let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
458    let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
459    let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
460    let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
461
462    o.push(String::new());
463    o.push(format!(
464        "  {BOLD}{WHITE}◆ lean-ctx CEP{RST}  {DIM}Live Session (no historical data yet){RST}"
465    ));
466    o.push(format!("  {DIM}{ln56}{RST}"));
467    o.push(String::new());
468
469    o.push(format!(
470        "  {BOLD}{WHITE}CEP Score{RST}         {BOLD}{}{score:>3}/100{RST}",
471        pct_color(score as f64),
472    ));
473    o.push(format!(
474        "  {BOLD}{WHITE}Cache Hit Rate{RST}    {BOLD}{}{cache_util}%{RST}  {DIM}({cache_hits} hits / {total_reads} reads){RST}",
475        pct_color(cache_util as f64),
476    ));
477    o.push(format!(
478        "  {BOLD}{WHITE}Mode Diversity{RST}    {BOLD}{}{mode_div}%{RST}",
479        pct_color(mode_div as f64),
480    ));
481    o.push(format!(
482        "  {BOLD}{WHITE}Compression{RST}       {BOLD}{}{comp_rate}%{RST}  {DIM}({} → {}){RST}",
483        pct_color(comp_rate as f64),
484        format_big(tok_orig),
485        format_big(tok_orig.saturating_sub(tok_saved)),
486    ));
487    o.push(format!(
488        "  {BOLD}{WHITE}Tokens Saved{RST}      {BOLD}{GREEN}{}{RST}  {DIM}(≈ {}){RST}",
489        format_big(tok_saved),
490        usd_estimate(tok_saved),
491    ));
492    o.push(format!(
493        "  {BOLD}{WHITE}Tool Calls{RST}        {BOLD}{CYAN}{tool_calls}{RST}"
494    ));
495    o.push(format!(
496        "  {BOLD}{WHITE}Complexity{RST}        {DIM}{complexity}{RST}"
497    ));
498    o.push(String::new());
499    o.push(format!("  {DIM}{ln56}{RST}"));
500    o.push(format!(
501        "  {DIM}This is live data from the current MCP session.{RST}"
502    ));
503    o.push(format!(
504        "  {DIM}Historical CEP trends appear after more sessions.{RST}"
505    ));
506    o.push(String::new());
507
508    o.join("\n")
509}
510
511fn load_mcp_live() -> Option<serde_json::Value> {
512    let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
513    let content = std::fs::read_to_string(path).ok()?;
514    serde_json::from_str(&content).ok()
515}
516
517pub fn format_cep_report() -> String {
518    let store = load();
519    let cep = &store.cep;
520    let live = load_mcp_live();
521    let mut o = Vec::new();
522    let ln56 = line('─', 56);
523
524    if cep.sessions == 0 && live.is_none() {
525        return format!(
526            "{DIM}No CEP sessions recorded yet.{RST}\n\
527             Use lean-ctx as an MCP server in your editor to start tracking.\n\
528             CEP metrics are recorded automatically during MCP sessions."
529        );
530    }
531
532    if cep.sessions == 0 {
533        if let Some(ref lv) = live {
534            return format_cep_live(lv);
535        }
536    }
537
538    let total_saved = cep
539        .total_tokens_original
540        .saturating_sub(cep.total_tokens_compressed);
541    let overall_compression = if cep.total_tokens_original > 0 {
542        total_saved as f64 / cep.total_tokens_original as f64 * 100.0
543    } else {
544        0.0
545    };
546    let cache_hit_rate = if cep.total_cache_reads > 0 {
547        cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
548    } else {
549        0.0
550    };
551    let avg_score = if !cep.scores.is_empty() {
552        cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
553    } else {
554        0.0
555    };
556    let latest_score = cep.scores.last().map(|s| s.score).unwrap_or(0);
557
558    let shell_saved = store
559        .total_input_tokens
560        .saturating_sub(store.total_output_tokens)
561        .saturating_sub(total_saved);
562    let total_all_saved = store
563        .total_input_tokens
564        .saturating_sub(store.total_output_tokens);
565    let cep_share = if total_all_saved > 0 {
566        total_saved as f64 / total_all_saved as f64 * 100.0
567    } else {
568        0.0
569    };
570
571    o.push(String::new());
572    o.push(format!(
573        "  {BOLD}{WHITE}◆ lean-ctx CEP{RST}  {DIM}Cognitive Efficiency Protocol Report{RST}"
574    ));
575    o.push(format!("  {DIM}{ln56}{RST}"));
576    o.push(String::new());
577
578    o.push(format!(
579        "  {BOLD}{WHITE}CEP Score{RST}         {BOLD}{}{:>3}/100{RST}  {DIM}(avg: {avg_score:.0}, latest: {latest_score}){RST}",
580        pct_color(latest_score as f64),
581        latest_score,
582    ));
583    o.push(format!(
584        "  {BOLD}{WHITE}Sessions{RST}          {BOLD}{CYAN}{}{RST}",
585        cep.sessions
586    ));
587    o.push(format!(
588        "  {BOLD}{WHITE}Cache Hit Rate{RST}    {BOLD}{}{:.1}%{RST}  {DIM}({} hits / {} reads){RST}",
589        pct_color(cache_hit_rate),
590        cache_hit_rate,
591        cep.total_cache_hits,
592        cep.total_cache_reads,
593    ));
594    o.push(format!(
595        "  {BOLD}{WHITE}MCP Compression{RST}   {BOLD}{}{:.1}%{RST}  {DIM}({} → {}){RST}",
596        pct_color(overall_compression),
597        overall_compression,
598        format_big(cep.total_tokens_original),
599        format_big(cep.total_tokens_compressed),
600    ));
601    o.push(format!(
602        "  {BOLD}{WHITE}Tokens Saved{RST}      {BOLD}{GREEN}{}{RST}  {DIM}(≈ {}){RST}",
603        format_big(total_saved),
604        usd_estimate(total_saved),
605    ));
606    o.push(String::new());
607
608    o.push(format!("  {BOLD}{WHITE}Savings Breakdown{RST}"));
609    o.push(format!("  {DIM}{ln56}{RST}"));
610
611    let bar_w = 30;
612    let shell_ratio = if total_all_saved > 0 {
613        shell_saved as f64 / total_all_saved as f64
614    } else {
615        0.0
616    };
617    let cep_ratio = if total_all_saved > 0 {
618        total_saved as f64 / total_all_saved as f64
619    } else {
620        0.0
621    };
622    o.push(format!(
623        "  {GRAY}Shell Hook{RST}   {YELLOW}{:<width$}{RST} {BOLD}{:>6}{RST} {DIM}({:.0}%){RST}",
624        bar_block(shell_ratio, bar_w),
625        format_big(shell_saved),
626        (1.0 - cep_share) * 100.0 / 100.0 * 100.0,
627        width = bar_w,
628    ));
629    o.push(format!(
630        "  {GRAY}MCP/CEP{RST}      {GREEN}{:<width$}{RST} {BOLD}{:>6}{RST} {DIM}({cep_share:.0}%){RST}",
631        bar_block(cep_ratio, bar_w),
632        format_big(total_saved),
633        width = bar_w,
634    ));
635    o.push(String::new());
636
637    if total_saved == 0 && cep.modes.is_empty() {
638        o.push(format!(
639            "  {YELLOW}⚠  MCP server not configured.{RST} Shell hook compresses output, but"
640        ));
641        o.push(
642            "     full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
643                .to_string(),
644        );
645        o.push(format!(
646            "     Run {CYAN}lean-ctx setup{RST} to auto-configure your editors."
647        ));
648        o.push(String::new());
649    }
650
651    if !cep.modes.is_empty() {
652        o.push(format!("  {BOLD}{WHITE}Read Modes Used{RST}"));
653        o.push(format!("  {DIM}{ln56}{RST}"));
654
655        let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
656        sorted_modes.sort_by(|a, b| b.1.cmp(a.1));
657        let max_mode = *sorted_modes.first().map(|(_, c)| *c).unwrap_or(&1);
658        let max_mode = max_mode.max(1);
659
660        for (mode, count) in &sorted_modes {
661            let ratio = **count as f64 / max_mode as f64;
662            let bar = bar_block(ratio, 20);
663            o.push(format!(
664                "  {CYAN}{:<14}{RST} {:>4}x  {GREEN}{bar:<20}{RST}",
665                mode, count,
666            ));
667        }
668
669        let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
670        let full_count = cep.modes.get("full").copied().unwrap_or(0);
671        let optimized = total_mode_calls.saturating_sub(full_count);
672        let opt_pct = if total_mode_calls > 0 {
673            optimized as f64 / total_mode_calls as f64 * 100.0
674        } else {
675            0.0
676        };
677        o.push(format!(
678            "  {DIM}{optimized}/{total_mode_calls} reads used optimized modes ({opt_pct:.0}% non-full){RST}"
679        ));
680    }
681
682    if cep.scores.len() >= 2 {
683        o.push(String::new());
684        o.push(format!("  {BOLD}{WHITE}CEP Score Trend{RST}"));
685        o.push(format!("  {DIM}{ln56}{RST}"));
686
687        let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
688        let spark = sparkline(&score_values);
689        o.push(format!("  {GREEN}{spark}{RST}"));
690
691        let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
692        for snap in recent.iter().rev() {
693            let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
694            let pc = pct_color(snap.score as f64);
695            o.push(format!(
696                "  {GRAY}{ts}{RST}  {pc}{BOLD}{:>3}{RST}/100  cache:{:>3}%  modes:{:>3}%  {DIM}{}{RST}",
697                snap.score, snap.cache_hit_rate, snap.mode_diversity, snap.complexity,
698            ));
699        }
700    }
701
702    o.push(String::new());
703    o.push(format!("  {DIM}{ln56}{RST}"));
704    o.push(format!("  {DIM}Improve your CEP score:{RST}"));
705    if cache_hit_rate < 50.0 {
706        o.push(format!(
707            "    {YELLOW}↑{RST} Re-read files with ctx_read to leverage caching"
708        ));
709    }
710    let modes_count = cep.modes.len();
711    if modes_count < 3 {
712        o.push(format!(
713            "    {YELLOW}↑{RST} Use map/signatures modes for context-only files"
714        ));
715    }
716    if avg_score >= 70.0 {
717        o.push(format!(
718            "    {GREEN}✓{RST} Great score! You're using lean-ctx effectively"
719        ));
720    }
721    o.push(String::new());
722
723    o.join("\n")
724}
725
726pub fn format_gain() -> String {
727    let store = load();
728    let mut o = Vec::new();
729
730    if store.total_commands == 0 {
731        return format!("{DIM}No commands recorded yet.{RST} Use {CYAN}lean-ctx -c \"command\"{RST} to start tracking.");
732    }
733
734    let input_saved = store
735        .total_input_tokens
736        .saturating_sub(store.total_output_tokens);
737    let pct = if store.total_input_tokens > 0 {
738        input_saved as f64 / store.total_input_tokens as f64 * 100.0
739    } else {
740        0.0
741    };
742    let cost_model = CostModel::default();
743    let cost = cost_model.calculate(&store);
744    let total_saved = input_saved + cost.output_tokens_saved;
745    let days_active = store.daily.len();
746
747    o.push(String::new());
748    let ln56 = line('─', 56);
749    o.push(format!(
750        "  {BOLD}{WHITE}◆ lean-ctx{RST}  {DIM}Token Savings Dashboard{RST}"
751    ));
752    o.push(format!("  {DIM}{ln56}{RST}"));
753    o.push(String::new());
754
755    o.push(format!(
756        "  {BOLD}{GREEN} {:<12}{RST}  {BOLD}{CYAN} {:<12}{RST}  {BOLD}{YELLOW} {:<10}{RST}  {BOLD}{MAGENTA} {:<10}{RST}",
757        format_big(total_saved),
758        format!("{pct:.1}%"),
759        format_num(store.total_commands),
760        format_usd(cost.total_saved),
761    ));
762    o.push(format!(
763        "  {DIM} tokens saved   compression    commands       USD saved{RST}"
764    ));
765    o.push(String::new());
766
767    o.push(format!(
768        "  {BOLD}{WHITE}Cost Breakdown{RST}  {DIM}(@ ${}/M input, ${}/M output){RST}",
769        DEFAULT_INPUT_PRICE_PER_M, DEFAULT_OUTPUT_PRICE_PER_M
770    ));
771    o.push(format!("  {DIM}{ln56}{RST}"));
772    o.push(format!(
773        "  {GRAY}Without lean-ctx{RST}  {:>8}  {DIM}({} input + {} output){RST}",
774        format_usd(cost.total_cost_without),
775        format_usd(cost.input_cost_without),
776        format_usd(cost.output_cost_without),
777    ));
778    o.push(format!(
779        "  {GRAY}With lean-ctx{RST}     {:>8}  {DIM}({} input + {} output){RST}",
780        format_usd(cost.total_cost_with),
781        format_usd(cost.input_cost_with),
782        format_usd(cost.output_cost_with),
783    ));
784    o.push(format!(
785        "  {GREEN}{BOLD}Total Saved{RST}       {GREEN}{BOLD}{:>8}{RST}  {DIM}(input: {} + output: {}){RST}",
786        format_usd(cost.total_saved),
787        format_usd(cost.input_cost_without - cost.input_cost_with),
788        format_usd(cost.output_cost_without - cost.output_cost_with),
789    ));
790    o.push(format!(
791        "  {DIM}Output savings: ~{} tokens saved via CEP/TDD ({} → {} per call){RST}",
792        format_big(cost.output_tokens_saved),
793        CostModel::default().avg_verbose_output_per_call,
794        CostModel::default().avg_concise_output_per_call,
795    ));
796    o.push(String::new());
797
798    o.push(format!(
799        "  {DIM}{} input tokens compressed · ~{} output tokens reduced via CEP/TDD{RST}",
800        format_num(input_saved),
801        format_big(cost.output_tokens_saved),
802    ));
803
804    if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
805        let first_short = first.get(..10).unwrap_or(first);
806        let daily_savings: Vec<u64> = store
807            .daily
808            .iter()
809            .map(|d| day_total_saved(d, &cost_model))
810            .collect();
811        let spark = sparkline(&daily_savings);
812        o.push(format!(
813            "  {DIM}Since {first_short} ({days_active} day{plural}){RST}  {GREEN}{spark}{RST}",
814            plural = if days_active != 1 { "s" } else { "" }
815        ));
816        o.push(String::new());
817    }
818
819    if !store.commands.is_empty() {
820        o.push(format!("  {BOLD}{WHITE}Top Commands{RST}"));
821        o.push(format!("  {DIM}{ln56}{RST}"));
822
823        let mut sorted: Vec<_> = store.commands.iter().collect();
824        sorted.sort_by(|a, b| {
825            let sa = cmd_total_saved(a.1, &cost_model);
826            let sb = cmd_total_saved(b.1, &cost_model);
827            sb.cmp(&sa)
828        });
829
830        let max_cmd_saved = sorted
831            .first()
832            .map(|(_, s)| cmd_total_saved(s, &cost_model))
833            .unwrap_or(1)
834            .max(1);
835
836        for (cmd, stats) in sorted.iter().take(12) {
837            let cmd_saved = cmd_total_saved(stats, &cost_model);
838            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
839            let cmd_pct = if stats.input_tokens > 0 {
840                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
841            } else {
842                0.0
843            };
844            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
845            let bar = bar_block(ratio, 20);
846            let pc = pct_color(cmd_pct);
847            o.push(format!(
848                "  {GRAY}{:<16}{RST} {:>5}x  {pc}{bar:<20}{RST} {BOLD}{pc}{:>6}{RST}  {DIM}{cmd_pct:.0}%{RST}",
849                truncate_cmd(cmd, 16),
850                stats.count,
851                format_big(cmd_saved),
852            ));
853        }
854
855        if sorted.len() > 12 {
856            o.push(format!(
857                "  {DIM}  ... +{} more commands{RST}",
858                sorted.len() - 12
859            ));
860        }
861    }
862
863    if store.daily.len() >= 2 {
864        o.push(String::new());
865        o.push(format!("  {BOLD}{WHITE}Recent Days{RST}"));
866        o.push(format!("  {DIM}{ln56}{RST}"));
867
868        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
869        for day in recent.iter().rev() {
870            let day_saved = day_total_saved(day, &cost_model);
871            let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
872            let day_pct = if day.input_tokens > 0 {
873                input_saved as f64 / day.input_tokens as f64 * 100.0
874            } else {
875                0.0
876            };
877            let pc = pct_color(day_pct);
878            let date_short = day.date.get(5..).unwrap_or(&day.date);
879            o.push(format!(
880                "  {GRAY}{date_short}{RST}  {:>5} cmds  {pc}{BOLD}{:>8}{RST} saved  {pc}{day_pct:>5.1}%{RST}",
881                day.commands,
882                format_big(day_saved),
883            ));
884        }
885    }
886
887    if let Some(tip) = contextual_tip(&store) {
888        o.push(format!("  {YELLOW}💡 {tip}{RST}"));
889        o.push(String::new());
890    } else {
891        o.push(String::new());
892    }
893
894    o.join("\n")
895}
896
897fn contextual_tip(store: &StatsStore) -> Option<String> {
898    let tips = build_tips(store);
899    if tips.is_empty() {
900        return None;
901    }
902    let seed = std::time::SystemTime::now()
903        .duration_since(std::time::UNIX_EPOCH)
904        .unwrap_or_default()
905        .as_secs()
906        / 86400;
907    Some(tips[(seed as usize) % tips.len()].clone())
908}
909
910fn build_tips(store: &StatsStore) -> Vec<String> {
911    let mut tips = Vec::new();
912
913    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
914        tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
915    }
916
917    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
918        tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
919    }
920
921    if store.cep.total_cache_reads > 0
922        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
923    {
924        tips.push(
925            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
926        );
927    }
928
929    if store.total_commands > 50 && store.cep.sessions == 0 {
930        tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
931    }
932
933    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
934        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
935    }
936
937    if store.daily.len() >= 7 {
938        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
939    }
940
941    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
942    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
943
944    tips
945}
946
947pub fn gain_live() {
948    use std::io::Write;
949
950    let interval = std::time::Duration::from_secs(2);
951    let mut line_count = 0usize;
952
953    eprintln!("  {DIM}▸ Live mode (2s refresh) · Ctrl+C to exit{RST}");
954
955    loop {
956        if line_count > 0 {
957            print!("\x1B[{line_count}A\x1B[J");
958        }
959
960        let output = format_gain();
961        let footer = format!("\n  {DIM}▸ Live · updates every 2s · Ctrl+C to exit{RST}\n");
962        let full = format!("{output}{footer}");
963        line_count = full.lines().count();
964
965        print!("{full}");
966        let _ = std::io::stdout().flush();
967
968        std::thread::sleep(interval);
969    }
970}
971
972pub fn format_gain_graph() -> String {
973    let store = load();
974    if store.daily.is_empty() {
975        return format!(
976            "{DIM}No daily data yet.{RST} Use lean-ctx for a few days to see the graph."
977        );
978    }
979
980    let cm = CostModel::default();
981    let days: Vec<_> = store
982        .daily
983        .iter()
984        .rev()
985        .take(30)
986        .collect::<Vec<_>>()
987        .into_iter()
988        .rev()
989        .collect();
990
991    let savings: Vec<u64> = days.iter().map(|d| day_total_saved(d, &cm)).collect();
992
993    let max_saved = *savings.iter().max().unwrap_or(&1);
994    let max_saved = max_saved.max(1);
995
996    let bar_width = 36;
997    let mut o = Vec::new();
998
999    o.push(String::new());
1000    let ln58 = line('─', 58);
1001    o.push(format!(
1002        "  {BOLD}{WHITE}◆ lean-ctx{RST}  {DIM}Token Savings Graph (last 30 days){RST}"
1003    ));
1004    o.push(format!("  {DIM}{ln58}{RST}"));
1005    o.push(format!(
1006        "  {DIM}{:>58}{RST}",
1007        format!("peak: {}", format_big(max_saved))
1008    ));
1009    o.push(String::new());
1010
1011    for (i, day) in days.iter().enumerate() {
1012        let saved = savings[i];
1013        let ratio = saved as f64 / max_saved as f64;
1014        let bar = bar_block(ratio, bar_width);
1015
1016        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1017        let pct = if day.input_tokens > 0 {
1018            input_saved as f64 / day.input_tokens as f64 * 100.0
1019        } else {
1020            0.0
1021        };
1022        let pc = pct_color(pct);
1023        let date_short = day.date.get(5..).unwrap_or(&day.date);
1024
1025        o.push(format!(
1026            "  {GRAY}{date_short}{RST} {DIM}│{RST} {pc}{bar:<width$}{RST} {BOLD}{:>6}{RST} {DIM}{pct:.0}%{RST}",
1027            format_big(saved),
1028            width = bar_width,
1029        ));
1030    }
1031
1032    let total_saved: u64 = savings.iter().sum();
1033    let total_cmds: u64 = days.iter().map(|d| d.commands).sum();
1034    let spark = sparkline(&savings);
1035
1036    o.push(String::new());
1037    o.push(format!("  {DIM}{ln58}{RST}"));
1038    o.push(format!(
1039        "  {GREEN}{spark}{RST}  {BOLD}{WHITE}{}{RST} saved across {BOLD}{}{RST} commands",
1040        format_big(total_saved),
1041        format_num(total_cmds),
1042    ));
1043    o.push(String::new());
1044
1045    o.join("\n")
1046}
1047
1048pub fn format_gain_daily() -> String {
1049    let store = load();
1050    if store.daily.is_empty() {
1051        return format!("{DIM}No daily data yet.{RST}");
1052    }
1053
1054    let mut o = Vec::new();
1055    let w = 64;
1056
1057    o.push(String::new());
1058    let lnw = line('─', w);
1059    o.push(format!(
1060        "  {BOLD}{WHITE}◆ lean-ctx{RST}  {DIM}Daily Breakdown{RST}"
1061    ));
1062    o.push(format!("  {DIM}┌{lnw}┐{RST}"));
1063    o.push(format!(
1064        "  {DIM}│{RST} {BOLD}{WHITE}{:<12} {:>6}  {:>10}  {:>10}  {:>7}  {:>6}{RST} {DIM}│{RST}",
1065        "Date", "Cmds", "Input", "Saved", "Rate", "USD"
1066    ));
1067    o.push(format!("  {DIM}├{lnw}┤{RST}"));
1068
1069    let days: Vec<_> = store
1070        .daily
1071        .iter()
1072        .rev()
1073        .take(30)
1074        .collect::<Vec<_>>()
1075        .into_iter()
1076        .rev()
1077        .cloned()
1078        .collect();
1079
1080    let cm = CostModel::default();
1081    for day in &days {
1082        let saved = day_total_saved(day, &cm);
1083        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1084        let pct = if day.input_tokens > 0 {
1085            input_saved as f64 / day.input_tokens as f64 * 100.0
1086        } else {
1087            0.0
1088        };
1089        let pc = pct_color(pct);
1090        let usd = usd_estimate(saved);
1091        o.push(format!(
1092            "  {DIM}│{RST} {GRAY}{:<12}{RST} {:>6}  {:>10}  {pc}{BOLD}{:>10}{RST}  {pc}{:>6.1}%{RST}  {DIM}{:>6}{RST} {DIM}│{RST}",
1093            &day.date,
1094            day.commands,
1095            format_big(day.input_tokens),
1096            format_big(saved),
1097            pct,
1098            usd,
1099        ));
1100    }
1101
1102    let total_input: u64 = store.daily.iter().map(|d| d.input_tokens).sum();
1103    let total_saved: u64 = store.daily.iter().map(|d| day_total_saved(d, &cm)).sum();
1104    let total_pct = if total_input > 0 {
1105        let input_saved: u64 = store
1106            .daily
1107            .iter()
1108            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
1109            .sum();
1110        input_saved as f64 / total_input as f64 * 100.0
1111    } else {
1112        0.0
1113    };
1114    let total_usd = usd_estimate(total_saved);
1115
1116    o.push(format!("  {DIM}├{lnw}┤{RST}"));
1117    o.push(format!(
1118        "  {DIM}│{RST} {BOLD}{WHITE}{:<12}{RST} {:>6}  {:>10}  {GREEN}{BOLD}{:>10}{RST}  {GREEN}{BOLD}{:>6.1}%{RST}  {BOLD}{:>6}{RST} {DIM}│{RST}",
1119        "TOTAL",
1120        format_num(store.total_commands),
1121        format_big(total_input),
1122        format_big(total_saved),
1123        total_pct,
1124        total_usd,
1125    ));
1126    o.push(format!("  {DIM}└{lnw}┘{RST}"));
1127
1128    let daily_savings: Vec<u64> = days.iter().map(|d| day_total_saved(d, &cm)).collect();
1129    let spark = sparkline(&daily_savings);
1130    o.push(format!("  {DIM}Trend:{RST} {GREEN}{spark}{RST}"));
1131    o.push(String::new());
1132
1133    o.join("\n")
1134}
1135
1136pub fn format_gain_json() -> String {
1137    let store = load();
1138    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1139}