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