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