1use 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
81pub(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
125const 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
144pub(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
207pub 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
329pub 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
344pub 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
415pub 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
475pub 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
492pub 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
498pub fn sessions_trace_text(id: &str, json: bool, workspace: Option<&Path>) -> Result<String> {
499 let ws = workspace_path(workspace)?;
500 let store = open_workspace_read_store(&ws, false)?;
501 let spans = store.trace_spans_for_session(id)?;
502 if json {
503 return Ok(format!("{}\n", serde_json::to_string_pretty(&spans)?));
504 }
505 if spans.is_empty() {
506 if store.get_session(id)?.is_none() {
507 anyhow::bail!("session not found: {id}");
508 }
509 return Ok(format!("(no trace spans for session {id})\n"));
510 }
511 Ok(format_trace_spans(&spans))
512}
513
514fn format_trace_spans(spans: &[crate::core::trace_span::TraceSpanRecord]) -> String {
515 use std::fmt::Write;
516 let mut out = String::new();
517 writeln!(
518 &mut out,
519 "{:<10} {:<18} {:<8} DURATION",
520 "KIND", "NAME", "STATUS"
521 )
522 .unwrap();
523 writeln!(&mut out, "{}", "-".repeat(64)).unwrap();
524 for span in spans {
525 let ms = span
526 .duration_ms
527 .map(|v| v.to_string())
528 .unwrap_or("-".into());
529 writeln!(
530 &mut out,
531 "{:<10} {:<18} {:<8} {}ms",
532 span.kind.as_str(),
533 span.name,
534 span.status,
535 ms
536 )
537 .unwrap();
538 }
539 out
540}
541
542pub fn cmd_sessions_trace(id: &str, json: bool, workspace: Option<&Path>) -> Result<()> {
543 print!("{}", sessions_trace_text(id, json, workspace)?);
544 Ok(())
545}
546
547pub fn summary_text(
549 workspace: Option<&Path>,
550 json_out: bool,
551 refresh: bool,
552 all_workspaces: bool,
553 source: crate::core::data_source::DataSource,
554) -> Result<String> {
555 let roots = scope::resolve(workspace, all_workspaces)?;
556 let mut total_cost_usd_e6 = 0_i64;
557 let mut session_count = 0_u64;
558 let mut by_agent = Vec::new();
559 let mut by_model = Vec::new();
560 let mut top_tools = Vec::new();
561 let mut hottest = Vec::new();
562 let mut slowest = Vec::new();
563
564 for workspace in &roots {
565 let cfg = config::load(workspace)?;
566 let store = open_workspace_read_store(
567 workspace,
568 refresh || source != crate::core::data_source::DataSource::Local,
569 )?;
570 crate::shell::remote_pull::maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
571 maybe_refresh_store(workspace, &store, refresh)?;
572 let ws_str = workspace.to_string_lossy().to_string();
573 let read_store = open_workspace_read_store(workspace, false)?;
574 let query = crate::store::query::QueryStore::open(&crate::core::paths::project_data_dir(
575 workspace,
576 )?)?;
577 let mut stats = query.summary_stats(&read_store, &ws_str)?;
578 if source != crate::core::data_source::DataSource::Local
579 && let Ok(Some(agg)) =
580 crate::shell::remote_observe::try_remote_event_agg(&read_store, &cfg, workspace)
581 {
582 stats = crate::shell::remote_observe::merge_summary_stats(stats, &agg, source);
583 }
584 total_cost_usd_e6 += stats.total_cost_usd_e6;
585 session_count += stats.session_count;
586 by_agent.push(stats.by_agent);
587 by_model.push(stats.by_model);
588 top_tools.push(stats.top_tools);
589 if let Ok(metrics) = report::build_report(&read_store, &ws_str, 7) {
590 if let Some(file) = metrics.hottest_files.first().cloned() {
591 hottest.push(if roots.len() == 1 {
592 file
593 } else {
594 crate::metrics::types::RankedFile {
595 path: scope::decorate_path(workspace, &file.path),
596 ..file
597 }
598 });
599 }
600 if let Some(tool) = metrics.slowest_tools.first().cloned() {
601 slowest.push(tool);
602 }
603 }
604 }
605
606 let stats = crate::store::SummaryStats {
607 session_count,
608 total_cost_usd_e6,
609 by_agent: combine_counts(by_agent),
610 by_model: combine_counts(by_model),
611 top_tools: combine_counts(top_tools),
612 };
613 let cost_dollars = stats.total_cost_usd_e6 as f64 / 1_000_000.0;
614 let hotspot = hottest
615 .into_iter()
616 .max_by(|a, b| a.value.cmp(&b.value).then_with(|| b.path.cmp(&a.path)));
617 let slowest_tool = slowest.into_iter().max_by(|a, b| {
618 a.p95_ms
619 .unwrap_or(0)
620 .cmp(&b.p95_ms.unwrap_or(0))
621 .then_with(|| b.tool.cmp(&a.tool))
622 });
623 let scope_label = scope::label(&roots);
624 let workspaces = if roots.len() > 1 {
625 workspace_names(&roots)
626 } else {
627 Vec::new()
628 };
629 let cost_note = summary_needs_cost_rollup_note(stats.session_count, stats.total_cost_usd_e6)
630 .then_some(cost_rollup_zero_note_paragraph().to_string());
631 if json_out {
632 return Ok(format!(
633 "{}\n",
634 serde_json::to_string_pretty(&SummaryJsonOut {
635 workspace: scope_label,
636 workspaces,
637 cost_usd: cost_dollars,
638 stats,
639 cost_note,
640 hotspot,
641 slowest_tool,
642 })?
643 ));
644 }
645 use std::fmt::Write;
646 let mut out = String::new();
647 if roots.len() > 1 {
648 writeln!(&mut out, "Scope: {}", scope::label(&roots)).unwrap();
649 }
650 writeln!(
651 &mut out,
652 "Sessions: {} Cost: ${:.2}",
653 stats.session_count, cost_dollars
654 )
655 .unwrap();
656
657 if !stats.by_agent.is_empty() {
658 let parts: Vec<String> = stats
659 .by_agent
660 .iter()
661 .map(|(a, n)| format!("{a} {n}"))
662 .collect();
663 writeln!(&mut out, "By agent: {}", parts.join(" · ")).unwrap();
664 }
665 if !stats.by_model.is_empty() {
666 let parts: Vec<String> = stats
667 .by_model
668 .iter()
669 .map(|(m, n)| format!("{m} {n}"))
670 .collect();
671 writeln!(&mut out, "By model: {}", parts.join(" · ")).unwrap();
672 }
673 if !stats.top_tools.is_empty() {
674 let parts: Vec<String> = stats
675 .top_tools
676 .iter()
677 .take(5)
678 .map(|(t, n)| format!("{t} {n}"))
679 .collect();
680 writeln!(&mut out, "Top tools: {}", parts.join(" · ")).unwrap();
681 }
682 if let Some(file) = hotspot {
683 writeln!(&mut out, "Hotspot: {} ({})", file.path, file.value).unwrap();
684 }
685 if let Some(tool) = slowest_tool {
686 let p95 = tool
687 .p95_ms
688 .map(|v| format!("{v}ms"))
689 .unwrap_or_else(|| "-".into());
690 writeln!(&mut out, "Slowest: {} p95 {}", tool.tool, p95).unwrap();
691 }
692 if cost_note.is_some() {
693 writeln!(&mut out).unwrap();
694 writeln!(&mut out, "Note: {}", cost_rollup_zero_note_paragraph()).unwrap();
695 }
696 Ok(out)
697}
698
699pub fn cmd_summary(
701 workspace: Option<&Path>,
702 json_out: bool,
703 refresh: bool,
704 all_workspaces: bool,
705 source: crate::core::data_source::DataSource,
706) -> Result<()> {
707 print!(
708 "{}",
709 summary_text(workspace, json_out, refresh, all_workspaces, source,)?
710 );
711 Ok(())
712}
713
714pub(crate) fn scan_all_agents(
715 ws: &Path,
716 cfg: &config::Config,
717 ws_str: &str,
718 store: &Store,
719) -> Result<()> {
720 scan_all_agents_with_stats(ws, cfg, ws_str, store).map(|_| ())
721}
722
723pub(crate) fn scan_all_agents_with_stats(
724 ws: &Path,
725 cfg: &config::Config,
726 ws_str: &str,
727 store: &Store,
728) -> Result<AgentScanStats> {
729 let _spin = ScanSpinner::start("Scanning agent sessions…");
730 let sync_ctx = crate::sync::ingest_ctx(cfg, ws.to_path_buf());
731 let sessions = collect_all_agent_sessions(ws, cfg, ws_str)?;
732 let stats = persist_session_batch(store, sessions, sync_ctx.as_ref())?;
733 maybe_auto_prune_after_scan(store, cfg)?;
734 Ok(stats)
735}
736
737pub(crate) fn collect_all_agent_sessions(
738 ws: &Path,
739 cfg: &config::Config,
740 ws_str: &str,
741) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
742 let mut out = Vec::new();
743 let slug = workspace_slug(ws_str);
744 let cursor_slug = crate::core::paths::cursor_slug(ws);
745 let claude_slug = crate::core::paths::claude_code_slug(ws);
746
747 for root in &cfg.scan.roots {
748 let expanded = expand_home(root);
749 let cursor_dir = PathBuf::from(&expanded)
750 .join(&cursor_slug)
751 .join("agent-transcripts");
752 out.extend(collect_agent_dirs(&cursor_dir, |p| {
753 scan_session_dir_all(p).map(|sessions| {
754 sessions
755 .into_iter()
756 .map(|(mut r, evs)| {
757 r.workspace = ws_str.to_string();
758 (r, evs)
759 })
760 .collect()
761 })
762 })?);
763 }
764
765 let home = std::env::var("HOME").unwrap_or_default();
766
767 let claude_project = PathBuf::from(&home)
768 .join(".claude/projects")
769 .join(&claude_slug);
770 out.extend(scan_claude_project_dir(&claude_project, ws)?);
771 let claude_dir = claude_project.join("sessions");
772 out.extend(collect_agent_dirs(&claude_dir, |p| {
773 scan_claude_session_dir(p).map(|(mut r, evs)| {
774 r.workspace = ws_str.to_string();
775 vec![(r, evs)]
776 })
777 })?);
778
779 let codex_dir = PathBuf::from(&home).join(".codex/sessions").join(&slug);
780 out.extend(collect_agent_dirs(&codex_dir, |p| {
781 scan_codex_session_dir(p).map(|(mut r, evs)| {
782 r.workspace = ws_str.to_string();
783 vec![(r, evs)]
784 })
785 })?);
786 out.extend(scan_codex_sessions_root(
787 &PathBuf::from(&home).join(".codex/sessions"),
788 ws,
789 )?);
790
791 let tail = &cfg.sources.tail;
792 let home_pb = PathBuf::from(&home);
793 if tail.goose {
794 out.extend(scan_goose_workspace(&home_pb, ws)?);
795 }
796 if tail.openclaw {
797 out.extend(scan_openclaw_workspace(ws)?);
798 }
799 if tail.opencode {
800 out.extend(scan_opencode_workspace(ws)?);
801 }
802 if tail.copilot_cli {
803 out.extend(scan_copilot_cli_workspace(ws)?);
804 }
805 if tail.copilot_vscode {
806 out.extend(scan_copilot_vscode_workspace(ws)?);
807 }
808 Ok(out)
809}
810
811pub(crate) fn persist_session_batch(
812 store: &Store,
813 sessions: Vec<(SessionRecord, Vec<Event>)>,
814 sync_ctx: Option<&crate::sync::SyncIngestContext>,
815) -> Result<AgentScanStats> {
816 let mut stats = AgentScanStats::default();
817 for (mut record, events) in sessions {
818 stats.record(&record, events.len());
819 if record.start_commit.is_none() && !record.workspace.is_empty() {
820 let binding = crate::core::repo::binding_for_session(
821 Path::new(&record.workspace),
822 record.started_at_ms,
823 record.ended_at_ms,
824 );
825 record.start_commit = binding.start_commit;
826 record.end_commit = binding.end_commit;
827 record.branch = binding.branch;
828 record.dirty_start = binding.dirty_start;
829 record.dirty_end = binding.dirty_end;
830 record.repo_binding_source = binding.source;
831 }
832 store.upsert_session(&record)?;
833 let flush_ms = record.ended_at_ms.unwrap_or(record.started_at_ms);
834 for ev in events {
835 store.append_event_with_sync(&ev, sync_ctx)?;
836 }
837 if record.status == crate::core::event::SessionStatus::Done {
838 store.flush_projector_session(&record.id, flush_ms)?;
839 }
840 }
841 Ok(stats)
842}
843
844pub(crate) fn collect_agent_dirs<F>(
845 dir: &Path,
846 scanner: F,
847) -> Result<Vec<(SessionRecord, Vec<Event>)>>
848where
849 F: Fn(&Path) -> Result<Vec<(SessionRecord, Vec<Event>)>>,
850{
851 if !dir.exists() {
852 return Ok(Vec::new());
853 }
854 let mut out = Vec::new();
855 for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
856 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
857 continue;
858 }
859 match scanner(&entry.path()) {
860 Ok(sessions) => out.extend(sessions),
861 Err(e) => tracing::warn!("scan {:?}: {e}", entry.path()),
862 }
863 }
864 Ok(out)
865}
866
867pub(crate) fn workspace_path(workspace: Option<&Path>) -> Result<PathBuf> {
868 crate::core::workspace::resolve(workspace)
869}
870
871pub fn resolve_target(
875 workspace: Option<&Path>,
876 project: Option<&str>,
877) -> Result<(PathBuf, crate::shell::scope::ScopeOrigin)> {
878 use crate::shell::scope::ScopeOrigin;
879 if let Some(name) = project {
880 let path = crate::core::workspace::resolve_project_name(name)?;
881 return Ok((path, ScopeOrigin::ExplicitProject(name.to_owned())));
882 }
883 let path = crate::core::workspace::resolve(workspace)?;
884 let origin = if workspace.is_some() {
885 ScopeOrigin::ExplicitWorkspace
886 } else {
887 ScopeOrigin::Cwd
888 };
889 Ok((path, origin))
890}
891
892pub(crate) fn workspace_slug(ws: &str) -> String {
894 crate::core::paths::workspace_slug(std::path::Path::new(ws))
895}
896
897pub(crate) fn expand_home(path: &str) -> String {
898 if let (Some(rest), Ok(home)) = (path.strip_prefix("~/"), std::env::var("HOME")) {
899 return format!("{home}/{rest}");
900 }
901 path.to_string()
902}
903
904#[cfg(test)]
905mod cost_rollup_note_tests {
906 use super::*;
907
908 #[test]
909 fn needs_note_only_when_sessions_and_zero_cost() {
910 assert!(summary_needs_cost_rollup_note(1, 0));
911 assert!(!summary_needs_cost_rollup_note(0, 0));
912 assert!(!summary_needs_cost_rollup_note(1, 1));
913 }
914
915 #[test]
916 fn paragraph_names_gap_and_doc_anchor() {
917 let s = cost_rollup_zero_note_paragraph();
918 assert!(s.contains("cost_usd_e6"));
919 assert!(s.contains("usage"));
920 assert!(s.contains("docs/usage.md#cost-shows-zero"));
921 }
922}