Skip to main content

kaizen/shell/
cli.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! CLI command implementations.
3
4use crate::collect::tail::antigravity::scan_antigravity_workspace;
5use crate::collect::tail::claude::scan_claude_session_dir;
6use crate::collect::tail::claude_code::scan_claude_project_dir_since;
7use crate::collect::tail::codex::scan_codex_session_dir;
8use crate::collect::tail::codex_desktop::scan_codex_sessions_root_since;
9use crate::collect::tail::copilot_cli::scan_copilot_cli_workspace;
10use crate::collect::tail::copilot_vscode::scan_copilot_vscode_workspace;
11use crate::collect::tail::cursor::scan_session_dir_all;
12use crate::collect::tail::cursor_state_db::scan_cursor_state_db_workspace;
13use crate::collect::tail::gemini::scan_gemini_workspace;
14use crate::collect::tail::goose::scan_goose_workspace;
15use crate::collect::tail::kimi::scan_kimi_workspace;
16use crate::collect::tail::openclaw::scan_openclaw_workspace;
17use crate::collect::tail::opencode::scan_opencode_workspace;
18use crate::collect::tail::pi::scan_pi_workspace;
19use crate::core::config;
20use crate::core::event::{Event, SessionRecord};
21use crate::metrics::report;
22use crate::shell::fmt::fmt_ts;
23use crate::shell::scope;
24use crate::store::{SYNC_STATE_LAST_AGENT_SCAN_MS, SYNC_STATE_LAST_AUTO_PRUNE_MS, Store};
25use anyhow::Result;
26use serde::Serialize;
27use std::collections::{BTreeSet, HashMap};
28use std::io::IsTerminal;
29use std::path::{Path, PathBuf};
30
31pub use crate::shell::init::cmd_init;
32pub use crate::shell::insights::cmd_insights;
33
34#[derive(Serialize)]
35struct SessionsListJson {
36    workspace: String,
37    #[serde(skip_serializing_if = "Vec::is_empty")]
38    workspaces: Vec<String>,
39    count: usize,
40    sessions: Vec<SessionRecord>,
41}
42
43#[derive(Serialize)]
44struct SummaryJsonOut {
45    workspace: String,
46    #[serde(skip_serializing_if = "Vec::is_empty")]
47    workspaces: Vec<String>,
48    #[serde(flatten)]
49    stats: crate::store::SummaryStats,
50    cost_usd: f64,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    cost_note: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    hotspot: Option<crate::metrics::types::RankedFile>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    slowest_tool: Option<crate::metrics::types::RankedTool>,
57}
58
59#[derive(Clone, Debug, Default, Serialize)]
60pub(crate) struct AgentScanStats {
61    pub sessions_found: u64,
62    pub sessions_upserted: u64,
63    pub events_found: u64,
64    pub events_upserted: u64,
65    pub agents: BTreeSet<String>,
66}
67
68impl AgentScanStats {
69    fn record(&mut self, record: &SessionRecord, event_count: usize) {
70        self.sessions_found += 1;
71        self.sessions_upserted += 1;
72        self.events_found += event_count as u64;
73        self.agents.insert(record.agent.clone());
74    }
75
76    pub(crate) fn merge(&mut self, other: &Self) {
77        self.sessions_found += other.sessions_found;
78        self.sessions_upserted += other.sessions_upserted;
79        self.events_found += other.events_found;
80        self.events_upserted += other.events_upserted;
81        self.agents.extend(other.agents.iter().cloned());
82    }
83}
84
85/// Summary/MCP: sessions exist but rollup has no stored micro-USD — show honest footnote, not invented spend.
86pub(crate) fn summary_needs_cost_rollup_note(session_count: u64, total_cost_usd_e6: i64) -> bool {
87    session_count > 0 && total_cost_usd_e6 == 0
88}
89
90pub(crate) fn cost_rollup_zero_note_paragraph() -> &'static str {
91    "Cost rollup shows $0.00 because stored events have no cost_usd_e6 — common when Cursor agent-transcript lines omit usage/tokens. \
92If you expect non-zero spend, ingest Claude/Codex transcripts with usage, hooks with total_cost_usd, or Kaizen proxy Cost events; run `kaizen summary --refresh` after ingest changes. \
93See docs/usage.md#cost-shows-zero."
94}
95
96pub(crate) fn cost_rollup_zero_doctor_hint() -> &'static str {
97    "Cost rollup $0.00 with sessions but no cost_usd_e6 — often Cursor transcripts without usage; see docs/usage.md#cost-shows-zero"
98}
99
100struct ScanSpinner(Option<indicatif::ProgressBar>);
101
102impl ScanSpinner {
103    fn start(msg: &'static str) -> Self {
104        if !std::io::stdout().is_terminal() {
105            return Self(None);
106        }
107        let p = indicatif::ProgressBar::new_spinner();
108        p.set_message(msg.to_string());
109        p.enable_steady_tick(std::time::Duration::from_millis(120));
110        Self(Some(p))
111    }
112}
113
114impl Drop for ScanSpinner {
115    fn drop(&mut self) {
116        if let Some(p) = self.0.take() {
117            p.finish_and_clear();
118        }
119    }
120}
121
122fn now_ms_u64() -> u64 {
123    std::time::SystemTime::now()
124        .duration_since(std::time::UNIX_EPOCH)
125        .unwrap_or_default()
126        .as_millis() as u64
127}
128
129/// Minimum interval between automatic local DB prunes after a successful rescan (24h).
130const AUTO_PRUNE_INTERVAL_MS: u64 = 86_400_000;
131const INITIAL_SCAN_LOOKBACK_MS: u64 = 30 * 86_400_000;
132const SCAN_OVERLAP_MS: u64 = 60_000;
133
134pub(crate) fn maybe_auto_prune_after_scan(store: &Store, cfg: &config::Config) -> Result<()> {
135    if cfg.retention.hot_days == 0 {
136        return Ok(());
137    }
138    let now = now_ms_u64();
139    if let Some(last) = store.sync_state_get_u64(SYNC_STATE_LAST_AUTO_PRUNE_MS)?
140        && now.saturating_sub(last) < AUTO_PRUNE_INTERVAL_MS
141    {
142        return Ok(());
143    }
144    let cutoff = now.saturating_sub((cfg.retention.hot_days as u64).saturating_mul(86_400_000));
145    store.prune_sessions_started_before(cutoff as i64)?;
146    store.sync_state_set_u64(SYNC_STATE_LAST_AUTO_PRUNE_MS, now)?;
147    Ok(())
148}
149
150/// Import recently changed transcripts unless throttled by the scan interval.
151pub(crate) fn maybe_scan_all_agents(
152    ws: &Path,
153    cfg: &config::Config,
154    ws_str: &str,
155    store: &Store,
156    refresh: bool,
157) -> Result<()> {
158    let interval_ms = cfg.scan.min_rescan_seconds.saturating_mul(1000);
159    let now = now_ms_u64();
160    if !refresh
161        && interval_ms > 0
162        && let Some(last) = store.sync_state_get_u64(SYNC_STATE_LAST_AGENT_SCAN_MS)?
163        && now.saturating_sub(last) < interval_ms
164    {
165        return Ok(());
166    }
167    let since_ms = scan_since_ms(store, now)?;
168    scan_all_agents_since(ws, cfg, ws_str, store, since_ms)?;
169    store.sync_state_set_u64(SYNC_STATE_LAST_AGENT_SCAN_MS, now)?;
170    Ok(())
171}
172
173fn scan_since_ms(store: &Store, now_ms: u64) -> Result<u64> {
174    Ok(store
175        .sync_state_get_u64(SYNC_STATE_LAST_AGENT_SCAN_MS)?
176        .map_or(now_ms.saturating_sub(INITIAL_SCAN_LOOKBACK_MS), |last| {
177            last.saturating_sub(SCAN_OVERLAP_MS)
178        }))
179}
180
181pub(crate) fn maybe_refresh_store(workspace: &Path, store: &Store, refresh: bool) -> Result<()> {
182    if !refresh {
183        return Ok(());
184    }
185    let cfg = config::load(workspace)?;
186    let ws_str = workspace.to_string_lossy().to_string();
187    maybe_scan_all_agents(workspace, &cfg, &ws_str, store, true)
188}
189
190fn combine_counts(rows: Vec<Vec<(String, u64)>>) -> Vec<(String, u64)> {
191    let mut counts = HashMap::new();
192    for set in rows {
193        for (key, value) in set {
194            *counts.entry(key).or_insert(0_u64) += value;
195        }
196    }
197    let mut out = counts.into_iter().collect::<Vec<_>>();
198    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
199    out
200}
201
202fn workspace_names(roots: &[PathBuf]) -> Vec<String> {
203    roots
204        .iter()
205        .map(|path| path.to_string_lossy().to_string())
206        .collect()
207}
208
209pub(crate) fn open_workspace_read_store(workspace: &Path, refresh: bool) -> Result<Store> {
210    let db_path = crate::core::workspace::db_path(workspace)?;
211    if refresh {
212        Store::open(&db_path)
213    } else if db_path.exists() {
214        Store::open_query(&db_path)
215    } else {
216        Store::open_empty(db_path.parent().unwrap_or(workspace))
217    }
218}
219
220/// `kaizen sessions list` — same output as CLI stdout.
221pub fn sessions_list_text(
222    workspace: Option<&Path>,
223    json_out: bool,
224    refresh: bool,
225    all_workspaces: bool,
226    limit: Option<usize>,
227) -> Result<String> {
228    let roots = scope::resolve(workspace, all_workspaces)?;
229    let output_limit = limit.unwrap_or(100);
230    let query_limit = if output_limit == 0 {
231        i64::MAX as usize
232    } else {
233        output_limit
234    };
235    let mut sessions = Vec::new();
236    if crate::daemon::enabled() && !refresh {
237        for workspace in &roots {
238            let ws_str = workspace.to_string_lossy().to_string();
239            let response =
240                crate::daemon::request_blocking(crate::ipc::DaemonRequest::ListSessions {
241                    workspace: ws_str,
242                    offset: 0,
243                    limit: query_limit,
244                    filter: crate::store::SessionFilter::default(),
245                })?;
246            match response {
247                crate::ipc::DaemonResponse::Sessions(page) => sessions.extend(page.rows),
248                crate::ipc::DaemonResponse::Error { message, .. } => anyhow::bail!(message),
249                _ => anyhow::bail!("unexpected daemon sessions response"),
250            }
251        }
252    } else {
253        for workspace in &roots {
254            let store = open_workspace_read_store(workspace, refresh)?;
255            maybe_refresh_store(workspace, &store, refresh)?;
256            let ws_str = workspace.to_string_lossy().to_string();
257            if output_limit == 0 {
258                sessions.extend(store.list_sessions(&ws_str)?);
259            } else {
260                sessions.extend(
261                    store
262                        .list_sessions_page(
263                            &ws_str,
264                            0,
265                            query_limit,
266                            crate::store::SessionFilter::default(),
267                        )?
268                        .rows,
269                );
270            }
271        }
272    }
273    sessions.sort_by(|a, b| {
274        b.started_at_ms
275            .cmp(&a.started_at_ms)
276            .then_with(|| a.id.cmp(&b.id))
277    });
278    if output_limit > 0 {
279        let n = output_limit;
280        sessions.truncate(n);
281    }
282    let scope_label = scope::label(&roots);
283    let workspaces = if roots.len() > 1 {
284        workspace_names(&roots)
285    } else {
286        Vec::new()
287    };
288    if json_out {
289        return Ok(format!(
290            "{}\n",
291            serde_json::to_string_pretty(&SessionsListJson {
292                workspace: scope_label,
293                workspaces,
294                count: sessions.len(),
295                sessions,
296            })?
297        ));
298    }
299    use std::fmt::Write;
300    let mut out = String::new();
301    if roots.len() > 1 {
302        writeln!(&mut out, "Scope: {scope_label}").unwrap();
303        writeln!(&mut out).unwrap();
304    }
305    writeln!(
306        &mut out,
307        "{:<40} {:<10} {:<10} STARTED",
308        "ID", "AGENT", "STATUS"
309    )
310    .unwrap();
311    writeln!(&mut out, "{}", "-".repeat(80)).unwrap();
312    for s in &sessions {
313        writeln!(
314            &mut out,
315            "{:<40} {:<10} {:<10} {}",
316            s.id,
317            s.agent,
318            format!("{:?}", s.status),
319            fmt_ts(s.started_at_ms),
320        )
321        .unwrap();
322    }
323    if sessions.is_empty() {
324        writeln!(&mut out, "(no sessions)").unwrap();
325        sessions_empty_state_hints(&mut out);
326    }
327    Ok(out)
328}
329
330fn sessions_empty_state_hints(out: &mut String) {
331    use std::fmt::Write;
332    let _ = writeln!(out);
333    let _ = writeln!(out, "No sessions found for this workspace. Try:");
334    let _ = writeln!(out, "  · `kaizen doctor` — verify config and hooks");
335    let _ = writeln!(out, "  · a short agent session in this repo, then re-run");
336    let _ = writeln!(
337        out,
338        "  · docs: https://github.com/marquesds/kaizen/blob/main/docs/config.md (sources)"
339    );
340}
341
342/// `kaizen sessions list` — scan all agent transcripts, upsert sessions, print table.
343pub fn cmd_sessions_list(
344    workspace: Option<&Path>,
345    json_out: bool,
346    refresh: bool,
347    all_workspaces: bool,
348    limit: Option<usize>,
349) -> Result<()> {
350    print!(
351        "{}",
352        sessions_list_text(workspace, json_out, refresh, all_workspaces, limit)?
353    );
354    Ok(())
355}
356
357/// `kaizen sessions show` — same output as CLI stdout.
358pub fn session_show_text(id: &str, workspace: Option<&Path>) -> Result<String> {
359    let ws = workspace_path(workspace)?;
360    let store = open_workspace_read_store(&ws, false)?;
361    use std::fmt::Write;
362    let mut out = String::new();
363    match store.get_session(id)? {
364        Some(s) => {
365            writeln!(&mut out, "id:           {}", s.id).unwrap();
366            writeln!(&mut out, "agent:        {}", s.agent).unwrap();
367            writeln!(
368                &mut out,
369                "model:        {}",
370                s.model.as_deref().unwrap_or("-")
371            )
372            .unwrap();
373            writeln!(&mut out, "workspace:    {}", s.workspace).unwrap();
374            writeln!(&mut out, "started_at:   {}", fmt_ts(s.started_at_ms)).unwrap();
375            writeln!(
376                &mut out,
377                "ended_at:     {}",
378                s.ended_at_ms.map(fmt_ts).unwrap_or_else(|| "-".to_string())
379            )
380            .unwrap();
381            writeln!(&mut out, "status:       {:?}", s.status).unwrap();
382            writeln!(&mut out, "trace_path:   {}", s.trace_path).unwrap();
383            if let Some(fp) = &s.prompt_fingerprint {
384                writeln!(&mut out, "prompt_fp:    {fp}").unwrap();
385                if let Ok(Some(snap)) = store.get_prompt_snapshot(fp) {
386                    for f in snap.files() {
387                        writeln!(&mut out, "  - {}", f.path).unwrap();
388                    }
389                }
390            }
391        }
392        None => anyhow::bail!("session not found: {id} — try `kaizen sessions list`"),
393    }
394    let evals = store.list_evals_for_session(id).unwrap_or_default();
395    if !evals.is_empty() {
396        writeln!(&mut out, "evals:").unwrap();
397        for e in &evals {
398            writeln!(
399                &mut out,
400                "  {} score={:.2} flagged={} {}",
401                e.rubric_id, e.score, e.flagged, e.rationale
402            )
403            .unwrap();
404        }
405    }
406    let fb = store
407        .feedback_for_sessions(&[id.to_string()])
408        .unwrap_or_default();
409    if let Some(r) = fb.get(id) {
410        let score = r
411            .score
412            .as_ref()
413            .map(|s| s.0.to_string())
414            .unwrap_or_else(|| "-".into());
415        let label = r
416            .label
417            .as_ref()
418            .map(|l| l.to_string())
419            .unwrap_or_else(|| "-".into());
420        writeln!(&mut out, "feedback:     score={score} label={label}").unwrap();
421        if let Some(n) = &r.note {
422            writeln!(&mut out, "  note: {n}").unwrap();
423        }
424    }
425    Ok(out)
426}
427
428/// `kaizen sessions show <id>` — print full session fields.
429pub fn cmd_session_show(id: &str, workspace: Option<&Path>) -> Result<()> {
430    print!("{}", session_show_text(id, workspace)?);
431    Ok(())
432}
433
434pub fn sessions_tree_text(id: &str, max_depth: u32, workspace: Option<&Path>) -> Result<String> {
435    let ws = workspace_path(workspace)?;
436    let store = open_workspace_read_store(&ws, false)?;
437    require_session(&store, id)?;
438    let nodes = store.session_span_tree(id)?;
439    if nodes.is_empty() {
440        return Ok(format!("(no tool spans for session {id})\n"));
441    }
442    Ok(render_span_tree(&nodes, max_depth))
443}
444
445fn require_session(store: &Store, id: &str) -> Result<()> {
446    anyhow::ensure!(session_exists(store, id)?, "session not found: {id}");
447    Ok(())
448}
449
450fn session_exists(store: &Store, id: &str) -> Result<bool> {
451    let ids = [id.to_owned()];
452    match store.session_statuses(&ids) {
453        Ok(rows) => Ok(!rows.is_empty()),
454        Err(error) if missing_sessions_table(&error) => Ok(false),
455        Err(error) => Err(error),
456    }
457}
458
459fn missing_sessions_table(error: &anyhow::Error) -> bool {
460    matches!(
461        error.downcast_ref::<rusqlite::Error>(),
462        Some(rusqlite::Error::SqliteFailure(_, Some(message)))
463            if message == "no such table: sessions"
464    )
465}
466
467fn render_span_tree(nodes: &[crate::store::span_tree::SpanNode], max_depth: u32) -> String {
468    let total_cost: i64 = nodes.iter().map(|n| n.subtree_cost_usd_e6).sum();
469    let mut out = String::new();
470    nodes
471        .iter()
472        .for_each(|node| render_node(&mut out, node, 0, max_depth, total_cost));
473    out
474}
475
476fn render_node(
477    out: &mut String,
478    node: &crate::store::span_tree::SpanNode,
479    depth: u32,
480    max_depth: u32,
481    session_total: i64,
482) {
483    use std::fmt::Write;
484    if depth > max_depth {
485        return;
486    }
487    let indent = "│  ".repeat(depth as usize);
488    let prefix = if depth == 0 { "┌─ " } else { "├─ " };
489    let cost_str = match node.span.subtree_cost_usd_e6 {
490        Some(c) => {
491            let pct = if session_total > 0 {
492                c * 100 / session_total
493            } else {
494                0
495            };
496            let flag = if pct > 40 { " ⚡" } else { "" };
497            format!(" ${:.4}{}", c as f64 / 1_000_000.0, flag)
498        }
499        None => String::new(),
500    };
501    writeln!(
502        out,
503        "{}{}{} [{}]{}",
504        indent, prefix, node.span.tool, node.span.status, cost_str
505    )
506    .unwrap();
507    for child in &node.children {
508        render_node(out, child, depth + 1, max_depth, session_total);
509    }
510}
511
512/// `kaizen sessions tree <id>` — produce text output (ASCII or JSON).
513pub fn cmd_sessions_tree_text(
514    id: &str,
515    depth: u32,
516    json: bool,
517    workspace: Option<&Path>,
518) -> Result<String> {
519    if json {
520        let ws = workspace_path(workspace)?;
521        let store = open_workspace_read_store(&ws, false)?;
522        let nodes = store.session_span_tree(id)?;
523        Ok(serde_json::to_string_pretty(&nodes)?)
524    } else {
525        sessions_tree_text(id, depth, workspace)
526    }
527}
528
529/// `kaizen sessions tree <id>` — print ASCII span tree.
530pub fn cmd_sessions_tree(id: &str, depth: u32, json: bool, workspace: Option<&Path>) -> Result<()> {
531    print!("{}", cmd_sessions_tree_text(id, depth, json, workspace)?);
532    Ok(())
533}
534
535pub fn sessions_trace_text(id: &str, json: bool, workspace: Option<&Path>) -> Result<String> {
536    let ws = workspace_path(workspace)?;
537    let store = open_workspace_read_store(&ws, false)?;
538    let spans = store.trace_spans_for_session(id)?;
539    if json {
540        return Ok(format!("{}\n", serde_json::to_string_pretty(&spans)?));
541    }
542    if spans.is_empty() {
543        if store.get_session(id)?.is_none() {
544            anyhow::bail!("session not found: {id}");
545        }
546        return Ok(format!("(no trace spans for session {id})\n"));
547    }
548    Ok(format_trace_spans(&spans))
549}
550
551fn format_trace_spans(spans: &[crate::core::trace_span::TraceSpanRecord]) -> String {
552    use std::fmt::Write;
553    let mut out = String::new();
554    writeln!(
555        &mut out,
556        "{:<10} {:<18} {:<8} DURATION",
557        "KIND", "NAME", "STATUS"
558    )
559    .unwrap();
560    writeln!(&mut out, "{}", "-".repeat(64)).unwrap();
561    for span in spans {
562        let ms = span
563            .duration_ms
564            .map(|v| v.to_string())
565            .unwrap_or("-".into());
566        writeln!(
567            &mut out,
568            "{:<10} {:<18} {:<8} {}ms",
569            span.kind.as_str(),
570            span.name,
571            span.status,
572            ms
573        )
574        .unwrap();
575    }
576    out
577}
578
579pub fn cmd_sessions_trace(id: &str, json: bool, workspace: Option<&Path>) -> Result<()> {
580    print!("{}", sessions_trace_text(id, json, workspace)?);
581    Ok(())
582}
583
584/// `kaizen summary` — same output as CLI stdout.
585pub fn summary_text(
586    workspace: Option<&Path>,
587    json_out: bool,
588    refresh: bool,
589    all_workspaces: bool,
590    source: crate::core::data_source::DataSource,
591) -> Result<String> {
592    let roots = scope::resolve(workspace, all_workspaces)?;
593    let mut total_cost_usd_e6 = 0_i64;
594    let mut session_count = 0_u64;
595    let mut by_agent = Vec::new();
596    let mut by_model = Vec::new();
597    let mut top_tools = Vec::new();
598    let mut hottest = Vec::new();
599    let mut slowest = Vec::new();
600
601    for workspace in &roots {
602        let cfg = config::load(workspace)?;
603        let store = open_workspace_read_store(
604            workspace,
605            refresh || source != crate::core::data_source::DataSource::Local,
606        )?;
607        crate::shell::remote_pull::maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
608        maybe_refresh_store(workspace, &store, refresh)?;
609        let ws_str = workspace.to_string_lossy().to_string();
610        let read_store = open_workspace_read_store(workspace, false)?;
611        let query = crate::store::query::QueryStore::open(&crate::core::paths::project_data_path(
612            workspace,
613        )?)?;
614        let mut stats = query.summary_stats(&read_store, &ws_str)?;
615        if source != crate::core::data_source::DataSource::Local
616            && let Ok(Some(agg)) =
617                crate::shell::remote_observe::try_remote_event_agg(&read_store, &cfg, workspace)
618        {
619            stats = crate::shell::remote_observe::merge_summary_stats(stats, &agg, source);
620        }
621        total_cost_usd_e6 += stats.total_cost_usd_e6;
622        session_count += stats.session_count;
623        by_agent.push(stats.by_agent);
624        by_model.push(stats.by_model);
625        top_tools.push(stats.top_tools);
626        if let Ok(metrics) = report::build_report(&read_store, &ws_str, 7) {
627            if let Some(file) = metrics.hottest_files.first().cloned() {
628                hottest.push(if roots.len() == 1 {
629                    file
630                } else {
631                    crate::metrics::types::RankedFile {
632                        path: scope::decorate_path(workspace, &file.path),
633                        ..file
634                    }
635                });
636            }
637            if let Some(tool) = metrics.slowest_tools.first().cloned() {
638                slowest.push(tool);
639            }
640        }
641    }
642
643    let stats = crate::store::SummaryStats {
644        session_count,
645        total_cost_usd_e6,
646        by_agent: combine_counts(by_agent),
647        by_model: combine_counts(by_model),
648        top_tools: combine_counts(top_tools),
649    };
650    let cost_dollars = stats.total_cost_usd_e6 as f64 / 1_000_000.0;
651    let hotspot = hottest
652        .into_iter()
653        .max_by(|a, b| a.value.cmp(&b.value).then_with(|| b.path.cmp(&a.path)));
654    let slowest_tool = slowest.into_iter().max_by(|a, b| {
655        a.p95_ms
656            .unwrap_or(0)
657            .cmp(&b.p95_ms.unwrap_or(0))
658            .then_with(|| b.tool.cmp(&a.tool))
659    });
660    let scope_label = scope::label(&roots);
661    let workspaces = if roots.len() > 1 {
662        workspace_names(&roots)
663    } else {
664        Vec::new()
665    };
666    let cost_note = summary_needs_cost_rollup_note(stats.session_count, stats.total_cost_usd_e6)
667        .then_some(cost_rollup_zero_note_paragraph().to_string());
668    if json_out {
669        return Ok(format!(
670            "{}\n",
671            serde_json::to_string_pretty(&SummaryJsonOut {
672                workspace: scope_label,
673                workspaces,
674                cost_usd: cost_dollars,
675                stats,
676                cost_note,
677                hotspot,
678                slowest_tool,
679            })?
680        ));
681    }
682    use std::fmt::Write;
683    let mut out = String::new();
684    if roots.len() > 1 {
685        writeln!(&mut out, "Scope: {}", scope::label(&roots)).unwrap();
686    }
687    writeln!(
688        &mut out,
689        "Sessions: {}   Cost: ${:.2}",
690        stats.session_count, cost_dollars
691    )
692    .unwrap();
693
694    if !stats.by_agent.is_empty() {
695        let parts: Vec<String> = stats
696            .by_agent
697            .iter()
698            .map(|(a, n)| format!("{a} {n}"))
699            .collect();
700        writeln!(&mut out, "By agent:  {}", parts.join(" · ")).unwrap();
701    }
702    if !stats.by_model.is_empty() {
703        let parts: Vec<String> = stats
704            .by_model
705            .iter()
706            .map(|(m, n)| format!("{m} {n}"))
707            .collect();
708        writeln!(&mut out, "By model:  {}", parts.join(" · ")).unwrap();
709    }
710    if !stats.top_tools.is_empty() {
711        let parts: Vec<String> = stats
712            .top_tools
713            .iter()
714            .take(5)
715            .map(|(t, n)| format!("{t} {n}"))
716            .collect();
717        writeln!(&mut out, "Top tools: {}", parts.join(" · ")).unwrap();
718    }
719    if let Some(file) = hotspot {
720        writeln!(&mut out, "Hotspot:   {} ({})", file.path, file.value).unwrap();
721    }
722    if let Some(tool) = slowest_tool {
723        let p95 = tool
724            .p95_ms
725            .map(|v| format!("{v}ms"))
726            .unwrap_or_else(|| "-".into());
727        writeln!(&mut out, "Slowest:   {} p95 {}", tool.tool, p95).unwrap();
728    }
729    if cost_note.is_some() {
730        writeln!(&mut out).unwrap();
731        writeln!(&mut out, "Note: {}", cost_rollup_zero_note_paragraph()).unwrap();
732    }
733    Ok(out)
734}
735
736/// `kaizen summary` — aggregate session + cost stats across all agents.
737pub fn cmd_summary(
738    workspace: Option<&Path>,
739    json_out: bool,
740    refresh: bool,
741    all_workspaces: bool,
742    source: crate::core::data_source::DataSource,
743) -> Result<()> {
744    print!(
745        "{}",
746        summary_text(workspace, json_out, refresh, all_workspaces, source,)?
747    );
748    Ok(())
749}
750
751fn scan_all_agents_since(
752    ws: &Path,
753    cfg: &config::Config,
754    ws_str: &str,
755    store: &Store,
756    since_ms: u64,
757) -> Result<()> {
758    scan_all_agents_with_stats_since(ws, cfg, ws_str, store, since_ms, false).map(|_| ())
759}
760
761pub(crate) fn scan_all_agents_with_stats(
762    ws: &Path,
763    cfg: &config::Config,
764    ws_str: &str,
765    store: &Store,
766) -> Result<AgentScanStats> {
767    scan_all_agents_with_stats_since(ws, cfg, ws_str, store, 0, true)
768}
769
770fn scan_all_agents_with_stats_since(
771    ws: &Path,
772    cfg: &config::Config,
773    ws_str: &str,
774    store: &Store,
775    since_ms: u64,
776    enrich_repo: bool,
777) -> Result<AgentScanStats> {
778    let _spin = ScanSpinner::start("Scanning agent sessions…");
779    let sync_ctx = crate::sync::ingest_ctx(cfg, ws.to_path_buf());
780    let sessions = collect_all_agent_sessions_since(ws, cfg, ws_str, since_ms)?;
781    let stats = persist_session_batch_mode(store, sessions, sync_ctx.as_ref(), enrich_repo)?;
782    maybe_auto_prune_after_scan(store, cfg)?;
783    Ok(stats)
784}
785
786fn collect_all_agent_sessions_since(
787    ws: &Path,
788    cfg: &config::Config,
789    ws_str: &str,
790    since_ms: u64,
791) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
792    let mut out = Vec::new();
793    let slug = workspace_slug(ws_str);
794    let cursor_slug = crate::core::paths::cursor_slug(ws);
795    let claude_slug = crate::core::paths::claude_code_slug(ws);
796
797    for root in &cfg.scan.roots {
798        let expanded = expand_home(root);
799        let cursor_dir = PathBuf::from(&expanded)
800            .join(&cursor_slug)
801            .join("agent-transcripts");
802        out.extend(collect_agent_dirs(&cursor_dir, |p| {
803            scan_session_dir_all(p).map(|sessions| {
804                sessions
805                    .into_iter()
806                    .map(|(mut r, evs)| {
807                        r.workspace = ws_str.to_string();
808                        (r, evs)
809                    })
810                    .collect()
811            })
812        })?);
813    }
814
815    let home = std::env::var("HOME").unwrap_or_default();
816
817    let claude_project = PathBuf::from(&home)
818        .join(".claude/projects")
819        .join(&claude_slug);
820    out.extend(scan_claude_project_dir_since(
821        &claude_project,
822        ws,
823        since_ms,
824    )?);
825    let claude_dir = claude_project.join("sessions");
826    out.extend(collect_agent_dirs(&claude_dir, |p| {
827        scan_claude_session_dir(p).map(|(mut r, evs)| {
828            r.workspace = ws_str.to_string();
829            vec![(r, evs)]
830        })
831    })?);
832
833    let codex_dir = PathBuf::from(&home).join(".codex/sessions").join(&slug);
834    out.extend(collect_agent_dirs(&codex_dir, |p| {
835        scan_codex_session_dir(p).map(|(mut r, evs)| {
836            r.workspace = ws_str.to_string();
837            vec![(r, evs)]
838        })
839    })?);
840    out.extend(scan_codex_sessions_root_since(
841        &PathBuf::from(&home).join(".codex/sessions"),
842        ws,
843        since_ms,
844    )?);
845
846    let tail = &cfg.sources.tail;
847    let home_pb = PathBuf::from(&home);
848    if tail.gemini {
849        out.extend(bind_workspace(scan_gemini_workspace(ws), ws_str));
850    }
851    if tail.pi {
852        out.extend(bind_workspace(scan_pi_workspace(ws), ws_str));
853    }
854    if tail.kimi {
855        out.extend(bind_workspace(scan_kimi_workspace(ws), ws_str));
856    }
857    if tail.antigravity {
858        out.extend(bind_workspace(scan_antigravity_workspace(ws), ws_str));
859    }
860    if tail.cursor_state_db {
861        out.extend(bind_workspace(scan_cursor_state_db_workspace(ws), ws_str));
862    }
863    if tail.goose {
864        out.extend(scan_goose_workspace(&home_pb, ws)?);
865    }
866    if tail.openclaw {
867        out.extend(scan_openclaw_workspace(ws)?);
868    }
869    if tail.opencode {
870        out.extend(scan_opencode_workspace(ws)?);
871    }
872    if tail.copilot_cli {
873        out.extend(scan_copilot_cli_workspace(ws)?);
874    }
875    if tail.copilot_vscode {
876        out.extend(scan_copilot_vscode_workspace(ws)?);
877    }
878    Ok(out)
879}
880
881fn bind_workspace(
882    rows: Vec<(SessionRecord, Vec<Event>)>,
883    workspace: &str,
884) -> Vec<(SessionRecord, Vec<Event>)> {
885    rows.into_iter()
886        .map(|(mut record, events)| {
887            record.workspace = workspace.to_string();
888            (record, events)
889        })
890        .collect()
891}
892
893#[cfg(test)]
894pub(crate) fn persist_session_batch(
895    store: &Store,
896    sessions: Vec<(SessionRecord, Vec<Event>)>,
897    sync_ctx: Option<&crate::sync::SyncIngestContext>,
898) -> Result<AgentScanStats> {
899    persist_session_batch_mode(store, sessions, sync_ctx, true)
900}
901
902fn persist_session_batch_mode(
903    store: &Store,
904    sessions: Vec<(SessionRecord, Vec<Event>)>,
905    sync_ctx: Option<&crate::sync::SyncIngestContext>,
906    enrich_repo: bool,
907) -> Result<AgentScanStats> {
908    let mut stats = AgentScanStats::default();
909    for (mut record, events) in sessions {
910        stats.record(&record, events.len());
911        let existing = store.get_session(&record.id)?;
912        inherit_repo_binding(&mut record, existing.as_ref());
913        enrich_repo_binding(&mut record, enrich_repo);
914        store.upsert_session(&record)?;
915        let last_seq = store.last_event_seq_for_session(&record.id)?;
916        let flush_ms = record.ended_at_ms.unwrap_or(record.started_at_ms);
917        let unseen = events
918            .into_iter()
919            .filter(|event| last_seq.is_none_or(|seq| event.seq > seq))
920            .collect::<Vec<_>>();
921        let flush = (record.status == crate::core::event::SessionStatus::Done).then_some(flush_ms);
922        stats.events_upserted += store.append_scanned_event_batch(&unseen, sync_ctx, flush)? as u64;
923    }
924    Ok(stats)
925}
926
927fn inherit_repo_binding(record: &mut SessionRecord, existing: Option<&SessionRecord>) {
928    let Some(existing) = existing else { return };
929    record.start_commit = record
930        .start_commit
931        .take()
932        .or_else(|| existing.start_commit.clone());
933    record.end_commit = record
934        .end_commit
935        .take()
936        .or_else(|| existing.end_commit.clone());
937    record.branch = record.branch.take().or_else(|| existing.branch.clone());
938    record.dirty_start = record.dirty_start.or(existing.dirty_start);
939    record.dirty_end = record.dirty_end.or(existing.dirty_end);
940    record.repo_binding_source = record
941        .repo_binding_source
942        .take()
943        .or_else(|| existing.repo_binding_source.clone());
944}
945
946fn enrich_repo_binding(record: &mut SessionRecord, enabled: bool) {
947    if !enabled || record.start_commit.is_some() || record.workspace.is_empty() {
948        return;
949    }
950    let binding = crate::core::repo::binding_for_session(
951        Path::new(&record.workspace),
952        record.started_at_ms,
953        record.ended_at_ms,
954    );
955    record.start_commit = binding.start_commit;
956    record.end_commit = binding.end_commit;
957    record.branch = binding.branch;
958    record.dirty_start = binding.dirty_start;
959    record.dirty_end = binding.dirty_end;
960    record.repo_binding_source = binding.source;
961}
962
963pub(crate) fn collect_agent_dirs<F>(
964    dir: &Path,
965    scanner: F,
966) -> Result<Vec<(SessionRecord, Vec<Event>)>>
967where
968    F: Fn(&Path) -> Result<Vec<(SessionRecord, Vec<Event>)>>,
969{
970    if !dir.exists() {
971        return Ok(Vec::new());
972    }
973    let paths = std::fs::read_dir(dir)?
974        .filter_map(|entry| entry.ok())
975        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
976        .map(|entry| entry.path())
977        .collect();
978    let mut out = Vec::new();
979    for path in crate::collect::tail::newest_paths(paths) {
980        match scanner(&path) {
981            Ok(sessions) => out.extend(sessions),
982            Err(e) => tracing::warn!("scan {path:?}: {e}"),
983        }
984    }
985    Ok(out)
986}
987
988pub(crate) fn workspace_path(workspace: Option<&Path>) -> Result<PathBuf> {
989    crate::core::workspace::resolve_read(workspace)
990}
991
992/// Resolve workspace from `--workspace` or `--project` (mutually exclusive at clap level).
993///
994/// Returns `(canonical_path, how_it_was_selected)`.
995pub fn resolve_target(
996    workspace: Option<&Path>,
997    project: Option<&str>,
998) -> Result<(PathBuf, crate::shell::scope::ScopeOrigin)> {
999    use crate::shell::scope::ScopeOrigin;
1000    if let Some(name) = project {
1001        let path = crate::core::workspace::resolve_project_name(name)?;
1002        return Ok((path, ScopeOrigin::ExplicitProject(name.to_owned())));
1003    }
1004    let path = crate::core::workspace::resolve_read(workspace)?;
1005    let origin = if workspace.is_some() {
1006        ScopeOrigin::ExplicitWorkspace
1007    } else {
1008        ScopeOrigin::Cwd
1009    };
1010    Ok((path, origin))
1011}
1012
1013/// Convert workspace path string to cursor project slug.
1014pub(crate) fn workspace_slug(ws: &str) -> String {
1015    crate::core::paths::workspace_slug(std::path::Path::new(ws))
1016}
1017
1018pub(crate) fn expand_home(path: &str) -> String {
1019    if let (Some(rest), Ok(home)) = (path.strip_prefix("~/"), std::env::var("HOME")) {
1020        return format!("{home}/{rest}");
1021    }
1022    path.to_string()
1023}
1024
1025#[cfg(test)]
1026mod cost_rollup_note_tests {
1027    use super::*;
1028
1029    #[test]
1030    fn needs_note_only_when_sessions_and_zero_cost() {
1031        assert!(summary_needs_cost_rollup_note(1, 0));
1032        assert!(!summary_needs_cost_rollup_note(0, 0));
1033        assert!(!summary_needs_cost_rollup_note(1, 1));
1034    }
1035
1036    #[test]
1037    fn paragraph_names_gap_and_doc_anchor() {
1038        let s = cost_rollup_zero_note_paragraph();
1039        assert!(s.contains("cost_usd_e6"));
1040        assert!(s.contains("usage"));
1041        assert!(s.contains("docs/usage.md#cost-shows-zero"));
1042    }
1043}