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