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 {
291            if let Some(q) = self.knowledge_recall_query_stem() {
292                let q_esc = escape_xml_attr(&q);
293                parts.push(format!(
294                    "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
295                ));
296            }
297        }
298
299        if let Some(root) = self
300            .project_root
301            .as_deref()
302            .filter(|r| !r.trim().is_empty())
303        {
304            let root_trim = root.trim_end_matches('/');
305            let mut cluster_lines: Vec<String> = Vec::new();
306            for ft in self.files_touched.iter().rev().take(3) {
307                let primary_esc = escape_xml_attr(&ft.path);
308                let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
309                let related_csv =
310                    graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
311                        .map(|s| escape_xml_attr(&s))
312                        .unwrap_or_default();
313                if related_csv.is_empty() {
314                    continue;
315                }
316                cluster_lines.push(format!(
317                    r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
318                ));
319            }
320            if !cluster_lines.is_empty() {
321                parts.push(format!(
322                    "<graph_context>\n{}\n</graph_context>",
323                    cluster_lines.join("\n")
324                ));
325            }
326        }
327
328        Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
329    }
330
331    fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
332        let mut out = parts.join("\n");
333        while out.len() > max_bytes && !parts.is_empty() {
334            parts.pop();
335            out = parts.join("\n");
336        }
337        if out.len() <= max_bytes {
338            return out;
339        }
340        if let Some(idx) = parts
341            .iter()
342            .position(|p| p.starts_with("<recovery_queries>"))
343        {
344            let mut lines: Vec<String> = parts[idx]
345                .lines()
346                .filter(|l| l.starts_with("<query "))
347                .map(str::to_string)
348                .collect();
349            while !lines.is_empty() && out.len() > max_bytes {
350                if lines.len() == 1 {
351                    parts.remove(idx);
352                    out = parts.join("\n");
353                    break;
354                }
355                lines.truncate(lines.len().saturating_sub(2));
356                parts[idx] = format!(
357                    "<recovery_queries>\n{}\n</recovery_queries>",
358                    lines.join("\n")
359                );
360                out = parts.join("\n");
361            }
362        }
363        if out.len() > max_bytes {
364            return String::new();
365        }
366        out
367    }
368
369    fn knowledge_recall_query_stem(&self) -> Option<String> {
370        let mut bits: Vec<String> = Vec::new();
371        if let Some(ref t) = self.task {
372            bits.push(Self::task_keyword_stem(&t.description));
373        }
374        if bits.iter().all(std::string::String::is_empty) {
375            if let Some(f) = self.findings.last() {
376                bits.push(Self::task_keyword_stem(&f.summary));
377            } else if let Some(d) = self.decisions.last() {
378                bits.push(Self::task_keyword_stem(&d.summary));
379            }
380        }
381        let q = bits.join(" ").trim().to_string();
382        if q.is_empty() {
383            None
384        } else {
385            Some(q)
386        }
387    }
388
389    fn task_keyword_stem(text: &str) -> String {
390        const STOP: &[&str] = &[
391            "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
392            "be", "this", "that", "it", "as", "at", "by", "from",
393        ];
394        text.split_whitespace()
395            .filter_map(|w| {
396                let w = w.trim_matches(|c: char| !c.is_alphanumeric());
397                if w.len() < 3 {
398                    return None;
399                }
400                let lower = w.to_lowercase();
401                if STOP.contains(&lower.as_str()) {
402                    return None;
403                }
404                Some(w.to_string())
405            })
406            .take(8)
407            .collect::<Vec<_>>()
408            .join(" ")
409    }
410
411    /// Writes the compaction snapshot to disk and returns the snapshot string.
412    pub fn save_compaction_snapshot(&self) -> Result<String, String> {
413        let snapshot = self.build_compaction_snapshot();
414        let dir = sessions_dir().ok_or("cannot determine home directory")?;
415        if !dir.exists() {
416            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
417        }
418        let path = dir.join(format!("{}_snapshot.txt", self.id));
419        std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
420        Ok(snapshot)
421    }
422
423    /// Loads a previously saved compaction snapshot by session ID.
424    pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
425        let dir = sessions_dir()?;
426        let path = dir.join(format!("{session_id}_snapshot.txt"));
427        std::fs::read_to_string(&path).ok()
428    }
429
430    /// Loads the most recently modified compaction snapshot from disk.
431    ///
432    /// When a project root can be derived from CWD, only snapshots whose
433    /// embedded session data matches the project root are considered. This
434    /// prevents cross-project snapshot leakage.
435    pub fn load_latest_snapshot() -> Option<String> {
436        let dir = sessions_dir()?;
437        let project_root = std::env::current_dir()
438            .ok()
439            .map(|p| p.to_string_lossy().to_string());
440
441        let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
442            .ok()?
443            .filter_map(std::result::Result::ok)
444            .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
445            .filter_map(|e| {
446                let meta = e.metadata().ok()?;
447                let modified = meta.modified().ok()?;
448
449                if let Some(ref root) = project_root {
450                    let content = std::fs::read_to_string(e.path()).ok()?;
451                    if !content.contains(root) {
452                        return None;
453                    }
454                }
455
456                Some((modified, e.path()))
457            })
458            .collect();
459
460        snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
461        snapshots
462            .first()
463            .and_then(|(_, path)| std::fs::read_to_string(path).ok())
464    }
465
466    /// Build a compact resume block for post-compaction injection.
467    /// Max ~500 tokens. Includes task, decisions, files, and archive references.
468    pub fn build_resume_block(&self) -> String {
469        let mut parts: Vec<String> = Vec::new();
470
471        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
472            .unwrap_or_default();
473        if let Some(hint) = crate::core::terse::agent_prompts::resume_block_hint(&level) {
474            parts.push(hint);
475        }
476
477        if let Some(ref root) = self.project_root {
478            let short = root.rsplit('/').next().unwrap_or(root);
479            parts.push(format!("Project: {short}"));
480        }
481
482        if let Some(ref task) = self.task {
483            let pct = task
484                .progress_pct
485                .map_or(String::new(), |p| format!(" [{p}%]"));
486            parts.push(format!("Task: {}{pct}", task.description));
487        }
488
489        if !self.decisions.is_empty() {
490            let items: Vec<&str> = self
491                .decisions
492                .iter()
493                .rev()
494                .take(5)
495                .map(|d| d.summary.as_str())
496                .collect();
497            parts.push(format!("Decisions: {}", items.join("; ")));
498        }
499
500        if !self.files_touched.is_empty() {
501            let modified: Vec<String> = self
502                .files_touched
503                .iter()
504                .filter(|f| f.modified)
505                .take(10)
506                .map(|f| {
507                    f.summary
508                        .as_deref()
509                        .map_or_else(|| f.path.clone(), |s| format!("{} ({})", f.path, s))
510                })
511                .collect();
512            if !modified.is_empty() {
513                parts.push(format!("Modified: {}", modified.join(", ")));
514            }
515        }
516
517        if !self.findings.is_empty() {
518            let recent: Vec<&str> = self
519                .findings
520                .iter()
521                .rev()
522                .take(5)
523                .map(|f| f.summary.as_str())
524                .collect();
525            parts.push(format!("Key findings: {}", recent.join("; ")));
526        }
527
528        if !self.next_steps.is_empty() {
529            let steps: Vec<&str> = self
530                .next_steps
531                .iter()
532                .take(3)
533                .map(std::string::String::as_str)
534                .collect();
535            parts.push(format!("Next: {}", steps.join("; ")));
536        }
537
538        let archives = crate::core::archive::list_entries(Some(&self.id));
539        if !archives.is_empty() {
540            let hints: Vec<String> = archives
541                .iter()
542                .take(5)
543                .map(|a| format!("{}({})", a.id, a.tool))
544                .collect();
545            parts.push(format!("Archives: {}", hints.join(", ")));
546        }
547
548        parts.push(format!(
549            "Stats: {} calls, {} tok saved",
550            self.stats.total_tool_calls, self.stats.total_tokens_saved
551        ));
552
553        format!(
554            "--- SESSION RESUME (post-compaction) ---\n{}\n---",
555            parts.join("\n")
556        )
557    }
558}