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