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 | CompressionLevel::Raw => 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 self.compact_summary().0
39 }
40
41 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 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 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 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 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 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 §ions {
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 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 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 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 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 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 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}