use std::path::PathBuf;
use crate::core::graph_context;
use super::paths::{
escape_xml_attr, file_stem_search_pattern, parent_dir_slash, sessions_dir, shorten_path,
};
use super::types::SessionState;
impl SessionState {
pub fn format_compact(&self) -> String {
let duration = self.updated_at - self.started_at;
let hours = duration.num_hours();
let mins = duration.num_minutes() % 60;
let duration_str = if hours > 0 {
format!("{hours}h {mins}m")
} else {
format!("{mins}m")
};
let mut lines = Vec::new();
lines.push(format!(
"SESSION v{} | {} | {} calls | {} tok saved",
self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
));
if let Some(ref task) = self.task {
let pct = task
.progress_pct
.map_or(String::new(), |p| format!(" [{p}%]"));
lines.push(format!("Task: {}{pct}", task.description));
}
if let Some(ref root) = self.project_root {
lines.push(format!("Root: {}", shorten_path(root)));
}
if !self.findings.is_empty() {
let items: Vec<String> = self
.findings
.iter()
.rev()
.take(5)
.map(|f| {
let loc = match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
(Some(file), None) => shorten_path(file),
_ => String::new(),
};
if loc.is_empty() {
f.summary.clone()
} else {
format!("{loc} \u{2014} {}", f.summary)
}
})
.collect();
lines.push(format!(
"Findings ({}): {}",
self.findings.len(),
items.join(" | ")
));
}
if !self.decisions.is_empty() {
let items: Vec<&str> = self
.decisions
.iter()
.rev()
.take(3)
.map(|d| d.summary.as_str())
.collect();
lines.push(format!("Decisions: {}", items.join(" | ")));
}
if !self.files_touched.is_empty() {
let items: Vec<String> = self
.files_touched
.iter()
.rev()
.take(10)
.map(|f| {
let status = if f.modified { "mod" } else { &f.last_mode };
let r = f.file_ref.as_deref().unwrap_or("?");
format!("[{r} {} {status}]", shorten_path(&f.path))
})
.collect();
lines.push(format!(
"Files ({}): {}",
self.files_touched.len(),
items.join(" ")
));
}
if let Some(ref tests) = self.test_results {
lines.push(format!(
"Tests: {}/{} pass ({})",
tests.passed, tests.total, tests.command
));
}
if !self.next_steps.is_empty() {
lines.push(format!("Next: {}", self.next_steps.join(" | ")));
}
lines.join("\n")
}
pub fn build_compaction_snapshot(&self) -> String {
const MAX_SNAPSHOT_BYTES: usize = 2048;
let mut sections: Vec<(u8, String)> = Vec::new();
let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
.unwrap_or_default();
if let Some(tag) = crate::core::terse::agent_prompts::session_context_tag(&level) {
sections.push((0, tag));
}
if let Some(ref task) = self.task {
let pct = task
.progress_pct
.map_or(String::new(), |p| format!(" [{p}%]"));
sections.push((1, format!("<task>{}{pct}</task>", task.description)));
}
if !self.files_touched.is_empty() {
let modified: Vec<&str> = self
.files_touched
.iter()
.filter(|f| f.modified)
.map(|f| f.path.as_str())
.collect();
let read_only: Vec<&str> = self
.files_touched
.iter()
.filter(|f| !f.modified)
.take(10)
.map(|f| f.path.as_str())
.collect();
let mut files_section = String::new();
if !modified.is_empty() {
files_section.push_str(&format!("Modified: {}", modified.join(", ")));
}
if !read_only.is_empty() {
if !files_section.is_empty() {
files_section.push_str(" | ");
}
files_section.push_str(&format!("Read: {}", read_only.join(", ")));
}
sections.push((1, format!("<files>{files_section}</files>")));
}
if !self.decisions.is_empty() {
let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
}
if !self.findings.is_empty() {
let items: Vec<String> = self
.findings
.iter()
.rev()
.take(5)
.map(|f| f.summary.clone())
.collect();
sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
}
if !self.progress.is_empty() {
let items: Vec<String> = self
.progress
.iter()
.rev()
.take(5)
.map(|p| {
let detail = p.detail.as_deref().unwrap_or("");
if detail.is_empty() {
p.action.clone()
} else {
format!("{}: {detail}", p.action)
}
})
.collect();
sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
}
if let Some(ref tests) = self.test_results {
sections.push((
3,
format!(
"<tests>{}/{} pass ({})</tests>",
tests.passed, tests.total, tests.command
),
));
}
if !self.next_steps.is_empty() {
sections.push((
3,
format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
));
}
sections.push((
4,
format!(
"<stats>calls={} saved={}tok</stats>",
self.stats.total_tool_calls, self.stats.total_tokens_saved
),
));
sections.sort_by_key(|(priority, _)| *priority);
const SNAPSHOT_HARD_CAP: usize = 2200;
const CLOSE_TAG: &str = "</session_snapshot>";
let open_len = "<session_snapshot>\n".len();
let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
let mut snapshot = String::from("<session_snapshot>\n");
for (_, section) in §ions {
if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
break;
}
snapshot.push_str(section);
snapshot.push('\n');
}
let used = snapshot.len().saturating_sub(open_len);
let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
if suffix_budget > 64 {
let suffix = self.build_compaction_structured_suffix(suffix_budget);
if !suffix.is_empty() {
snapshot.push_str(&suffix);
if !suffix.ends_with('\n') {
snapshot.push('\n');
}
}
}
snapshot.push_str(CLOSE_TAG);
snapshot
}
fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
if max_bytes <= 64 {
return String::new();
}
let mut recovery_queries: Vec<String> = Vec::new();
for ft in self.files_touched.iter().rev().take(12) {
let path_esc = escape_xml_attr(&ft.path);
let mode = if ft.last_mode.is_empty() {
"map".to_string()
} else {
escape_xml_attr(&ft.last_mode)
};
recovery_queries.push(format!(
r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
));
let pattern = file_stem_search_pattern(&ft.path);
if !pattern.is_empty() {
let search_dir = parent_dir_slash(&ft.path);
let pat_esc = escape_xml_attr(&pattern);
let dir_esc = escape_xml_attr(&search_dir);
recovery_queries.push(format!(
r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
));
}
}
let mut parts: Vec<String> = Vec::new();
if !recovery_queries.is_empty() {
parts.push(format!(
"<recovery_queries>\n{}\n</recovery_queries>",
recovery_queries.join("\n")
));
}
let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
if knowledge_ok {
if let Some(q) = self.knowledge_recall_query_stem() {
let q_esc = escape_xml_attr(&q);
parts.push(format!(
"<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
));
}
}
if let Some(root) = self
.project_root
.as_deref()
.filter(|r| !r.trim().is_empty())
{
let root_trim = root.trim_end_matches('/');
let mut cluster_lines: Vec<String> = Vec::new();
for ft in self.files_touched.iter().rev().take(3) {
let primary_esc = escape_xml_attr(&ft.path);
let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
let related_csv =
graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
.map(|s| escape_xml_attr(&s))
.unwrap_or_default();
if related_csv.is_empty() {
continue;
}
cluster_lines.push(format!(
r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
));
}
if !cluster_lines.is_empty() {
parts.push(format!(
"<graph_context>\n{}\n</graph_context>",
cluster_lines.join("\n")
));
}
}
Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
}
fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
let mut out = parts.join("\n");
while out.len() > max_bytes && !parts.is_empty() {
parts.pop();
out = parts.join("\n");
}
if out.len() <= max_bytes {
return out;
}
if let Some(idx) = parts
.iter()
.position(|p| p.starts_with("<recovery_queries>"))
{
let mut lines: Vec<String> = parts[idx]
.lines()
.filter(|l| l.starts_with("<query "))
.map(str::to_string)
.collect();
while !lines.is_empty() && out.len() > max_bytes {
if lines.len() == 1 {
parts.remove(idx);
out = parts.join("\n");
break;
}
lines.truncate(lines.len().saturating_sub(2));
parts[idx] = format!(
"<recovery_queries>\n{}\n</recovery_queries>",
lines.join("\n")
);
out = parts.join("\n");
}
}
if out.len() > max_bytes {
return String::new();
}
out
}
fn knowledge_recall_query_stem(&self) -> Option<String> {
let mut bits: Vec<String> = Vec::new();
if let Some(ref t) = self.task {
bits.push(Self::task_keyword_stem(&t.description));
}
if bits.iter().all(std::string::String::is_empty) {
if let Some(f) = self.findings.last() {
bits.push(Self::task_keyword_stem(&f.summary));
} else if let Some(d) = self.decisions.last() {
bits.push(Self::task_keyword_stem(&d.summary));
}
}
let q = bits.join(" ").trim().to_string();
if q.is_empty() {
None
} else {
Some(q)
}
}
fn task_keyword_stem(text: &str) -> String {
const STOP: &[&str] = &[
"the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
"be", "this", "that", "it", "as", "at", "by", "from",
];
text.split_whitespace()
.filter_map(|w| {
let w = w.trim_matches(|c: char| !c.is_alphanumeric());
if w.len() < 3 {
return None;
}
let lower = w.to_lowercase();
if STOP.contains(&lower.as_str()) {
return None;
}
Some(w.to_string())
})
.take(8)
.collect::<Vec<_>>()
.join(" ")
}
pub fn save_compaction_snapshot(&self) -> Result<String, String> {
let snapshot = self.build_compaction_snapshot();
let dir = sessions_dir().ok_or("cannot determine home directory")?;
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
}
let path = dir.join(format!("{}_snapshot.txt", self.id));
std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
Ok(snapshot)
}
pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
let dir = sessions_dir()?;
let path = dir.join(format!("{session_id}_snapshot.txt"));
std::fs::read_to_string(&path).ok()
}
pub fn load_latest_snapshot() -> Option<String> {
let dir = sessions_dir()?;
let project_root = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string());
let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
.ok()?
.filter_map(std::result::Result::ok)
.filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
.filter_map(|e| {
let meta = e.metadata().ok()?;
let modified = meta.modified().ok()?;
if let Some(ref root) = project_root {
let content = std::fs::read_to_string(e.path()).ok()?;
if !content.contains(root) {
return None;
}
}
Some((modified, e.path()))
})
.collect();
snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
snapshots
.first()
.and_then(|(_, path)| std::fs::read_to_string(path).ok())
}
pub fn build_resume_block(&self) -> String {
let mut parts: Vec<String> = Vec::new();
let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
.unwrap_or_default();
if let Some(hint) = crate::core::terse::agent_prompts::resume_block_hint(&level) {
parts.push(hint);
}
if let Some(ref root) = self.project_root {
let short = root.rsplit('/').next().unwrap_or(root);
parts.push(format!("Project: {short}"));
}
if let Some(ref task) = self.task {
let pct = task
.progress_pct
.map_or(String::new(), |p| format!(" [{p}%]"));
parts.push(format!("Task: {}{pct}", task.description));
}
if !self.decisions.is_empty() {
let items: Vec<&str> = self
.decisions
.iter()
.rev()
.take(5)
.map(|d| d.summary.as_str())
.collect();
parts.push(format!("Decisions: {}", items.join("; ")));
}
if !self.files_touched.is_empty() {
let modified: Vec<&str> = self
.files_touched
.iter()
.filter(|f| f.modified)
.take(10)
.map(|f| f.path.as_str())
.collect();
if !modified.is_empty() {
parts.push(format!("Modified: {}", modified.join(", ")));
}
}
if !self.next_steps.is_empty() {
let steps: Vec<&str> = self
.next_steps
.iter()
.take(3)
.map(std::string::String::as_str)
.collect();
parts.push(format!("Next: {}", steps.join("; ")));
}
let archives = crate::core::archive::list_entries(Some(&self.id));
if !archives.is_empty() {
let hints: Vec<String> = archives
.iter()
.take(5)
.map(|a| format!("{}({})", a.id, a.tool))
.collect();
parts.push(format!("Archives: {}", hints.join(", ")));
}
parts.push(format!(
"Stats: {} calls, {} tok saved",
self.stats.total_tool_calls, self.stats.total_tokens_saved
));
format!(
"--- SESSION RESUME (post-compaction) ---\n{}\n---",
parts.join("\n")
)
}
}