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