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