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 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 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 §ions {
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 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 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 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 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}