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 | CompressionLevel::Raw => 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        self.compact_summary().0
39    }
40
41    /// [`Self::format_compact`] as the reply of a tool that declares its
42    /// sources (`ctx_session status`). Findings publish their sources or mark
43    /// the call's capture themselves ([`Self::finding_served`]); every other
44    /// session text in the summary — task, decisions, touched files, test
45    /// results, next steps, playbook — was typed or derived without a source
46    /// binding, so a summary that carries any of it marks the capture
47    /// incomplete. What remains unmarked is fixed text, the session counters
48    /// and the project root the request is bound to.
49    pub fn format_compact_delivered(&self) -> String {
50        let (summary, unsourced) = self.compact_summary();
51        if unsourced {
52            crate::core::archive::authority::mark_unbound();
53        }
54        summary
55    }
56
57    /// The compact summary, and whether it carries session text other than
58    /// findings (see [`Self::format_compact_delivered`]). A new section that
59    /// prints stored session text sets `unsourced`.
60    fn compact_summary(&self) -> (String, bool) {
61        let mut unsourced = false;
62        let duration = self.updated_at - self.started_at;
63        let hours = duration.num_hours();
64        let mins = duration.num_minutes() % 60;
65        let duration_str = if hours > 0 {
66            format!("{hours}h {mins}m")
67        } else {
68            format!("{mins}m")
69        };
70
71        let mut lines = Vec::new();
72        if self
73            .evidence
74            .iter()
75            .any(|item| item.key == "personal_context_copy")
76        {
77            lines.push("COPIED CONTEXT: historical user notes; not verified facts, execution approvals or source-access grants.".into());
78        }
79        if self.last_save_failed {
80            lines.push(
81                "PERSISTENCE FAILED: unsaved changes retained; resolve conflict or retry storage."
82                    .into(),
83            );
84        }
85        lines.push(format!(
86            "SESSION v{} | {} | {} calls | {} tok saved",
87            self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
88        ));
89
90        if let Some(ref task) = self.task {
91            let pct = task
92                .progress_pct
93                .map_or(String::new(), |p| format!(" [{p}%]"));
94            lines.push(format!("Task: {}{pct}", task.description));
95            unsourced = true;
96        }
97
98        if let Some(ref root) = self.project_root {
99            lines.push(format!("Root: {}", shorten_path(root)));
100        }
101
102        // Only findings that may be delivered now (`finding_served`); when
103        // none of the stored ones may, the line is left out.
104        let recent_findings: Vec<&super::types::Finding> = self
105            .findings
106            .iter()
107            .rev()
108            .filter(|finding| self.finding_served(finding))
109            .take(5)
110            .collect();
111        if !recent_findings.is_empty() {
112            let items: Vec<String> = recent_findings
113                .into_iter()
114                .map(|f| {
115                    let loc = match (&f.file, f.line) {
116                        (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
117                        (Some(file), None) => shorten_path(file),
118                        _ => String::new(),
119                    };
120                    if loc.is_empty() {
121                        f.summary.clone()
122                    } else {
123                        format!("{loc} \u{2014} {}", f.summary)
124                    }
125                })
126                .collect();
127            lines.push(format!(
128                "Findings ({}): {}",
129                self.findings.len(),
130                items.join(" | ")
131            ));
132        }
133
134        if !self.decisions.is_empty() {
135            let items: Vec<&str> = self
136                .decisions
137                .iter()
138                .rev()
139                .take(3)
140                .map(|d| d.summary.as_str())
141                .collect();
142            lines.push(format!("Decisions: {}", items.join(" | ")));
143            unsourced = true;
144        }
145
146        if !self.files_touched.is_empty() {
147            let items: Vec<String> = self
148                .files_touched
149                .iter()
150                .rev()
151                .take(10)
152                .map(|f| {
153                    let status = if f.modified { "mod" } else { &f.last_mode };
154                    let r = f.file_ref.as_deref().unwrap_or("?");
155                    format!("[{r} {} {status}]", shorten_path(&f.path))
156                })
157                .collect();
158            lines.push(format!(
159                "Files ({}): {}",
160                self.files_touched.len(),
161                items.join(" ")
162            ));
163            unsourced = true;
164        }
165
166        if let Some(ref tests) = self.test_results {
167            lines.push(format!(
168                "Tests: {}/{} pass ({})",
169                tests.passed, tests.total, tests.command
170            ));
171            unsourced = true;
172        }
173
174        if !self.next_steps.is_empty() {
175            lines.push(format!("Next: {}", self.next_steps.join(" | ")));
176            unsourced = true;
177        }
178
179        // ACE playbook (#541): restore the delta log (top entries by
180        // salience, stable IDs) so resumed sessions keep accumulated
181        // strategies/pitfalls without re-summarization loss.
182        let playbook_block = self.playbook.render(12);
183        if !playbook_block.is_empty() {
184            lines.push(playbook_block.trim_end().to_string());
185            unsourced = true;
186        }
187
188        (lines.join("\n"), unsourced)
189    }
190
191    /// Builds a size-limited XML snapshot of session state for context compaction.
192    pub fn build_compaction_snapshot(&self) -> String {
193        const MAX_SNAPSHOT_BYTES: usize = 2048;
194
195        let mut sections: Vec<(u8, String)> = Vec::new();
196
197        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
198            .unwrap_or_default();
199        if let Some(tag) = session_context_tag(&level) {
200            sections.push((0, tag));
201        }
202
203        if let Some(ref task) = self.task {
204            let pct = task
205                .progress_pct
206                .map_or(String::new(), |p| format!(" [{p}%]"));
207            sections.push((1, format!("<task>{}{pct}</task>", task.description)));
208        }
209
210        if !self.files_touched.is_empty() {
211            let modified: Vec<&str> = self
212                .files_touched
213                .iter()
214                .filter(|f| f.modified)
215                .map(|f| f.path.as_str())
216                .collect();
217            let read_only: Vec<&str> = self
218                .files_touched
219                .iter()
220                .filter(|f| !f.modified)
221                .take(10)
222                .map(|f| f.path.as_str())
223                .collect();
224            let mut files_section = String::new();
225            if !modified.is_empty() {
226                files_section.push_str(&format!("Modified: {}", modified.join(", ")));
227            }
228            if !read_only.is_empty() {
229                if !files_section.is_empty() {
230                    files_section.push_str(" | ");
231                }
232                files_section.push_str(&format!("Read: {}", read_only.join(", ")));
233            }
234            sections.push((1, format!("<files>{files_section}</files>")));
235        }
236
237        if !self.decisions.is_empty() {
238            let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
239            sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
240        }
241
242        // Only findings that may be delivered now (`finding_served`).
243        let items: Vec<String> = self
244            .findings
245            .iter()
246            .rev()
247            .filter(|finding| self.finding_served(finding))
248            .take(5)
249            .map(|f| f.summary.clone())
250            .collect();
251        if !items.is_empty() {
252            sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
253        }
254
255        if !self.progress.is_empty() {
256            let items: Vec<String> = self
257                .progress
258                .iter()
259                .rev()
260                .take(5)
261                .map(|p| {
262                    let detail = p.detail.as_deref().unwrap_or("");
263                    if detail.is_empty() {
264                        p.action.clone()
265                    } else {
266                        format!("{}: {detail}", p.action)
267                    }
268                })
269                .collect();
270            sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
271        }
272
273        if let Some(ref tests) = self.test_results {
274            sections.push((
275                3,
276                format!(
277                    "<tests>{}/{} pass ({})</tests>",
278                    tests.passed, tests.total, tests.command
279                ),
280            ));
281        }
282
283        if !self.next_steps.is_empty() {
284            sections.push((
285                3,
286                format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
287            ));
288        }
289
290        sections.push((
291            4,
292            format!(
293                "<stats>calls={} saved={}tok</stats>",
294                self.stats.total_tool_calls, self.stats.total_tokens_saved
295            ),
296        ));
297
298        sections.sort_by_key(|(priority, _)| *priority);
299
300        const SNAPSHOT_HARD_CAP: usize = 2200;
301        const CLOSE_TAG: &str = "</session_snapshot>";
302        let open_len = "<session_snapshot>\n".len();
303        let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
304
305        let mut snapshot = String::from("<session_snapshot>\n");
306        for (_, section) in &sections {
307            if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
308                break;
309            }
310            snapshot.push_str(section);
311            snapshot.push('\n');
312        }
313
314        let used = snapshot.len().saturating_sub(open_len);
315        let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
316        if suffix_budget > 64 {
317            let suffix = self.build_compaction_structured_suffix(suffix_budget);
318            if !suffix.is_empty() {
319                snapshot.push_str(&suffix);
320                if !suffix.ends_with('\n') {
321                    snapshot.push('\n');
322                }
323            }
324        }
325
326        snapshot.push_str(CLOSE_TAG);
327        snapshot
328    }
329
330    fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
331        if max_bytes <= 64 {
332            return String::new();
333        }
334
335        let mut recovery_queries: Vec<String> = Vec::new();
336        for ft in self.files_touched.iter().rev().take(12) {
337            let path_esc = escape_xml_attr(&ft.path);
338            let mode = if ft.last_mode.is_empty() {
339                "map".to_string()
340            } else {
341                escape_xml_attr(&ft.last_mode)
342            };
343            recovery_queries.push(format!(
344                r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
345            ));
346            let pattern = file_stem_search_pattern(&ft.path);
347            if !pattern.is_empty() {
348                let search_dir = parent_dir_slash(&ft.path);
349                let pat_esc = escape_xml_attr(&pattern);
350                let dir_esc = escape_xml_attr(&search_dir);
351                recovery_queries.push(format!(
352                    r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
353                ));
354            }
355        }
356
357        let mut parts: Vec<String> = Vec::new();
358        if !recovery_queries.is_empty() {
359            parts.push(format!(
360                "<recovery_queries>\n{}\n</recovery_queries>",
361                recovery_queries.join("\n")
362            ));
363        }
364
365        let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
366        if knowledge_ok && let Some(q) = self.knowledge_recall_query_stem() {
367            let q_esc = escape_xml_attr(&q);
368            parts.push(format!(
369                "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
370            ));
371        }
372
373        if let Some(root) = self
374            .project_root
375            .as_deref()
376            .filter(|r| !r.trim().is_empty())
377        {
378            let root_trim = root.trim_end_matches('/');
379            let mut cluster_lines: Vec<String> = Vec::new();
380            for ft in self.files_touched.iter().rev().take(3) {
381                let primary_esc = escape_xml_attr(&ft.path);
382                let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
383                let related_csv =
384                    graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
385                        .map(|s| escape_xml_attr(&s))
386                        .unwrap_or_default();
387                if related_csv.is_empty() {
388                    continue;
389                }
390                cluster_lines.push(format!(
391                    r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
392                ));
393            }
394            if !cluster_lines.is_empty() {
395                parts.push(format!(
396                    "<graph_context>\n{}\n</graph_context>",
397                    cluster_lines.join("\n")
398                ));
399            }
400        }
401
402        Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
403    }
404
405    fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
406        let mut out = parts.join("\n");
407        while out.len() > max_bytes && !parts.is_empty() {
408            parts.pop();
409            out = parts.join("\n");
410        }
411        if out.len() <= max_bytes {
412            return out;
413        }
414        if let Some(idx) = parts
415            .iter()
416            .position(|p| p.starts_with("<recovery_queries>"))
417        {
418            let mut lines: Vec<String> = parts[idx]
419                .lines()
420                .filter(|l| l.starts_with("<query "))
421                .map(str::to_string)
422                .collect();
423            while !lines.is_empty() && out.len() > max_bytes {
424                if lines.len() == 1 {
425                    parts.remove(idx);
426                    out = parts.join("\n");
427                    break;
428                }
429                lines.truncate(lines.len().saturating_sub(2));
430                parts[idx] = format!(
431                    "<recovery_queries>\n{}\n</recovery_queries>",
432                    lines.join("\n")
433                );
434                out = parts.join("\n");
435            }
436        }
437        if out.len() > max_bytes {
438            return String::new();
439        }
440        out
441    }
442
443    fn knowledge_recall_query_stem(&self) -> Option<String> {
444        let mut bits: Vec<String> = Vec::new();
445        if let Some(ref t) = self.task {
446            bits.push(Self::task_keyword_stem(&t.description));
447        }
448        if bits.iter().all(std::string::String::is_empty) {
449            // The stem is text of the finding: only one that may be delivered.
450            if let Some(f) = self
451                .findings
452                .iter()
453                .rev()
454                .find(|finding| self.finding_served(finding))
455            {
456                bits.push(Self::task_keyword_stem(&f.summary));
457            } else if let Some(d) = self.decisions.last() {
458                bits.push(Self::task_keyword_stem(&d.summary));
459            }
460        }
461        let q = bits.join(" ").trim().to_string();
462        if q.is_empty() { None } else { Some(q) }
463    }
464
465    fn task_keyword_stem(text: &str) -> String {
466        const STOP: &[&str] = &[
467            "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
468            "be", "this", "that", "it", "as", "at", "by", "from",
469        ];
470        text.split_whitespace()
471            .filter_map(|w| {
472                let w = w.trim_matches(|c: char| !c.is_alphanumeric());
473                if w.len() < 3 {
474                    return None;
475                }
476                let lower = w.to_lowercase();
477                if STOP.contains(&lower.as_str()) {
478                    return None;
479                }
480                Some(w.to_string())
481            })
482            .take(8)
483            .collect::<Vec<_>>()
484            .join(" ")
485    }
486
487    /// Writes the compaction snapshot to disk and returns the snapshot string.
488    pub fn save_compaction_snapshot(&self) -> Result<String, String> {
489        let snapshot = self.build_compaction_snapshot();
490        let dir = sessions_dir().ok_or("cannot determine home directory")?;
491        if !dir.exists() {
492            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
493        }
494        let path = dir.join(format!("{}_snapshot.txt", self.id));
495        crate::core::atomic_fs::write_bytes_with_fallback(&path, snapshot.as_bytes(), None)?;
496        #[cfg(unix)]
497        {
498            use std::os::unix::fs::PermissionsExt;
499            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
500        }
501        Ok(snapshot)
502    }
503
504    /// Loads a previously saved compaction snapshot by session ID.
505    pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
506        let dir = sessions_dir()?;
507        let path = dir.join(format!("{session_id}_snapshot.txt"));
508        std::fs::read_to_string(&path).ok()
509    }
510
511    /// Loads the most recently modified compaction snapshot from disk.
512    ///
513    /// When a project root can be derived from CWD, only snapshots whose
514    /// embedded session data matches the project root are considered. This
515    /// prevents cross-project snapshot leakage.
516    pub fn load_latest_snapshot() -> Option<String> {
517        let dir = sessions_dir()?;
518        let project_root = std::env::current_dir()
519            .ok()
520            .map(|p| p.to_string_lossy().to_string());
521
522        let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
523            .ok()?
524            .filter_map(std::result::Result::ok)
525            .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
526            .filter_map(|e| {
527                let meta = e.metadata().ok()?;
528                let modified = meta.modified().ok()?;
529
530                if let Some(ref root) = project_root {
531                    let content = std::fs::read_to_string(e.path()).ok()?;
532                    if !content.contains(root) {
533                        return None;
534                    }
535                }
536
537                Some((modified, e.path()))
538            })
539            .collect();
540
541        snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
542        snapshots
543            .first()
544            .and_then(|(_, path)| std::fs::read_to_string(path).ok())
545    }
546
547    /// Build a compact resume block for post-compaction injection.
548    /// Max ~500 tokens. Includes task, decisions, files, and archive references.
549    pub fn build_resume_block(&self) -> String {
550        let mut parts: Vec<String> = Vec::new();
551
552        let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
553            .unwrap_or_default();
554        if let Some(hint) = resume_block_hint(&level) {
555            parts.push(hint);
556        }
557
558        if let Some(ref root) = self.project_root {
559            let short = root.rsplit('/').next().unwrap_or(root);
560            parts.push(format!("Project: {short}"));
561        }
562
563        if let Some(ref task) = self.task {
564            let pct = task
565                .progress_pct
566                .map_or(String::new(), |p| format!(" [{p}%]"));
567            parts.push(format!("Task: {}{pct}", task.description));
568        }
569
570        if !self.decisions.is_empty() {
571            let items: Vec<&str> = self
572                .decisions
573                .iter()
574                .rev()
575                .take(5)
576                .map(|d| d.summary.as_str())
577                .collect();
578            parts.push(format!("Decisions: {}", items.join("; ")));
579        }
580
581        if !self.files_touched.is_empty() {
582            let modified: Vec<String> = self
583                .files_touched
584                .iter()
585                .filter(|f| f.modified)
586                .take(10)
587                .map(|f| {
588                    f.summary
589                        .as_deref()
590                        .map_or_else(|| f.path.clone(), |s| format!("{} ({})", f.path, s))
591                })
592                .collect();
593            if !modified.is_empty() {
594                parts.push(format!("Modified: {}", modified.join(", ")));
595            }
596        }
597
598        // Only findings that may be delivered now (`finding_served`).
599        let recent: Vec<&str> = self
600            .findings
601            .iter()
602            .rev()
603            .filter(|finding| self.finding_served(finding))
604            .take(5)
605            .map(|f| f.summary.as_str())
606            .collect();
607        if !recent.is_empty() {
608            parts.push(format!("Key findings: {}", recent.join("; ")));
609        }
610
611        if !self.next_steps.is_empty() {
612            let steps: Vec<&str> = self
613                .next_steps
614                .iter()
615                .take(3)
616                .map(std::string::String::as_str)
617                .collect();
618            parts.push(format!("Next: {}", steps.join("; ")));
619        }
620
621        let archives = crate::core::archive::list_entries(Some(&self.id));
622        if !archives.is_empty() {
623            let hints: Vec<String> = archives
624                .iter()
625                .take(5)
626                .map(|a| format!("{}({})", a.id, a.tool))
627                .collect();
628            parts.push(format!("Archives: {}", hints.join(", ")));
629        }
630
631        parts.push(format!(
632            "Stats: {} calls, {} tok saved",
633            self.stats.total_tool_calls, self.stats.total_tokens_saved
634        ));
635
636        format!(
637            "--- SESSION RESUME (post-compaction) ---\n{}\n---",
638            parts.join("\n")
639        )
640    }
641}