1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::core::graph_context;
6use crate::core::intent_protocol::{IntentRecord, IntentSource};
7
8const MAX_FINDINGS: usize = 20;
9const MAX_DECISIONS: usize = 10;
10const MAX_FILES: usize = 50;
11const MAX_EVIDENCE: usize = 500;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct SessionState {
17 pub id: String,
18 pub version: u32,
19 pub started_at: DateTime<Utc>,
20 pub updated_at: DateTime<Utc>,
21 pub project_root: Option<String>,
22 #[serde(default)]
23 pub shell_cwd: Option<String>,
24 pub task: Option<TaskInfo>,
25 pub findings: Vec<Finding>,
26 pub decisions: Vec<Decision>,
27 pub files_touched: Vec<FileTouched>,
28 pub test_results: Option<TestSnapshot>,
29 pub progress: Vec<ProgressEntry>,
30 pub next_steps: Vec<String>,
31 #[serde(default)]
32 pub evidence: Vec<EvidenceRecord>,
33 #[serde(default)]
34 pub intents: Vec<IntentRecord>,
35 #[serde(default)]
36 pub active_structured_intent: Option<crate::core::intent_engine::StructuredIntent>,
37 pub stats: SessionStats,
38 #[serde(default)]
40 pub terse_mode: bool,
41}
42
43#[derive(Serialize, Deserialize, Clone, Debug)]
45pub struct TaskInfo {
46 pub description: String,
47 pub intent: Option<String>,
48 pub progress_pct: Option<u8>,
49}
50
51#[derive(Serialize, Deserialize, Clone, Debug)]
53pub struct Finding {
54 pub file: Option<String>,
55 pub line: Option<u32>,
56 pub summary: String,
57 pub timestamp: DateTime<Utc>,
58}
59
60#[derive(Serialize, Deserialize, Clone, Debug)]
62pub struct Decision {
63 pub summary: String,
64 pub rationale: Option<String>,
65 pub timestamp: DateTime<Utc>,
66}
67
68#[derive(Serialize, Deserialize, Clone, Debug)]
70pub struct FileTouched {
71 pub path: String,
72 pub file_ref: Option<String>,
73 pub read_count: u32,
74 pub modified: bool,
75 pub last_mode: String,
76 pub tokens: usize,
77 #[serde(default)]
78 pub stale: bool,
79 #[serde(default)]
80 pub context_item_id: Option<String>,
81}
82
83#[derive(Serialize, Deserialize, Clone, Debug)]
85pub struct TestSnapshot {
86 pub command: String,
87 pub passed: u32,
88 pub failed: u32,
89 pub total: u32,
90 pub timestamp: DateTime<Utc>,
91}
92
93#[derive(Serialize, Deserialize, Clone, Debug)]
95pub struct ProgressEntry {
96 pub action: String,
97 pub detail: Option<String>,
98 pub timestamp: DateTime<Utc>,
99}
100
101#[derive(Serialize, Deserialize, Clone, Debug)]
103#[serde(rename_all = "snake_case")]
104pub enum EvidenceKind {
105 ToolCall,
106 Manual,
107}
108
109#[derive(Serialize, Deserialize, Clone, Debug)]
111pub struct EvidenceRecord {
112 pub kind: EvidenceKind,
113 pub key: String,
114 pub value: Option<String>,
115 pub tool: Option<String>,
116 pub input_md5: Option<String>,
117 pub output_md5: Option<String>,
118 pub agent_id: Option<String>,
119 pub client_name: Option<String>,
120 pub timestamp: DateTime<Utc>,
121}
122
123#[derive(Serialize, Deserialize, Clone, Debug, Default)]
125#[serde(default)]
126pub struct SessionStats {
127 pub total_tool_calls: u32,
128 pub total_tokens_saved: u64,
129 pub total_tokens_input: u64,
130 pub cache_hits: u32,
131 pub files_read: u32,
132 pub commands_run: u32,
133 pub intents_inferred: u32,
134 pub intents_explicit: u32,
135 pub unsaved_changes: u32,
136}
137
138#[derive(Serialize, Deserialize, Clone, Debug)]
139struct LatestPointer {
140 id: String,
141}
142
143pub struct PreparedSave {
147 dir: PathBuf,
148 id: String,
149 json: String,
150 pointer_json: String,
151 compaction_snapshot: Option<String>,
152}
153
154impl PreparedSave {
155 pub fn write_to_disk(self) -> Result<(), String> {
158 if !self.dir.exists() {
159 std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
160 }
161 let path = self.dir.join(format!("{}.json", self.id));
162 let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
163 std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
164 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
165
166 let latest_path = self.dir.join("latest.json");
167 let latest_tmp = self.dir.join(".latest.json.tmp");
168 std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
169 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
170
171 if let Some(snapshot) = self.compaction_snapshot {
172 let snap_path = self.dir.join(format!("{}_snapshot.txt", self.id));
173 let _ = std::fs::write(&snap_path, &snapshot);
174 }
175 Ok(())
176 }
177}
178
179impl Default for SessionState {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185impl SessionState {
186 pub fn new() -> Self {
188 let now = Utc::now();
189 Self {
190 id: generate_session_id(),
191 version: 0,
192 started_at: now,
193 updated_at: now,
194 project_root: None,
195 shell_cwd: None,
196 task: None,
197 findings: Vec::new(),
198 decisions: Vec::new(),
199 files_touched: Vec::new(),
200 test_results: None,
201 progress: Vec::new(),
202 next_steps: Vec::new(),
203 evidence: Vec::new(),
204 intents: Vec::new(),
205 active_structured_intent: None,
206 stats: SessionStats::default(),
207 terse_mode: crate::core::profiles::active_profile()
208 .compression
209 .terse_mode_effective(),
210 }
211 }
212
213 pub fn increment(&mut self) {
215 self.version += 1;
216 self.updated_at = Utc::now();
217 self.stats.unsaved_changes += 1;
218 }
219
220 pub fn should_save(&self) -> bool {
222 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
223 }
224
225 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
227 self.task = Some(TaskInfo {
228 description: description.to_string(),
229 intent: intent.map(std::string::ToString::to_string),
230 progress_pct: None,
231 });
232
233 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
234 let si = if touched.is_empty() {
235 crate::core::intent_engine::StructuredIntent::from_query(description)
236 } else {
237 crate::core::intent_engine::StructuredIntent::from_query_with_session(
238 description,
239 &touched,
240 )
241 };
242 if si.confidence >= 0.7 {
243 self.active_structured_intent = Some(si);
244 }
245
246 self.increment();
247 }
248
249 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
251 self.findings.push(Finding {
252 file: file.map(std::string::ToString::to_string),
253 line,
254 summary: summary.to_string(),
255 timestamp: Utc::now(),
256 });
257 while self.findings.len() > MAX_FINDINGS {
258 self.findings.remove(0);
259 }
260 self.increment();
261 }
262
263 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
265 self.decisions.push(Decision {
266 summary: summary.to_string(),
267 rationale: rationale.map(std::string::ToString::to_string),
268 timestamp: Utc::now(),
269 });
270 while self.decisions.len() > MAX_DECISIONS {
271 self.decisions.remove(0);
272 }
273 self.increment();
274 }
275
276 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
278 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
279 existing.read_count += 1;
280 existing.last_mode = mode.to_string();
281 existing.tokens = tokens;
282 if let Some(r) = file_ref {
283 existing.file_ref = Some(r.to_string());
284 }
285 } else {
286 let item_id = crate::core::context_field::ContextItemId::from_file(path);
287 self.files_touched.push(FileTouched {
288 path: path.to_string(),
289 file_ref: file_ref.map(std::string::ToString::to_string),
290 read_count: 1,
291 modified: false,
292 last_mode: mode.to_string(),
293 tokens,
294 stale: false,
295 context_item_id: Some(item_id.to_string()),
296 });
297 while self.files_touched.len() > MAX_FILES {
298 self.files_touched.remove(0);
299 }
300 }
301 self.stats.files_read += 1;
302 self.increment();
303 }
304
305 pub fn mark_modified(&mut self, path: &str) {
307 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
308 existing.modified = true;
309 }
310 self.increment();
311 }
312
313 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
315 self.stats.total_tool_calls += 1;
316 self.stats.total_tokens_saved += tokens_saved;
317 self.stats.total_tokens_input += tokens_input;
318 }
319
320 pub fn record_intent(&mut self, mut intent: IntentRecord) {
322 if intent.occurrences == 0 {
323 intent.occurrences = 1;
324 }
325
326 if let Some(last) = self.intents.last_mut() {
327 if last.fingerprint() == intent.fingerprint() {
328 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
329 last.timestamp = intent.timestamp;
330 match intent.source {
331 IntentSource::Inferred => self.stats.intents_inferred += 1,
332 IntentSource::Explicit => self.stats.intents_explicit += 1,
333 }
334 self.increment();
335 return;
336 }
337 }
338
339 match intent.source {
340 IntentSource::Inferred => self.stats.intents_inferred += 1,
341 IntentSource::Explicit => self.stats.intents_explicit += 1,
342 }
343
344 self.intents.push(intent);
345 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
346 self.intents.remove(0);
347 }
348 self.increment();
349 }
350
351 pub fn record_tool_receipt(
353 &mut self,
354 tool: &str,
355 action: Option<&str>,
356 input_md5: &str,
357 output_md5: &str,
358 agent_id: Option<&str>,
359 client_name: Option<&str>,
360 ) {
361 let now = Utc::now();
362 let mut push = |key: String| {
363 self.evidence.push(EvidenceRecord {
364 kind: EvidenceKind::ToolCall,
365 key,
366 value: None,
367 tool: Some(tool.to_string()),
368 input_md5: Some(input_md5.to_string()),
369 output_md5: Some(output_md5.to_string()),
370 agent_id: agent_id.map(std::string::ToString::to_string),
371 client_name: client_name.map(std::string::ToString::to_string),
372 timestamp: now,
373 });
374 };
375
376 push(format!("tool:{tool}"));
377 if let Some(a) = action {
378 push(format!("tool:{tool}:{a}"));
379 }
380 while self.evidence.len() > MAX_EVIDENCE {
381 self.evidence.remove(0);
382 }
383 self.increment();
384 }
385
386 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
388 self.evidence.push(EvidenceRecord {
389 kind: EvidenceKind::Manual,
390 key: key.to_string(),
391 value: value.map(std::string::ToString::to_string),
392 tool: None,
393 input_md5: None,
394 output_md5: None,
395 agent_id: None,
396 client_name: None,
397 timestamp: Utc::now(),
398 });
399 while self.evidence.len() > MAX_EVIDENCE {
400 self.evidence.remove(0);
401 }
402 self.increment();
403 }
404
405 pub fn has_evidence_key(&self, key: &str) -> bool {
407 self.evidence.iter().any(|e| e.key == key)
408 }
409
410 pub fn record_cache_hit(&mut self) {
412 self.stats.cache_hits += 1;
413 }
414
415 pub fn record_command(&mut self) {
417 self.stats.commands_run += 1;
418 }
419
420 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
423 if let Some(cwd) = explicit_cwd {
424 if !cwd.is_empty() && cwd != "." {
425 return cwd.to_string();
426 }
427 }
428 if let Some(ref cwd) = self.shell_cwd {
429 return cwd.clone();
430 }
431 if let Some(ref root) = self.project_root {
432 return root.clone();
433 }
434 std::env::current_dir()
435 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
436 }
437
438 pub fn update_shell_cwd(&mut self, command: &str) {
442 let base = self.effective_cwd(None);
443 if let Some(new_cwd) = extract_cd_target(command, &base) {
444 let path = std::path::Path::new(&new_cwd);
445 if path.exists() && path.is_dir() {
446 self.shell_cwd = Some(
447 crate::core::pathutil::safe_canonicalize_or_self(path)
448 .to_string_lossy()
449 .to_string(),
450 );
451 }
452 }
453 }
454
455 pub fn format_compact(&self) -> String {
457 let duration = self.updated_at - self.started_at;
458 let hours = duration.num_hours();
459 let mins = duration.num_minutes() % 60;
460 let duration_str = if hours > 0 {
461 format!("{hours}h {mins}m")
462 } else {
463 format!("{mins}m")
464 };
465
466 let mut lines = Vec::new();
467 lines.push(format!(
468 "SESSION v{} | {} | {} calls | {} tok saved",
469 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
470 ));
471
472 if let Some(ref task) = self.task {
473 let pct = task
474 .progress_pct
475 .map_or(String::new(), |p| format!(" [{p}%]"));
476 lines.push(format!("Task: {}{pct}", task.description));
477 }
478
479 if let Some(ref root) = self.project_root {
480 lines.push(format!("Root: {}", shorten_path(root)));
481 }
482
483 if !self.findings.is_empty() {
484 let items: Vec<String> = self
485 .findings
486 .iter()
487 .rev()
488 .take(5)
489 .map(|f| {
490 let loc = match (&f.file, f.line) {
491 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
492 (Some(file), None) => shorten_path(file),
493 _ => String::new(),
494 };
495 if loc.is_empty() {
496 f.summary.clone()
497 } else {
498 format!("{loc} \u{2014} {}", f.summary)
499 }
500 })
501 .collect();
502 lines.push(format!(
503 "Findings ({}): {}",
504 self.findings.len(),
505 items.join(" | ")
506 ));
507 }
508
509 if !self.decisions.is_empty() {
510 let items: Vec<&str> = self
511 .decisions
512 .iter()
513 .rev()
514 .take(3)
515 .map(|d| d.summary.as_str())
516 .collect();
517 lines.push(format!("Decisions: {}", items.join(" | ")));
518 }
519
520 if !self.files_touched.is_empty() {
521 let items: Vec<String> = self
522 .files_touched
523 .iter()
524 .rev()
525 .take(10)
526 .map(|f| {
527 let status = if f.modified { "mod" } else { &f.last_mode };
528 let r = f.file_ref.as_deref().unwrap_or("?");
529 format!("[{r} {} {status}]", shorten_path(&f.path))
530 })
531 .collect();
532 lines.push(format!(
533 "Files ({}): {}",
534 self.files_touched.len(),
535 items.join(" ")
536 ));
537 }
538
539 if let Some(ref tests) = self.test_results {
540 lines.push(format!(
541 "Tests: {}/{} pass ({})",
542 tests.passed, tests.total, tests.command
543 ));
544 }
545
546 if !self.next_steps.is_empty() {
547 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
548 }
549
550 lines.join("\n")
551 }
552
553 pub fn build_compaction_snapshot(&self) -> String {
555 const MAX_SNAPSHOT_BYTES: usize = 2048;
556
557 let mut sections: Vec<(u8, String)> = Vec::new();
558
559 if self.terse_mode {
560 sections.push((0, "<config terse=\"true\" />".to_string()));
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 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
568 }
569
570 if !self.files_touched.is_empty() {
571 let modified: Vec<&str> = self
572 .files_touched
573 .iter()
574 .filter(|f| f.modified)
575 .map(|f| f.path.as_str())
576 .collect();
577 let read_only: Vec<&str> = self
578 .files_touched
579 .iter()
580 .filter(|f| !f.modified)
581 .take(10)
582 .map(|f| f.path.as_str())
583 .collect();
584 let mut files_section = String::new();
585 if !modified.is_empty() {
586 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
587 }
588 if !read_only.is_empty() {
589 if !files_section.is_empty() {
590 files_section.push_str(" | ");
591 }
592 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
593 }
594 sections.push((1, format!("<files>{files_section}</files>")));
595 }
596
597 if !self.decisions.is_empty() {
598 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
599 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
600 }
601
602 if !self.findings.is_empty() {
603 let items: Vec<String> = self
604 .findings
605 .iter()
606 .rev()
607 .take(5)
608 .map(|f| f.summary.clone())
609 .collect();
610 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
611 }
612
613 if !self.progress.is_empty() {
614 let items: Vec<String> = self
615 .progress
616 .iter()
617 .rev()
618 .take(5)
619 .map(|p| {
620 let detail = p.detail.as_deref().unwrap_or("");
621 if detail.is_empty() {
622 p.action.clone()
623 } else {
624 format!("{}: {detail}", p.action)
625 }
626 })
627 .collect();
628 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
629 }
630
631 if let Some(ref tests) = self.test_results {
632 sections.push((
633 3,
634 format!(
635 "<tests>{}/{} pass ({})</tests>",
636 tests.passed, tests.total, tests.command
637 ),
638 ));
639 }
640
641 if !self.next_steps.is_empty() {
642 sections.push((
643 3,
644 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
645 ));
646 }
647
648 sections.push((
649 4,
650 format!(
651 "<stats>calls={} saved={}tok</stats>",
652 self.stats.total_tool_calls, self.stats.total_tokens_saved
653 ),
654 ));
655
656 sections.sort_by_key(|(priority, _)| *priority);
657
658 const SNAPSHOT_HARD_CAP: usize = 2200;
659 const CLOSE_TAG: &str = "</session_snapshot>";
660 let open_len = "<session_snapshot>\n".len();
661 let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
662
663 let mut snapshot = String::from("<session_snapshot>\n");
664 for (_, section) in §ions {
665 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
666 break;
667 }
668 snapshot.push_str(section);
669 snapshot.push('\n');
670 }
671
672 let used = snapshot.len().saturating_sub(open_len);
673 let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
674 if suffix_budget > 64 {
675 let suffix = self.build_compaction_structured_suffix(suffix_budget);
676 if !suffix.is_empty() {
677 snapshot.push_str(&suffix);
678 if !suffix.ends_with('\n') {
679 snapshot.push('\n');
680 }
681 }
682 }
683
684 snapshot.push_str(CLOSE_TAG);
685 snapshot
686 }
687
688 fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
690 if max_bytes <= 64 {
691 return String::new();
692 }
693
694 let mut recovery_queries: Vec<String> = Vec::new();
695 for ft in self.files_touched.iter().rev().take(12) {
696 let path_esc = escape_xml_attr(&ft.path);
697 let mode = if ft.last_mode.is_empty() {
698 "map".to_string()
699 } else {
700 escape_xml_attr(&ft.last_mode)
701 };
702 recovery_queries.push(format!(
703 r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
704 ));
705 let pattern = file_stem_search_pattern(&ft.path);
706 if !pattern.is_empty() {
707 let search_dir = parent_dir_slash(&ft.path);
708 let pat_esc = escape_xml_attr(&pattern);
709 let dir_esc = escape_xml_attr(&search_dir);
710 recovery_queries.push(format!(
711 r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
712 ));
713 }
714 }
715
716 let mut parts: Vec<String> = Vec::new();
717 if !recovery_queries.is_empty() {
718 parts.push(format!(
719 "<recovery_queries>\n{}\n</recovery_queries>",
720 recovery_queries.join("\n")
721 ));
722 }
723
724 let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
725 if knowledge_ok {
726 if let Some(q) = self.knowledge_recall_query_stem() {
727 let q_esc = escape_xml_attr(&q);
728 parts.push(format!(
729 "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
730 ));
731 }
732 }
733
734 if let Some(root) = self
735 .project_root
736 .as_deref()
737 .filter(|r| !r.trim().is_empty())
738 {
739 let root_trim = root.trim_end_matches('/');
740 let mut cluster_lines: Vec<String> = Vec::new();
741 for ft in self.files_touched.iter().rev().take(3) {
742 let primary_esc = escape_xml_attr(&ft.path);
743 let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
744 let related_csv =
745 graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
746 .map(|s| escape_xml_attr(&s))
747 .unwrap_or_default();
748 if related_csv.is_empty() {
749 continue;
750 }
751 cluster_lines.push(format!(
752 r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
753 ));
754 }
755 if !cluster_lines.is_empty() {
756 parts.push(format!(
757 "<graph_context>\n{}\n</graph_context>",
758 cluster_lines.join("\n")
759 ));
760 }
761 }
762
763 Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
764 }
765
766 fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
767 let mut out = parts.join("\n");
768 while out.len() > max_bytes && !parts.is_empty() {
769 parts.pop();
770 out = parts.join("\n");
771 }
772 if out.len() <= max_bytes {
773 return out;
774 }
775 if let Some(idx) = parts
776 .iter()
777 .position(|p| p.starts_with("<recovery_queries>"))
778 {
779 let mut lines: Vec<String> = parts[idx]
780 .lines()
781 .filter(|l| l.starts_with("<query "))
782 .map(str::to_string)
783 .collect();
784 while !lines.is_empty() && out.len() > max_bytes {
785 if lines.len() == 1 {
786 parts.remove(idx);
787 out = parts.join("\n");
788 break;
789 }
790 lines.truncate(lines.len().saturating_sub(2));
791 parts[idx] = format!(
792 "<recovery_queries>\n{}\n</recovery_queries>",
793 lines.join("\n")
794 );
795 out = parts.join("\n");
796 }
797 }
798 if out.len() > max_bytes {
799 return String::new();
800 }
801 out
802 }
803
804 fn knowledge_recall_query_stem(&self) -> Option<String> {
805 let mut bits: Vec<String> = Vec::new();
806 if let Some(ref t) = self.task {
807 bits.push(Self::task_keyword_stem(&t.description));
808 }
809 if bits.iter().all(std::string::String::is_empty) {
810 if let Some(f) = self.findings.last() {
811 bits.push(Self::task_keyword_stem(&f.summary));
812 } else if let Some(d) = self.decisions.last() {
813 bits.push(Self::task_keyword_stem(&d.summary));
814 }
815 }
816 let q = bits.join(" ").trim().to_string();
817 if q.is_empty() {
818 None
819 } else {
820 Some(q)
821 }
822 }
823
824 fn task_keyword_stem(text: &str) -> String {
825 const STOP: &[&str] = &[
826 "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
827 "be", "this", "that", "it", "as", "at", "by", "from",
828 ];
829 text.split_whitespace()
830 .filter_map(|w| {
831 let w = w.trim_matches(|c: char| !c.is_alphanumeric());
832 if w.len() < 3 {
833 return None;
834 }
835 let lower = w.to_lowercase();
836 if STOP.contains(&lower.as_str()) {
837 return None;
838 }
839 Some(w.to_string())
840 })
841 .take(8)
842 .collect::<Vec<_>>()
843 .join(" ")
844 }
845
846 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
848 let snapshot = self.build_compaction_snapshot();
849 let dir = sessions_dir().ok_or("cannot determine home directory")?;
850 if !dir.exists() {
851 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
852 }
853 let path = dir.join(format!("{}_snapshot.txt", self.id));
854 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
855 Ok(snapshot)
856 }
857
858 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
860 let dir = sessions_dir()?;
861 let path = dir.join(format!("{session_id}_snapshot.txt"));
862 std::fs::read_to_string(&path).ok()
863 }
864
865 pub fn load_latest_snapshot() -> Option<String> {
871 let dir = sessions_dir()?;
872 let project_root = std::env::current_dir()
873 .ok()
874 .map(|p| p.to_string_lossy().to_string());
875
876 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
877 .ok()?
878 .filter_map(std::result::Result::ok)
879 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
880 .filter_map(|e| {
881 let meta = e.metadata().ok()?;
882 let modified = meta.modified().ok()?;
883
884 if let Some(ref root) = project_root {
885 let content = std::fs::read_to_string(e.path()).ok()?;
886 if !content.contains(root) {
887 return None;
888 }
889 }
890
891 Some((modified, e.path()))
892 })
893 .collect();
894
895 snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
896 snapshots
897 .first()
898 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
899 }
900
901 pub fn build_resume_block(&self) -> String {
904 let mut parts: Vec<String> = Vec::new();
905
906 if self.terse_mode {
907 parts.push(
908 "[TERSE MODE] Keep responses concise. Use bullet points, avoid filler. Focus on code and actions, not explanations."
909 .to_string(),
910 );
911 }
912
913 if let Some(ref root) = self.project_root {
914 let short = root.rsplit('/').next().unwrap_or(root);
915 parts.push(format!("Project: {short}"));
916 }
917
918 if let Some(ref task) = self.task {
919 let pct = task
920 .progress_pct
921 .map_or(String::new(), |p| format!(" [{p}%]"));
922 parts.push(format!("Task: {}{pct}", task.description));
923 }
924
925 if !self.decisions.is_empty() {
926 let items: Vec<&str> = self
927 .decisions
928 .iter()
929 .rev()
930 .take(5)
931 .map(|d| d.summary.as_str())
932 .collect();
933 parts.push(format!("Decisions: {}", items.join("; ")));
934 }
935
936 if !self.files_touched.is_empty() {
937 let modified: Vec<&str> = self
938 .files_touched
939 .iter()
940 .filter(|f| f.modified)
941 .take(10)
942 .map(|f| f.path.as_str())
943 .collect();
944 if !modified.is_empty() {
945 parts.push(format!("Modified: {}", modified.join(", ")));
946 }
947 }
948
949 if !self.next_steps.is_empty() {
950 let steps: Vec<&str> = self
951 .next_steps
952 .iter()
953 .take(3)
954 .map(std::string::String::as_str)
955 .collect();
956 parts.push(format!("Next: {}", steps.join("; ")));
957 }
958
959 let archives = super::archive::list_entries(Some(&self.id));
960 if !archives.is_empty() {
961 let hints: Vec<String> = archives
962 .iter()
963 .take(5)
964 .map(|a| format!("{}({})", a.id, a.tool))
965 .collect();
966 parts.push(format!("Archives: {}", hints.join(", ")));
967 }
968
969 parts.push(format!(
970 "Stats: {} calls, {} tok saved",
971 self.stats.total_tool_calls, self.stats.total_tokens_saved
972 ));
973
974 format!(
975 "--- SESSION RESUME (post-compaction) ---\n{}\n---",
976 parts.join("\n")
977 )
978 }
979
980 pub fn save(&mut self) -> Result<(), String> {
982 let prepared = self.prepare_save()?;
983 match prepared.write_to_disk() {
984 Ok(()) => Ok(()),
985 Err(e) => {
986 self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
987 Err(e)
988 }
989 }
990 }
991
992 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
996 let dir = sessions_dir().ok_or("cannot determine home directory")?;
997 let compaction_snapshot = if self.stats.total_tool_calls > 0 {
998 Some(self.build_compaction_snapshot())
999 } else {
1000 None
1001 };
1002 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
1003 let pointer_json = serde_json::to_string(&LatestPointer {
1004 id: self.id.clone(),
1005 })
1006 .map_err(|e| e.to_string())?;
1007 self.stats.unsaved_changes = 0;
1008 Ok(PreparedSave {
1009 dir,
1010 id: self.id.clone(),
1011 json,
1012 pointer_json,
1013 compaction_snapshot,
1014 })
1015 }
1016
1017 pub fn load_latest() -> Option<Self> {
1023 if let Some(project_root) = std::env::current_dir()
1024 .ok()
1025 .map(|p| p.to_string_lossy().to_string())
1026 {
1027 if let Some(session) = Self::load_latest_for_project_root(&project_root) {
1028 return Some(session);
1029 }
1030 }
1031 let dir = sessions_dir()?;
1032 let latest_path = dir.join("latest.json");
1033 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
1034 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
1035 Self::load_by_id(&pointer.id)
1036 }
1037
1038 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
1040 let dir = sessions_dir()?;
1041 let target_root =
1042 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
1043 let mut latest_match: Option<Self> = None;
1044
1045 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
1046 let path = entry.path();
1047 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1048 continue;
1049 }
1050 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1051 continue;
1052 }
1053
1054 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
1055 continue;
1056 };
1057 let Some(session) = Self::load_by_id(id) else {
1058 continue;
1059 };
1060
1061 if !session_matches_project_root(&session, &target_root) {
1062 continue;
1063 }
1064
1065 if latest_match
1066 .as_ref()
1067 .is_none_or(|existing| session.updated_at > existing.updated_at)
1068 {
1069 latest_match = Some(session);
1070 }
1071 }
1072
1073 latest_match
1074 }
1075
1076 pub fn load_by_id(id: &str) -> Option<Self> {
1078 let dir = sessions_dir()?;
1079 let path = dir.join(format!("{id}.json"));
1080 let json = std::fs::read_to_string(&path).ok()?;
1081 let session: Self = serde_json::from_str(&json).ok()?;
1082 Some(normalize_loaded_session(session))
1083 }
1084
1085 pub fn list_sessions() -> Vec<SessionSummary> {
1087 let Some(dir) = sessions_dir() else {
1088 return Vec::new();
1089 };
1090
1091 let mut summaries = Vec::new();
1092 if let Ok(entries) = std::fs::read_dir(&dir) {
1093 for entry in entries.flatten() {
1094 let path = entry.path();
1095 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1096 continue;
1097 }
1098 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1099 continue;
1100 }
1101 if let Ok(json) = std::fs::read_to_string(&path) {
1102 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1103 summaries.push(SessionSummary {
1104 id: session.id,
1105 started_at: session.started_at,
1106 updated_at: session.updated_at,
1107 version: session.version,
1108 task: session.task.as_ref().map(|t| t.description.clone()),
1109 tool_calls: session.stats.total_tool_calls,
1110 tokens_saved: session.stats.total_tokens_saved,
1111 });
1112 }
1113 }
1114 }
1115 }
1116
1117 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
1118 summaries
1119 }
1120
1121 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
1123 let Some(dir) = sessions_dir() else { return 0 };
1124
1125 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
1126 let latest = Self::load_latest().map(|s| s.id);
1127 let mut removed = 0u32;
1128
1129 if let Ok(entries) = std::fs::read_dir(&dir) {
1130 for entry in entries.flatten() {
1131 let path = entry.path();
1132 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1133 continue;
1134 }
1135 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
1136 if filename == "latest" || filename.starts_with('.') {
1137 continue;
1138 }
1139 if latest.as_deref() == Some(filename) {
1140 continue;
1141 }
1142 if let Ok(json) = std::fs::read_to_string(&path) {
1143 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1144 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
1145 removed += 1;
1146 }
1147 }
1148 }
1149 }
1150 }
1151
1152 removed
1153 }
1154}
1155
1156#[derive(Debug, Clone)]
1158pub struct SessionSummary {
1159 pub id: String,
1160 pub started_at: DateTime<Utc>,
1161 pub updated_at: DateTime<Utc>,
1162 pub version: u32,
1163 pub task: Option<String>,
1164 pub tool_calls: u32,
1165 pub tokens_saved: u64,
1166}
1167
1168fn escape_xml_attr(value: &str) -> String {
1169 value
1170 .replace('&', "&")
1171 .replace('<', "<")
1172 .replace('>', ">")
1173 .replace('"', """)
1174}
1175
1176fn file_stem_search_pattern(path: &str) -> String {
1177 Path::new(path)
1178 .file_stem()
1179 .and_then(|s| s.to_str())
1180 .map(str::trim)
1181 .filter(|s| !s.is_empty() && s.chars().any(char::is_alphanumeric))
1182 .unwrap_or("")
1183 .to_string()
1184}
1185
1186fn parent_dir_slash(path: &str) -> String {
1187 Path::new(path)
1188 .parent()
1189 .and_then(|p| p.to_str())
1190 .map_or_else(
1191 || "./".to_string(),
1192 |p| {
1193 let norm = p.replace('\\', "/");
1194 let trimmed = norm.trim_end_matches('/');
1195 if trimmed.is_empty() {
1196 "./".to_string()
1197 } else {
1198 format!("{trimmed}/")
1199 }
1200 },
1201 )
1202}
1203
1204fn sessions_dir() -> Option<PathBuf> {
1205 crate::core::data_dir::lean_ctx_data_dir()
1206 .ok()
1207 .map(|d| d.join("sessions"))
1208}
1209
1210fn generate_session_id() -> String {
1211 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1212 let now = Utc::now();
1213 let ts = now.format("%Y%m%d-%H%M%S").to_string();
1214 let nanos = now.timestamp_subsec_micros();
1215 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1216 format!("{ts}-{nanos:06}s{seq}")
1217}
1218
1219fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
1222 let first_cmd = command
1223 .split("&&")
1224 .next()
1225 .unwrap_or(command)
1226 .split(';')
1227 .next()
1228 .unwrap_or(command)
1229 .trim();
1230
1231 if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
1232 return None;
1233 }
1234
1235 let target = first_cmd.strip_prefix("cd")?.trim();
1236 if target.is_empty() || target == "~" {
1237 return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
1238 }
1239
1240 let target = target.trim_matches('"').trim_matches('\'');
1241 let path = std::path::Path::new(target);
1242
1243 if path.is_absolute() {
1244 Some(target.to_string())
1245 } else {
1246 let base = std::path::Path::new(base_cwd);
1247 let joined = base.join(target).to_string_lossy().to_string();
1248 Some(joined.replace('\\', "/"))
1249 }
1250}
1251
1252fn shorten_path(path: &str) -> String {
1253 let parts: Vec<&str> = path.split('/').collect();
1254 if parts.len() <= 2 {
1255 return path.to_string();
1256 }
1257 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
1258 format!("…/{}/{}", last_two[1], last_two[0])
1259}
1260
1261fn normalize_loaded_session(mut session: SessionState) -> SessionState {
1262 if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
1263 session.project_root = None;
1264 }
1265 if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
1266 session.shell_cwd = None;
1267 }
1268
1269 if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
1272 let root_p = std::path::Path::new(root);
1273 let cwd_p = std::path::Path::new(cwd);
1274 let root_looks_real = has_project_marker(root_p);
1275 let cwd_looks_real = has_project_marker(cwd_p);
1276
1277 if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
1278 session.project_root = Some(cwd.clone());
1279 }
1280 }
1281
1282 if !session.terse_mode {
1284 let profile_terse = crate::core::profiles::active_profile()
1285 .compression
1286 .terse_mode_effective();
1287 if profile_terse {
1288 session.terse_mode = true;
1289 }
1290 }
1291
1292 session
1293}
1294
1295fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
1296 if let Some(root) = session.project_root.as_deref() {
1297 let root_path =
1298 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
1299 if root_path == target_root {
1300 return true;
1301 }
1302 if has_project_marker(&root_path) {
1303 return false;
1304 }
1305 }
1306
1307 if let Some(cwd) = session.shell_cwd.as_deref() {
1308 let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
1309 return cwd_path == target_root || cwd_path.starts_with(target_root);
1310 }
1311
1312 false
1313}
1314
1315fn has_project_marker(dir: &std::path::Path) -> bool {
1316 const MARKERS: &[&str] = &[
1317 ".git",
1318 ".lean-ctx.toml",
1319 "Cargo.toml",
1320 "package.json",
1321 "go.mod",
1322 "pyproject.toml",
1323 ".planning",
1324 ];
1325 MARKERS.iter().any(|m| dir.join(m).exists())
1326}
1327
1328fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1329 let s = dir.to_string_lossy();
1330 s.contains("/.claude")
1331 || s.contains("/.codex")
1332 || s.contains("/var/folders/")
1333 || s.contains("/tmp/")
1334 || s.contains("\\.claude")
1335 || s.contains("\\.codex")
1336 || s.contains("\\AppData\\Local\\Temp")
1337 || s.contains("\\Temp\\")
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343
1344 #[test]
1345 fn extract_cd_absolute_path() {
1346 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1347 assert_eq!(result, Some("/usr/local/bin".to_string()));
1348 }
1349
1350 #[test]
1351 fn extract_cd_relative_path() {
1352 let result = extract_cd_target("cd subdir", "/home/user");
1353 assert_eq!(result, Some("/home/user/subdir".to_string()));
1354 }
1355
1356 #[test]
1357 fn extract_cd_with_chained_command() {
1358 let result = extract_cd_target("cd /tmp && ls", "/home/user");
1359 assert_eq!(result, Some("/tmp".to_string()));
1360 }
1361
1362 #[test]
1363 fn extract_cd_with_semicolon() {
1364 let result = extract_cd_target("cd /tmp; ls", "/home/user");
1365 assert_eq!(result, Some("/tmp".to_string()));
1366 }
1367
1368 #[test]
1369 fn extract_cd_parent_dir() {
1370 let result = extract_cd_target("cd ..", "/home/user/project");
1371 assert_eq!(result, Some("/home/user/project/..".to_string()));
1372 }
1373
1374 #[test]
1375 fn extract_cd_no_cd_returns_none() {
1376 let result = extract_cd_target("ls -la", "/home/user");
1377 assert!(result.is_none());
1378 }
1379
1380 #[test]
1381 fn extract_cd_bare_cd_goes_home() {
1382 let result = extract_cd_target("cd", "/home/user");
1383 assert!(result.is_some());
1384 }
1385
1386 #[test]
1387 fn effective_cwd_explicit_takes_priority() {
1388 let mut session = SessionState::new();
1389 session.project_root = Some("/project".to_string());
1390 session.shell_cwd = Some("/project/src".to_string());
1391 assert_eq!(session.effective_cwd(Some("/explicit")), "/explicit");
1392 }
1393
1394 #[test]
1395 fn effective_cwd_shell_cwd_second_priority() {
1396 let mut session = SessionState::new();
1397 session.project_root = Some("/project".to_string());
1398 session.shell_cwd = Some("/project/src".to_string());
1399 assert_eq!(session.effective_cwd(None), "/project/src");
1400 }
1401
1402 #[test]
1403 fn effective_cwd_project_root_third_priority() {
1404 let mut session = SessionState::new();
1405 session.project_root = Some("/project".to_string());
1406 assert_eq!(session.effective_cwd(None), "/project");
1407 }
1408
1409 #[test]
1410 fn effective_cwd_dot_ignored() {
1411 let mut session = SessionState::new();
1412 session.project_root = Some("/project".to_string());
1413 assert_eq!(session.effective_cwd(Some(".")), "/project");
1414 }
1415
1416 #[test]
1417 fn compaction_snapshot_includes_terse_config_when_enabled() {
1418 let mut session = SessionState::new();
1419 session.terse_mode = true;
1420 session.set_task("x", None);
1421 let snapshot = session.build_compaction_snapshot();
1422 assert!(snapshot.contains("<config terse=\"true\" />"));
1423 }
1424
1425 #[test]
1426 fn resume_block_prefixes_terse_instruction_when_enabled() {
1427 let mut session = SessionState::new();
1428 session.terse_mode = true;
1429 let block = session.build_resume_block();
1430 assert!(block.contains("[TERSE MODE]"));
1431 }
1432
1433 #[test]
1434 fn compaction_snapshot_includes_task() {
1435 let mut session = SessionState::new();
1436 session.set_task("fix auth bug", None);
1437 let snapshot = session.build_compaction_snapshot();
1438 assert!(snapshot.contains("<task>fix auth bug</task>"));
1439 assert!(snapshot.contains("<session_snapshot>"));
1440 assert!(snapshot.contains("</session_snapshot>"));
1441 }
1442
1443 #[test]
1444 fn compaction_snapshot_includes_files() {
1445 let mut session = SessionState::new();
1446 session.touch_file("src/auth.rs", None, "full", 500);
1447 session.files_touched[0].modified = true;
1448 session.touch_file("src/main.rs", None, "map", 100);
1449 let snapshot = session.build_compaction_snapshot();
1450 assert!(snapshot.contains("auth.rs"));
1451 assert!(snapshot.contains("<files>"));
1452 }
1453
1454 #[test]
1455 fn compaction_snapshot_includes_decisions() {
1456 let mut session = SessionState::new();
1457 session.add_decision("Use JWT RS256", None);
1458 let snapshot = session.build_compaction_snapshot();
1459 assert!(snapshot.contains("JWT RS256"));
1460 assert!(snapshot.contains("<decisions>"));
1461 }
1462
1463 #[test]
1464 fn compaction_snapshot_respects_size_limit() {
1465 let mut session = SessionState::new();
1466 session.set_task("a]task", None);
1467 for i in 0..100 {
1468 session.add_finding(
1469 Some(&format!("file{i}.rs")),
1470 Some(i),
1471 &format!("Finding number {i} with some detail text here"),
1472 );
1473 }
1474 let snapshot = session.build_compaction_snapshot();
1475 assert!(snapshot.len() <= 2200);
1476 }
1477
1478 #[test]
1479 fn compaction_snapshot_includes_stats() {
1480 let mut session = SessionState::new();
1481 session.stats.total_tool_calls = 42;
1482 session.stats.total_tokens_saved = 10000;
1483 let snapshot = session.build_compaction_snapshot();
1484 assert!(snapshot.contains("calls=42"));
1485 assert!(snapshot.contains("saved=10000"));
1486 }
1487}