Skip to main content

lean_ctx/core/session_summary/
generate.rs

1//! Build a deterministic session-summary candidate from `SessionState` (#292).
2//!
3//! No LLM, no randomness: the same session always yields the same summary, which
4//! is what makes the benchmark/recall reproducible.
5
6use 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
15/// Build an owned candidate snapshot of the current session.
16pub 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}