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