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