lean_ctx/core/session_summary/
generate.rs1use crate::core::session::SessionState;
7
8use super::record::SummaryCandidate;
9
10const MAX_FILES: usize = 12;
11const MAX_DECISIONS: usize = 6;
12const MAX_FINDINGS: usize = 6;
13const MAX_NEXT: usize = 6;
14
15pub fn build_candidate(session: &SessionState) -> SummaryCandidate {
17 let title = session
18 .task
19 .as_ref()
20 .map(|t| t.description.trim().to_string())
21 .filter(|d| !d.is_empty())
22 .unwrap_or_else(|| inferred_title(session));
23
24 let files: Vec<String> = session
25 .files_touched
26 .iter()
27 .map(|f| f.path.clone())
28 .take(MAX_FILES)
29 .collect();
30 let decisions: Vec<String> = session
31 .decisions
32 .iter()
33 .rev()
34 .map(|d| d.summary.trim().to_string())
35 .filter(|s| !s.is_empty())
36 .take(MAX_DECISIONS)
37 .collect();
38 let findings: Vec<String> = session
39 .findings
40 .iter()
41 .rev()
42 .map(|f| f.summary.trim().to_string())
43 .filter(|s| !s.is_empty())
44 .take(MAX_FINDINGS)
45 .collect();
46 let next_steps: Vec<String> = session
47 .next_steps
48 .iter()
49 .map(|s| s.trim().to_string())
50 .filter(|s| !s.is_empty())
51 .take(MAX_NEXT)
52 .collect();
53
54 let body = render_body(session, &title, &files, &decisions, &findings, &next_steps);
55 let has_content = session.task.is_some()
56 || !files.is_empty()
57 || !decisions.is_empty()
58 || !findings.is_empty();
59
60 SummaryCandidate {
61 session_id: session.id.clone(),
62 created_at: chrono::Utc::now(),
63 title,
64 body,
65 files,
66 decisions,
67 next_steps,
68 tool_calls: u64::from(session.stats.total_tool_calls),
69 has_content,
70 }
71}
72
73fn inferred_title(session: &SessionState) -> String {
74 if let Some(modified) = session.files_touched.iter().find(|f| f.modified) {
75 return format!("Worked on {}", short_path(&modified.path));
76 }
77 if let Some(first) = session.files_touched.first() {
78 return format!("Explored {}", short_path(&first.path));
79 }
80 "Session".to_string()
81}
82
83fn short_path(path: &str) -> String {
84 path.rsplit('/').next().unwrap_or(path).to_string()
85}
86
87fn render_body(
88 session: &SessionState,
89 title: &str,
90 files: &[String],
91 decisions: &[String],
92 findings: &[String],
93 next_steps: &[String],
94) -> String {
95 let mut out = String::new();
96 if let Some(task) = &session.task {
97 let pct = task
98 .progress_pct
99 .map(|p| format!(" ({p}%)"))
100 .unwrap_or_default();
101 out.push_str(&format!("Task: {}{}\n", task.description.trim(), pct));
102 } else {
103 out.push_str(&format!("Focus: {title}\n"));
104 }
105
106 let modified: Vec<&String> = session
107 .files_touched
108 .iter()
109 .filter(|f| f.modified)
110 .map(|f| &f.path)
111 .collect();
112 if !modified.is_empty() {
113 out.push_str(&format!(
114 "Modified ({}): {}\n",
115 modified.len(),
116 join_short(modified.iter().map(|s| s.as_str()), 8)
117 ));
118 }
119 if !files.is_empty() {
120 out.push_str(&format!(
121 "Touched ({}): {}\n",
122 files.len(),
123 join_short(files.iter().map(String::as_str), 8)
124 ));
125 }
126 if !decisions.is_empty() {
127 out.push_str("Decisions:\n");
128 for d in decisions {
129 out.push_str(&format!(" - {d}\n"));
130 }
131 }
132 if !findings.is_empty() {
133 out.push_str("Findings:\n");
134 for f in findings {
135 out.push_str(&format!(" - {f}\n"));
136 }
137 }
138 if !next_steps.is_empty() {
139 out.push_str("Next:\n");
140 for n in next_steps {
141 out.push_str(&format!(" - {n}\n"));
142 }
143 }
144 out.push_str(&format!(
145 "Stats: {} tool calls, {} tokens saved\n",
146 session.stats.total_tool_calls, session.stats.total_tokens_saved
147 ));
148 out
149}
150
151fn join_short<'a>(paths: impl Iterator<Item = &'a str>, max: usize) -> String {
152 let names: Vec<String> = paths.take(max).map(short_path).collect();
153 names.join(", ")
154}