Skip to main content

lean_ctx/core/session/
compaction.rs

1use std::path::PathBuf;
2
3use crate::core::graph_context;
4
5use super::paths::{
6    escape_xml_attr, file_stem_search_pattern, parent_dir_slash, sessions_dir, shorten_path,
7};
8use super::types::SessionState;
9
10impl SessionState {
11    /// Formats the session state as a compact multi-line summary for agent context.
12    pub fn format_compact(&self) -> String {
13        let duration = self.updated_at - self.started_at;
14        let hours = duration.num_hours();
15        let mins = duration.num_minutes() % 60;
16        let duration_str = if hours > 0 {
17            format!("{hours}h {mins}m")
18        } else {
19            format!("{mins}m")
20        };
21
22        let mut lines = Vec::new();
23        lines.push(format!(
24            "SESSION v{} | {} | {} calls | {} tok saved",
25            self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
26        ));
27
28        if let Some(ref task) = self.task {
29            let pct = task
30                .progress_pct
31                .map_or(String::new(), |p| format!(" [{p}%]"));
32            lines.push(format!("Task: {}{pct}", task.description));
33        }
34
35        if let Some(ref root) = self.project_root {
36            lines.push(format!("Root: {}", shorten_path(root)));
37        }
38
39        if !self.findings.is_empty() {
40            let items: Vec<String> = self
41                .findings
42                .iter()
43                .rev()
44                .take(5)
45                .map(|f| {
46                    let loc = match (&f.file, f.line) {
47                        (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
48                        (Some(file), None) => shorten_path(file),
49                        _ => String::new(),
50                    };
51                    if loc.is_empty() {
52                        f.summary.clone()
53                    } else {
54                        format!("{loc} \u{2014} {}", f.summary)
55                    }
56                })
57                .collect();
58            lines.push(format!(
59                "Findings ({}): {}",
60                self.findings.len(),
61                items.join(" | ")
62            ));
63        }
64
65        if !self.decisions.is_empty() {
66            let items: Vec<&str> = self
67                .decisions
68                .iter()
69                .rev()
70                .take(3)
71                .map(|d| d.summary.as_str())
72                .collect();
73            lines.push(format!("Decisions: {}", items.join(" | ")));
74        }
75
76        if !self.files_touched.is_empty() {
77            let items: Vec<String> = self
78                .files_touched
79                .iter()
80                .rev()
81                .take(10)
82                .map(|f| {
83                    let status = if f.modified { "mod" } else { &f.last_mode };
84                    let r = f.file_ref.as_deref().unwrap_or("?");
85                    format!("[{r} {} {status}]", shorten_path(&f.path))
86                })
87                .collect();
88            lines.push(format!(
89                "Files ({}): {}",
90                self.files_touched.len(),
91                items.join(" ")
92            ));
93        }
94
95        if let Some(ref tests) = self.test_results {
96            lines.push(format!(
97                "Tests: {}/{} pass ({})",
98                tests.passed, tests.total, tests.command
99            ));
100        }
101
102        if !self.next_steps.is_empty() {
103            lines.push(format!("Next: {}", self.next_steps.join(" | ")));
104        }
105
106        lines.join("\n")
107    }
108
109    /// Builds a size-limited XML snapshot of session state for context compaction.
110    pub fn build_compaction_snapshot(&self) -> String {
111        const MAX_SNAPSHOT_BYTES: usize = 2048;
112
113        let mut sections: Vec<(u8, String)> = Vec::new();
114
115        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
116            .unwrap_or_default();
117        if let Some(tag) = crate::core::terse::agent_prompts::session_context_tag(&level) {
118            sections.push((0, tag));
119        }
120
121        if let Some(ref task) = self.task {
122            let pct = task
123                .progress_pct
124                .map_or(String::new(), |p| format!(" [{p}%]"));
125            sections.push((1, format!("<task>{}{pct}</task>", task.description)));
126        }
127
128        if !self.files_touched.is_empty() {
129            let modified: Vec<&str> = self
130                .files_touched
131                .iter()
132                .filter(|f| f.modified)
133                .map(|f| f.path.as_str())
134                .collect();
135            let read_only: Vec<&str> = self
136                .files_touched
137                .iter()
138                .filter(|f| !f.modified)
139                .take(10)
140                .map(|f| f.path.as_str())
141                .collect();
142            let mut files_section = String::new();
143            if !modified.is_empty() {
144                files_section.push_str(&format!("Modified: {}", modified.join(", ")));
145            }
146            if !read_only.is_empty() {
147                if !files_section.is_empty() {
148                    files_section.push_str(" | ");
149                }
150                files_section.push_str(&format!("Read: {}", read_only.join(", ")));
151            }
152            sections.push((1, format!("<files>{files_section}</files>")));
153        }
154
155        if !self.decisions.is_empty() {
156            let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
157            sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
158        }
159
160        if !self.findings.is_empty() {
161            let items: Vec<String> = self
162                .findings
163                .iter()
164                .rev()
165                .take(5)
166                .map(|f| f.summary.clone())
167                .collect();
168            sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
169        }
170
171        if !self.progress.is_empty() {
172            let items: Vec<String> = self
173                .progress
174                .iter()
175                .rev()
176                .take(5)
177                .map(|p| {
178                    let detail = p.detail.as_deref().unwrap_or("");
179                    if detail.is_empty() {
180                        p.action.clone()
181                    } else {
182                        format!("{}: {detail}", p.action)
183                    }
184                })
185                .collect();
186            sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
187        }
188
189        if let Some(ref tests) = self.test_results {
190            sections.push((
191                3,
192                format!(
193                    "<tests>{}/{} pass ({})</tests>",
194                    tests.passed, tests.total, tests.command
195                ),
196            ));
197        }
198
199        if !self.next_steps.is_empty() {
200            sections.push((
201                3,
202                format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
203            ));
204        }
205
206        sections.push((
207            4,
208            format!(
209                "<stats>calls={} saved={}tok</stats>",
210                self.stats.total_tool_calls, self.stats.total_tokens_saved
211            ),
212        ));
213
214        sections.sort_by_key(|(priority, _)| *priority);
215
216        const SNAPSHOT_HARD_CAP: usize = 2200;
217        const CLOSE_TAG: &str = "</session_snapshot>";
218        let open_len = "<session_snapshot>\n".len();
219        let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
220
221        let mut snapshot = String::from("<session_snapshot>\n");
222        for (_, section) in &sections {
223            if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
224                break;
225            }
226            snapshot.push_str(section);
227            snapshot.push('\n');
228        }
229
230        let used = snapshot.len().saturating_sub(open_len);
231        let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
232        if suffix_budget > 64 {
233            let suffix = self.build_compaction_structured_suffix(suffix_budget);
234            if !suffix.is_empty() {
235                snapshot.push_str(&suffix);
236                if !suffix.ends_with('\n') {
237                    snapshot.push('\n');
238                }
239            }
240        }
241
242        snapshot.push_str(CLOSE_TAG);
243        snapshot
244    }
245
246    fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
247        if max_bytes <= 64 {
248            return String::new();
249        }
250
251        let mut recovery_queries: Vec<String> = Vec::new();
252        for ft in self.files_touched.iter().rev().take(12) {
253            let path_esc = escape_xml_attr(&ft.path);
254            let mode = if ft.last_mode.is_empty() {
255                "map".to_string()
256            } else {
257                escape_xml_attr(&ft.last_mode)
258            };
259            recovery_queries.push(format!(
260                r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
261            ));
262            let pattern = file_stem_search_pattern(&ft.path);
263            if !pattern.is_empty() {
264                let search_dir = parent_dir_slash(&ft.path);
265                let pat_esc = escape_xml_attr(&pattern);
266                let dir_esc = escape_xml_attr(&search_dir);
267                recovery_queries.push(format!(
268                    r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
269                ));
270            }
271        }
272
273        let mut parts: Vec<String> = Vec::new();
274        if !recovery_queries.is_empty() {
275            parts.push(format!(
276                "<recovery_queries>\n{}\n</recovery_queries>",
277                recovery_queries.join("\n")
278            ));
279        }
280
281        let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
282        if knowledge_ok {
283            if let Some(q) = self.knowledge_recall_query_stem() {
284                let q_esc = escape_xml_attr(&q);
285                parts.push(format!(
286                    "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
287                ));
288            }
289        }
290
291        if let Some(root) = self
292            .project_root
293            .as_deref()
294            .filter(|r| !r.trim().is_empty())
295        {
296            let root_trim = root.trim_end_matches('/');
297            let mut cluster_lines: Vec<String> = Vec::new();
298            for ft in self.files_touched.iter().rev().take(3) {
299                let primary_esc = escape_xml_attr(&ft.path);
300                let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
301                let related_csv =
302                    graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
303                        .map(|s| escape_xml_attr(&s))
304                        .unwrap_or_default();
305                if related_csv.is_empty() {
306                    continue;
307                }
308                cluster_lines.push(format!(
309                    r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
310                ));
311            }
312            if !cluster_lines.is_empty() {
313                parts.push(format!(
314                    "<graph_context>\n{}\n</graph_context>",
315                    cluster_lines.join("\n")
316                ));
317            }
318        }
319
320        Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
321    }
322
323    fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
324        let mut out = parts.join("\n");
325        while out.len() > max_bytes && !parts.is_empty() {
326            parts.pop();
327            out = parts.join("\n");
328        }
329        if out.len() <= max_bytes {
330            return out;
331        }
332        if let Some(idx) = parts
333            .iter()
334            .position(|p| p.starts_with("<recovery_queries>"))
335        {
336            let mut lines: Vec<String> = parts[idx]
337                .lines()
338                .filter(|l| l.starts_with("<query "))
339                .map(str::to_string)
340                .collect();
341            while !lines.is_empty() && out.len() > max_bytes {
342                if lines.len() == 1 {
343                    parts.remove(idx);
344                    out = parts.join("\n");
345                    break;
346                }
347                lines.truncate(lines.len().saturating_sub(2));
348                parts[idx] = format!(
349                    "<recovery_queries>\n{}\n</recovery_queries>",
350                    lines.join("\n")
351                );
352                out = parts.join("\n");
353            }
354        }
355        if out.len() > max_bytes {
356            return String::new();
357        }
358        out
359    }
360
361    fn knowledge_recall_query_stem(&self) -> Option<String> {
362        let mut bits: Vec<String> = Vec::new();
363        if let Some(ref t) = self.task {
364            bits.push(Self::task_keyword_stem(&t.description));
365        }
366        if bits.iter().all(std::string::String::is_empty) {
367            if let Some(f) = self.findings.last() {
368                bits.push(Self::task_keyword_stem(&f.summary));
369            } else if let Some(d) = self.decisions.last() {
370                bits.push(Self::task_keyword_stem(&d.summary));
371            }
372        }
373        let q = bits.join(" ").trim().to_string();
374        if q.is_empty() {
375            None
376        } else {
377            Some(q)
378        }
379    }
380
381    fn task_keyword_stem(text: &str) -> String {
382        const STOP: &[&str] = &[
383            "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
384            "be", "this", "that", "it", "as", "at", "by", "from",
385        ];
386        text.split_whitespace()
387            .filter_map(|w| {
388                let w = w.trim_matches(|c: char| !c.is_alphanumeric());
389                if w.len() < 3 {
390                    return None;
391                }
392                let lower = w.to_lowercase();
393                if STOP.contains(&lower.as_str()) {
394                    return None;
395                }
396                Some(w.to_string())
397            })
398            .take(8)
399            .collect::<Vec<_>>()
400            .join(" ")
401    }
402
403    /// Writes the compaction snapshot to disk and returns the snapshot string.
404    pub fn save_compaction_snapshot(&self) -> Result<String, String> {
405        let snapshot = self.build_compaction_snapshot();
406        let dir = sessions_dir().ok_or("cannot determine home directory")?;
407        if !dir.exists() {
408            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
409        }
410        let path = dir.join(format!("{}_snapshot.txt", self.id));
411        std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
412        Ok(snapshot)
413    }
414
415    /// Loads a previously saved compaction snapshot by session ID.
416    pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
417        let dir = sessions_dir()?;
418        let path = dir.join(format!("{session_id}_snapshot.txt"));
419        std::fs::read_to_string(&path).ok()
420    }
421
422    /// Loads the most recently modified compaction snapshot from disk.
423    ///
424    /// When a project root can be derived from CWD, only snapshots whose
425    /// embedded session data matches the project root are considered. This
426    /// prevents cross-project snapshot leakage.
427    pub fn load_latest_snapshot() -> Option<String> {
428        let dir = sessions_dir()?;
429        let project_root = std::env::current_dir()
430            .ok()
431            .map(|p| p.to_string_lossy().to_string());
432
433        let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
434            .ok()?
435            .filter_map(std::result::Result::ok)
436            .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
437            .filter_map(|e| {
438                let meta = e.metadata().ok()?;
439                let modified = meta.modified().ok()?;
440
441                if let Some(ref root) = project_root {
442                    let content = std::fs::read_to_string(e.path()).ok()?;
443                    if !content.contains(root) {
444                        return None;
445                    }
446                }
447
448                Some((modified, e.path()))
449            })
450            .collect();
451
452        snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
453        snapshots
454            .first()
455            .and_then(|(_, path)| std::fs::read_to_string(path).ok())
456    }
457
458    /// Build a compact resume block for post-compaction injection.
459    /// Max ~500 tokens. Includes task, decisions, files, and archive references.
460    pub fn build_resume_block(&self) -> String {
461        let mut parts: Vec<String> = Vec::new();
462
463        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
464            .unwrap_or_default();
465        if let Some(hint) = crate::core::terse::agent_prompts::resume_block_hint(&level) {
466            parts.push(hint);
467        }
468
469        if let Some(ref root) = self.project_root {
470            let short = root.rsplit('/').next().unwrap_or(root);
471            parts.push(format!("Project: {short}"));
472        }
473
474        if let Some(ref task) = self.task {
475            let pct = task
476                .progress_pct
477                .map_or(String::new(), |p| format!(" [{p}%]"));
478            parts.push(format!("Task: {}{pct}", task.description));
479        }
480
481        if !self.decisions.is_empty() {
482            let items: Vec<&str> = self
483                .decisions
484                .iter()
485                .rev()
486                .take(5)
487                .map(|d| d.summary.as_str())
488                .collect();
489            parts.push(format!("Decisions: {}", items.join("; ")));
490        }
491
492        if !self.files_touched.is_empty() {
493            let modified: Vec<&str> = self
494                .files_touched
495                .iter()
496                .filter(|f| f.modified)
497                .take(10)
498                .map(|f| f.path.as_str())
499                .collect();
500            if !modified.is_empty() {
501                parts.push(format!("Modified: {}", modified.join(", ")));
502            }
503        }
504
505        if !self.next_steps.is_empty() {
506            let steps: Vec<&str> = self
507                .next_steps
508                .iter()
509                .take(3)
510                .map(std::string::String::as_str)
511                .collect();
512            parts.push(format!("Next: {}", steps.join("; ")));
513        }
514
515        let archives = crate::core::archive::list_entries(Some(&self.id));
516        if !archives.is_empty() {
517            let hints: Vec<String> = archives
518                .iter()
519                .take(5)
520                .map(|a| format!("{}({})", a.id, a.tool))
521                .collect();
522            parts.push(format!("Archives: {}", hints.join(", ")));
523        }
524
525        parts.push(format!(
526            "Stats: {} calls, {} tok saved",
527            self.stats.total_tool_calls, self.stats.total_tokens_saved
528        ));
529
530        format!(
531            "--- SESSION RESUME (post-compaction) ---\n{}\n---",
532            parts.join("\n")
533        )
534    }
535}