Skip to main content

lean_ctx/core/session/
compaction.rs

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