1use crate::core::config;
5use crate::core::data_source::DataSource;
6use crate::metrics::report;
7use crate::shell::cli::{maybe_refresh_store, open_workspace_read_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
17pub 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 = open_workspace_read_store(workspace, refresh || source != DataSource::Local)?;
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
67pub 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}