Skip to main content

kaizen/shell/
insights.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen insights` — workspace activity dashboard.
3
4use crate::core::config;
5use crate::core::data_source::DataSource;
6use crate::metrics::report;
7use crate::shell::cli::maybe_refresh_store;
8use crate::shell::fmt::fmt_ts;
9use crate::shell::remote_pull::maybe_telemetry_pull;
10use crate::shell::scope;
11use crate::store::InsightsStats;
12use anyhow::Result;
13use std::collections::HashMap;
14use std::fmt::Write;
15use std::path::Path;
16
17/// Same output as `kaizen insights` stdout.
18pub fn insights_text(
19    workspace: Option<&Path>,
20    all_workspaces: bool,
21    refresh: bool,
22    source: DataSource,
23) -> Result<String> {
24    let roots = scope::resolve(workspace, all_workspaces)?;
25    let mut stats_rows = Vec::new();
26    let mut reports = Vec::new();
27    let mut guidance = String::new();
28    for workspace in &roots {
29        let cfg = config::load(workspace)?;
30        let store = crate::store::Store::open(&crate::core::workspace::db_path(workspace))?;
31        maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
32        maybe_refresh_store(workspace, &store, refresh)?;
33        let ws_str = workspace.to_string_lossy().to_string();
34        let row = store.insights(&ws_str)?;
35        let row = if source != DataSource::Local
36            && let Ok(Some(agg)) =
37                crate::shell::remote_observe::try_remote_event_agg(&store, &cfg, workspace)
38        {
39            crate::shell::remote_observe::merge_insights_stats(row, &agg, source)
40        } else {
41            row
42        };
43        stats_rows.push(row);
44        if let Ok(report) = report::build_report(&store, &ws_str, 7) {
45            reports.push(if roots.len() == 1 {
46                report
47            } else {
48                decorate_metrics(workspace, report)
49            });
50        }
51        if roots.len() == 1 {
52            guidance =
53                crate::shell::guidance::format_guidance_teaser(&store, workspace, &ws_str, 7)
54                    .unwrap_or_else(|_| String::new());
55        }
56    }
57    let stats = merge_insights(stats_rows);
58    let metrics = merge_metrics(reports);
59    Ok(format_dashboard(
60        &scope::label(&roots),
61        &stats,
62        metrics.as_ref(),
63        &guidance,
64    ))
65}
66
67/// Print workspace activity dashboard.
68pub fn cmd_insights(
69    workspace: Option<&Path>,
70    all_workspaces: bool,
71    refresh: bool,
72    source: DataSource,
73) -> Result<()> {
74    print!(
75        "{}",
76        insights_text(workspace, all_workspaces, refresh, source)?
77    );
78    Ok(())
79}
80
81fn merge_insights(rows: Vec<InsightsStats>) -> InsightsStats {
82    let mut sessions_by_day = HashMap::new();
83    let mut recent = Vec::new();
84    let mut top_tools = HashMap::new();
85    let mut total_sessions = 0;
86    let mut running_sessions = 0;
87    let mut total_events = 0;
88    let mut total_cost_usd_e6 = 0;
89    let mut sessions_with_cost = 0;
90    for row in rows {
91        total_sessions += row.total_sessions;
92        running_sessions += row.running_sessions;
93        total_events += row.total_events;
94        total_cost_usd_e6 += row.total_cost_usd_e6;
95        sessions_with_cost += row.sessions_with_cost;
96        for (day, count) in row.sessions_by_day {
97            *sessions_by_day.entry(day).or_insert(0_u64) += count;
98        }
99        recent.extend(row.recent);
100        for (tool, count) in row.top_tools {
101            *top_tools.entry(tool).or_insert(0_u64) += count;
102        }
103    }
104    recent.sort_by(|a, b| {
105        b.0.started_at_ms
106            .cmp(&a.0.started_at_ms)
107            .then_with(|| a.0.id.cmp(&b.0.id))
108    });
109    recent.truncate(3);
110    let mut sessions_by_day = sessions_by_day.into_iter().collect::<Vec<_>>();
111    sessions_by_day.sort_by(|a, b| a.0.cmp(&b.0));
112    let mut top_tools = top_tools.into_iter().collect::<Vec<_>>();
113    top_tools.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
114    top_tools.truncate(5);
115    InsightsStats {
116        total_sessions,
117        running_sessions,
118        total_events,
119        sessions_by_day,
120        recent,
121        top_tools,
122        total_cost_usd_e6,
123        sessions_with_cost,
124    }
125}
126
127fn decorate_metrics(
128    workspace: &Path,
129    mut metrics: crate::metrics::types::MetricsReport,
130) -> crate::metrics::types::MetricsReport {
131    for row in &mut metrics.hottest_files {
132        row.path = scope::decorate_path(workspace, &row.path);
133    }
134    for row in &mut metrics.most_changed_files {
135        row.path = scope::decorate_path(workspace, &row.path);
136    }
137    for row in &mut metrics.most_complex_files {
138        row.path = scope::decorate_path(workspace, &row.path);
139    }
140    for row in &mut metrics.highest_risk_files {
141        row.path = scope::decorate_path(workspace, &row.path);
142    }
143    for row in &mut metrics.agent_pain_hotspots {
144        row.path = scope::decorate_path(workspace, &row.path);
145    }
146    metrics
147}
148
149fn merge_metrics(
150    rows: Vec<crate::metrics::types::MetricsReport>,
151) -> Option<crate::metrics::types::MetricsReport> {
152    let mut it = rows.into_iter();
153    let first = it.next()?;
154    let mut out = crate::metrics::types::MetricsReport {
155        snapshot: None,
156        hottest_files: first.hottest_files,
157        most_changed_files: first.most_changed_files,
158        most_complex_files: first.most_complex_files,
159        highest_risk_files: first.highest_risk_files,
160        slowest_tools: first.slowest_tools,
161        highest_token_tools: first.highest_token_tools,
162        highest_reasoning_tools: first.highest_reasoning_tools,
163        agent_pain_hotspots: first.agent_pain_hotspots,
164    };
165    for row in it {
166        out.hottest_files.extend(row.hottest_files);
167        out.most_changed_files.extend(row.most_changed_files);
168        out.most_complex_files.extend(row.most_complex_files);
169        out.highest_risk_files.extend(row.highest_risk_files);
170        out.agent_pain_hotspots.extend(row.agent_pain_hotspots);
171        merge_tool_rows(&mut out.slowest_tools, row.slowest_tools);
172        merge_tool_rows(&mut out.highest_token_tools, row.highest_token_tools);
173        merge_tool_rows(
174            &mut out.highest_reasoning_tools,
175            row.highest_reasoning_tools,
176        );
177    }
178    trim_file_rows(&mut out.hottest_files);
179    trim_file_rows(&mut out.most_changed_files);
180    trim_file_rows(&mut out.most_complex_files);
181    trim_file_rows(&mut out.highest_risk_files);
182    trim_file_rows(&mut out.agent_pain_hotspots);
183    trim_tool_rows(&mut out.slowest_tools, |row| row.p95_ms.unwrap_or(0));
184    trim_tool_rows(&mut out.highest_token_tools, |row| row.total_tokens);
185    trim_tool_rows(&mut out.highest_reasoning_tools, |row| {
186        row.total_reasoning_tokens
187    });
188    Some(out)
189}
190
191fn merge_tool_rows(
192    target: &mut Vec<crate::metrics::types::RankedTool>,
193    rows: Vec<crate::metrics::types::RankedTool>,
194) {
195    for row in rows {
196        if let Some(existing) = target.iter_mut().find(|item| item.tool == row.tool) {
197            existing.calls += row.calls;
198            existing.total_tokens += row.total_tokens;
199            existing.total_reasoning_tokens += row.total_reasoning_tokens;
200            existing.p50_ms = existing.p50_ms.max(row.p50_ms);
201            existing.p95_ms = existing.p95_ms.max(row.p95_ms);
202            continue;
203        }
204        target.push(row);
205    }
206}
207
208fn trim_file_rows(rows: &mut Vec<crate::metrics::types::RankedFile>) {
209    rows.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.path.cmp(&b.path)));
210    rows.truncate(10);
211}
212
213fn trim_tool_rows<F>(rows: &mut Vec<crate::metrics::types::RankedTool>, rank: F)
214where
215    F: Fn(&crate::metrics::types::RankedTool) -> u64,
216{
217    rows.sort_by(|a, b| rank(b).cmp(&rank(a)).then_with(|| a.tool.cmp(&b.tool)));
218    rows.truncate(10);
219}
220
221fn format_dashboard(
222    ws: &str,
223    stats: &InsightsStats,
224    metrics: Option<&crate::metrics::types::MetricsReport>,
225    guidance_teaser: &str,
226) -> String {
227    let mut s = String::new();
228    writeln!(&mut s, "kaizen — {ws}").unwrap();
229    writeln!(&mut s).unwrap();
230    format_sessions(&mut s, stats);
231    writeln!(&mut s).unwrap();
232    format_tools(&mut s, stats);
233    writeln!(&mut s).unwrap();
234    format_cost(&mut s, stats);
235    if !guidance_teaser.is_empty() {
236        writeln!(&mut s).unwrap();
237        s.push_str(guidance_teaser);
238    }
239    if let Some(metrics) = metrics {
240        writeln!(&mut s).unwrap();
241        format_code(&mut s, metrics);
242        writeln!(&mut s).unwrap();
243        format_tool_spans(&mut s, metrics);
244    }
245    writeln!(&mut s).unwrap();
246    s.push_str(&takeaway_block(ws, stats, metrics));
247    s
248}
249
250fn takeaway_block(
251    _ws: &str,
252    stats: &InsightsStats,
253    metrics: Option<&crate::metrics::types::MetricsReport>,
254) -> String {
255    use std::fmt::Write;
256    let mut s = String::new();
257    let _ = writeln!(&mut s, "Takeaway");
258    if let Some(m) = metrics {
259        if let Some(f) = m.hottest_files.first() {
260            let _ = writeln!(
261                &mut s,
262                "  · Hottest file (agent × churn signal): {} — value {}",
263                f.path, f.value
264            );
265        }
266        if let Some(t) = m.slowest_tools.first() {
267            let p95 = t
268                .p95_ms
269                .map(|v| format!("{v}ms"))
270                .unwrap_or_else(|| "n/a".into());
271            let _ = writeln!(&mut s, "  · Slowest tool (p95): {} @ {}", t.tool, p95);
272        }
273    }
274    if let Some((rec, _n)) = stats.recent.first() {
275        let _ = writeln!(&mut s, "  · Recent session agent: {}", rec.agent);
276    }
277    if !stats.top_tools.is_empty() {
278        let _ = writeln!(
279            &mut s,
280            "  · Next: `kaizen retro --days 7` for ranked bets, or `kaizen exp new` to A/B a change"
281        );
282    } else {
283        let _ = writeln!(
284            &mut s,
285            "  · Next: `kaizen metrics` or run more agent sessions to populate tools"
286        );
287    }
288    s
289}
290
291fn format_sessions(out: &mut String, stats: &InsightsStats) {
292    let _ = writeln!(
293        out,
294        "Sessions ({} total, {} running)",
295        stats.total_sessions, stats.running_sessions
296    );
297    let day_parts: Vec<String> = stats
298        .sessions_by_day
299        .iter()
300        .map(|(d, c)| format!("{d} {c}"))
301        .collect();
302    let _ = writeln!(out, "  Last 7 days:  {}", day_parts.join("  "));
303    if stats.recent.is_empty() {
304        return;
305    }
306    let _ = writeln!(out, "  Most recent:");
307    for (s, cnt) in &stats.recent {
308        let _ = writeln!(
309            out,
310            "    {}  {:<8}  {:<8}  {} events",
311            fmt_ts(s.started_at_ms),
312            s.agent,
313            format!("{:?}", s.status),
314            cnt
315        );
316    }
317}
318
319fn format_tools(out: &mut String, stats: &InsightsStats) {
320    let _ = writeln!(out, "Tools (top 5)");
321    let max = stats.top_tools.first().map(|(_, c)| *c).unwrap_or(1).max(1);
322    for (tool, cnt) in &stats.top_tools {
323        let bar_len = (cnt * 20 / max).max(1) as usize;
324        let bar = "█".repeat(bar_len);
325        let _ = writeln!(out, "  {:<14} {:>5}  {}", tool, cnt, bar);
326    }
327    if stats.top_tools.is_empty() {
328        let _ = writeln!(out, "  (no tool data)");
329    }
330}
331
332fn format_cost(out: &mut String, stats: &InsightsStats) {
333    let cost = stats.total_cost_usd_e6 as f64 / 1_000_000.0;
334    let _ = writeln!(
335        out,
336        "Cost:  ${cost:.2}  ({} sessions with cost data)",
337        stats.sessions_with_cost
338    );
339}
340
341fn format_code(out: &mut String, metrics: &crate::metrics::types::MetricsReport) {
342    let _ = writeln!(out, "Code");
343    for row in metrics.hottest_files.iter().take(5) {
344        let _ = writeln!(out, "  hot {:>8}  {}", row.value, row.path);
345    }
346    for row in metrics.agent_pain_hotspots.iter().take(5) {
347        let _ = writeln!(out, "  pain {:>7}  {}", row.value, row.path);
348    }
349    if metrics.hottest_files.is_empty() && metrics.agent_pain_hotspots.is_empty() {
350        let _ = writeln!(out, "  (no file metrics)");
351    }
352}
353
354fn format_tool_spans(out: &mut String, metrics: &crate::metrics::types::MetricsReport) {
355    let _ = writeln!(out, "Tool Spans");
356    for row in metrics.slowest_tools.iter().take(5) {
357        let p95 = row
358            .p95_ms
359            .map(|v| format!("{v}ms"))
360            .unwrap_or_else(|| "-".into());
361        let _ = writeln!(
362            out,
363            "  {:<14} p95={} tok={} rtok={}",
364            row.tool, p95, row.total_tokens, row.total_reasoning_tokens
365        );
366    }
367    if metrics.slowest_tools.is_empty() {
368        let _ = writeln!(out, "  (no span metrics)");
369    }
370}