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 {
425 let root = self.project_root.as_deref().unwrap_or(".");
426 if let Some(cwd) = explicit_cwd {
427 if !cwd.is_empty() && cwd != "." {
428 return Self::jail_cwd(cwd, root);
429 }
430 }
431 if let Some(ref cwd) = self.shell_cwd {
432 return cwd.clone();
433 }
434 if let Some(ref r) = self.project_root {
435 return r.clone();
436 }
437 std::env::current_dir()
438 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
439 }
440
441 fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
444 let p = std::path::Path::new(candidate);
445 match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
446 Ok(jailed) => jailed.to_string_lossy().to_string(),
447 Err(_) => fallback_root.to_string(),
448 }
449 }
450
451 pub fn update_shell_cwd(&mut self, command: &str) {
456 let base = self.effective_cwd(None);
457 if let Some(new_cwd) = extract_cd_target(command, &base) {
458 let path = std::path::Path::new(&new_cwd);
459 if path.exists() && path.is_dir() {
460 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
461 .to_string_lossy()
462 .to_string();
463 let root = self.project_root.as_deref().unwrap_or(".");
464 if crate::core::pathjail::jail_path(
465 std::path::Path::new(&canonical),
466 std::path::Path::new(root),
467 )
468 .is_ok()
469 {
470 self.shell_cwd = Some(canonical);
471 }
472 }
473 }
474 }
475
476 pub fn format_compact(&self) -> String {
478 let duration = self.updated_at - self.started_at;
479 let hours = duration.num_hours();
480 let mins = duration.num_minutes() % 60;
481 let duration_str = if hours > 0 {
482 format!("{hours}h {mins}m")
483 } else {
484 format!("{mins}m")
485 };
486
487 let mut lines = Vec::new();
488 lines.push(format!(
489 "SESSION v{} | {} | {} calls | {} tok saved",
490 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
491 ));
492
493 if let Some(ref task) = self.task {
494 let pct = task
495 .progress_pct
496 .map_or(String::new(), |p| format!(" [{p}%]"));
497 lines.push(format!("Task: {}{pct}", task.description));
498 }
499
500 if let Some(ref root) = self.project_root {
501 lines.push(format!("Root: {}", shorten_path(root)));
502 }
503
504 if !self.findings.is_empty() {
505 let items: Vec<String> = self
506 .findings
507 .iter()
508 .rev()
509 .take(5)
510 .map(|f| {
511 let loc = match (&f.file, f.line) {
512 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
513 (Some(file), None) => shorten_path(file),
514 _ => String::new(),
515 };
516 if loc.is_empty() {
517 f.summary.clone()
518 } else {
519 format!("{loc} \u{2014} {}", f.summary)
520 }
521 })
522 .collect();
523 lines.push(format!(
524 "Findings ({}): {}",
525 self.findings.len(),
526 items.join(" | ")
527 ));
528 }
529
530 if !self.decisions.is_empty() {
531 let items: Vec<&str> = self
532 .decisions
533 .iter()
534 .rev()
535 .take(3)
536 .map(|d| d.summary.as_str())
537 .collect();
538 lines.push(format!("Decisions: {}", items.join(" | ")));
539 }
540
541 if !self.files_touched.is_empty() {
542 let items: Vec<String> = self
543 .files_touched
544 .iter()
545 .rev()
546 .take(10)
547 .map(|f| {
548 let status = if f.modified { "mod" } else { &f.last_mode };
549 let r = f.file_ref.as_deref().unwrap_or("?");
550 format!("[{r} {} {status}]", shorten_path(&f.path))
551 })
552 .collect();
553 lines.push(format!(
554 "Files ({}): {}",
555 self.files_touched.len(),
556 items.join(" ")
557 ));
558 }
559
560 if let Some(ref tests) = self.test_results {
561 lines.push(format!(
562 "Tests: {}/{} pass ({})",
563 tests.passed, tests.total, tests.command
564 ));
565 }
566
567 if !self.next_steps.is_empty() {
568 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
569 }
570
571 lines.join("\n")
572 }
573
574 pub fn build_compaction_snapshot(&self) -> String {
576 const MAX_SNAPSHOT_BYTES: usize = 2048;
577
578 let mut sections: Vec<(u8, String)> = Vec::new();
579
580 if self.terse_mode {
581 sections.push((0, "<config terse=\"true\" />".to_string()));
582 }
583
584 if let Some(ref task) = self.task {
585 let pct = task
586 .progress_pct
587 .map_or(String::new(), |p| format!(" [{p}%]"));
588 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
589 }
590
591 if !self.files_touched.is_empty() {
592 let modified: Vec<&str> = self
593 .files_touched
594 .iter()
595 .filter(|f| f.modified)
596 .map(|f| f.path.as_str())
597 .collect();
598 let read_only: Vec<&str> = self
599 .files_touched
600 .iter()
601 .filter(|f| !f.modified)
602 .take(10)
603 .map(|f| f.path.as_str())
604 .collect();
605 let mut files_section = String::new();
606 if !modified.is_empty() {
607 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
608 }
609 if !read_only.is_empty() {
610 if !files_section.is_empty() {
611 files_section.push_str(" | ");
612 }
613 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
614 }
615 sections.push((1, format!("<files>{files_section}</files>")));
616 }
617
618 if !self.decisions.is_empty() {
619 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
620 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
621 }
622
623 if !self.findings.is_empty() {
624 let items: Vec<String> = self
625 .findings
626 .iter()
627 .rev()
628 .take(5)
629 .map(|f| f.summary.clone())
630 .collect();
631 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
632 }
633
634 if !self.progress.is_empty() {
635 let items: Vec<String> = self
636 .progress
637 .iter()
638 .rev()
639 .take(5)
640 .map(|p| {
641 let detail = p.detail.as_deref().unwrap_or("");
642 if detail.is_empty() {
643 p.action.clone()
644 } else {
645 format!("{}: {detail}", p.action)
646 }
647 })
648 .collect();
649 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
650 }
651
652 if let Some(ref tests) = self.test_results {
653 sections.push((
654 3,
655 format!(
656 "<tests>{}/{} pass ({})</tests>",
657 tests.passed, tests.total, tests.command
658 ),
659 ));
660 }
661
662 if !self.next_steps.is_empty() {
663 sections.push((
664 3,
665 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
666 ));
667 }
668
669 sections.push((
670 4,
671 format!(
672 "<stats>calls={} saved={}tok</stats>",
673 self.stats.total_tool_calls, self.stats.total_tokens_saved
674 ),
675 ));
676
677 sections.sort_by_key(|(priority, _)| *priority);
678
679 const SNAPSHOT_HARD_CAP: usize = 2200;
680 const CLOSE_TAG: &str = "</session_snapshot>";
681 let open_len = "<session_snapshot>\n".len();
682 let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
683
684 let mut snapshot = String::from("<session_snapshot>\n");
685 for (_, section) in §ions {
686 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
687 break;
688 }
689 snapshot.push_str(section);
690 snapshot.push('\n');
691 }
692
693 let used = snapshot.len().saturating_sub(open_len);
694 let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
695 if suffix_budget > 64 {
696 let suffix = self.build_compaction_structured_suffix(suffix_budget);
697 if !suffix.is_empty() {
698 snapshot.push_str(&suffix);
699 if !suffix.ends_with('\n') {
700 snapshot.push('\n');
701 }
702 }
703 }
704
705 snapshot.push_str(CLOSE_TAG);
706 snapshot
707 }
708
709 fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
711 if max_bytes <= 64 {
712 return String::new();
713 }
714
715 let mut recovery_queries: Vec<String> = Vec::new();
716 for ft in self.files_touched.iter().rev().take(12) {
717 let path_esc = escape_xml_attr(&ft.path);
718 let mode = if ft.last_mode.is_empty() {
719 "map".to_string()
720 } else {
721 escape_xml_attr(&ft.last_mode)
722 };
723 recovery_queries.push(format!(
724 r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
725 ));
726 let pattern = file_stem_search_pattern(&ft.path);
727 if !pattern.is_empty() {
728 let search_dir = parent_dir_slash(&ft.path);
729 let pat_esc = escape_xml_attr(&pattern);
730 let dir_esc = escape_xml_attr(&search_dir);
731 recovery_queries.push(format!(
732 r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
733 ));
734 }
735 }
736
737 let mut parts: Vec<String> = Vec::new();
738 if !recovery_queries.is_empty() {
739 parts.push(format!(
740 "<recovery_queries>\n{}\n</recovery_queries>",
741 recovery_queries.join("\n")
742 ));
743 }
744
745 let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
746 if knowledge_ok {
747 if let Some(q) = self.knowledge_recall_query_stem() {
748 let q_esc = escape_xml_attr(&q);
749 parts.push(format!(
750 "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
751 ));
752 }
753 }
754
755 if let Some(root) = self
756 .project_root
757 .as_deref()
758 .filter(|r| !r.trim().is_empty())
759 {
760 let root_trim = root.trim_end_matches('/');
761 let mut cluster_lines: Vec<String> = Vec::new();
762 for ft in self.files_touched.iter().rev().take(3) {
763 let primary_esc = escape_xml_attr(&ft.path);
764 let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
765 let related_csv =
766 graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
767 .map(|s| escape_xml_attr(&s))
768 .unwrap_or_default();
769 if related_csv.is_empty() {
770 continue;
771 }
772 cluster_lines.push(format!(
773 r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
774 ));
775 }
776 if !cluster_lines.is_empty() {
777 parts.push(format!(
778 "<graph_context>\n{}\n</graph_context>",
779 cluster_lines.join("\n")
780 ));
781 }
782 }
783
784 Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
785 }
786
787 fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
788 let mut out = parts.join("\n");
789 while out.len() > max_bytes && !parts.is_empty() {
790 parts.pop();
791 out = parts.join("\n");
792 }
793 if out.len() <= max_bytes {
794 return out;
795 }
796 if let Some(idx) = parts
797 .iter()
798 .position(|p| p.starts_with("<recovery_queries>"))
799 {
800 let mut lines: Vec<String> = parts[idx]
801 .lines()
802 .filter(|l| l.starts_with("<query "))
803 .map(str::to_string)
804 .collect();
805 while !lines.is_empty() && out.len() > max_bytes {
806 if lines.len() == 1 {
807 parts.remove(idx);
808 out = parts.join("\n");
809 break;
810 }
811 lines.truncate(lines.len().saturating_sub(2));
812 parts[idx] = format!(
813 "<recovery_queries>\n{}\n</recovery_queries>",
814 lines.join("\n")
815 );
816 out = parts.join("\n");
817 }
818 }
819 if out.len() > max_bytes {
820 return String::new();
821 }
822 out
823 }
824
825 fn knowledge_recall_query_stem(&self) -> Option<String> {
826 let mut bits: Vec<String> = Vec::new();
827 if let Some(ref t) = self.task {
828 bits.push(Self::task_keyword_stem(&t.description));
829 }
830 if bits.iter().all(std::string::String::is_empty) {
831 if let Some(f) = self.findings.last() {
832 bits.push(Self::task_keyword_stem(&f.summary));
833 } else if let Some(d) = self.decisions.last() {
834 bits.push(Self::task_keyword_stem(&d.summary));
835 }
836 }
837 let q = bits.join(" ").trim().to_string();
838 if q.is_empty() {
839 None
840 } else {
841 Some(q)
842 }
843 }
844
845 fn task_keyword_stem(text: &str) -> String {
846 const STOP: &[&str] = &[
847 "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
848 "be", "this", "that", "it", "as", "at", "by", "from",
849 ];
850 text.split_whitespace()
851 .filter_map(|w| {
852 let w = w.trim_matches(|c: char| !c.is_alphanumeric());
853 if w.len() < 3 {
854 return None;
855 }
856 let lower = w.to_lowercase();
857 if STOP.contains(&lower.as_str()) {
858 return None;
859 }
860 Some(w.to_string())
861 })
862 .take(8)
863 .collect::<Vec<_>>()
864 .join(" ")
865 }
866
867 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
869 let snapshot = self.build_compaction_snapshot();
870 let dir = sessions_dir().ok_or("cannot determine home directory")?;
871 if !dir.exists() {
872 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
873 }
874 let path = dir.join(format!("{}_snapshot.txt", self.id));
875 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
876 Ok(snapshot)
877 }
878
879 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
881 let dir = sessions_dir()?;
882 let path = dir.join(format!("{session_id}_snapshot.txt"));
883 std::fs::read_to_string(&path).ok()
884 }
885
886 pub fn load_latest_snapshot() -> Option<String> {
892 let dir = sessions_dir()?;
893 let project_root = std::env::current_dir()
894 .ok()
895 .map(|p| p.to_string_lossy().to_string());
896
897 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
898 .ok()?
899 .filter_map(std::result::Result::ok)
900 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
901 .filter_map(|e| {
902 let meta = e.metadata().ok()?;
903 let modified = meta.modified().ok()?;
904
905 if let Some(ref root) = project_root {
906 let content = std::fs::read_to_string(e.path()).ok()?;
907 if !content.contains(root) {
908 return None;
909 }
910 }
911
912 Some((modified, e.path()))
913 })
914 .collect();
915
916 snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
917 snapshots
918 .first()
919 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
920 }
921
922 pub fn build_resume_block(&self) -> String {
925 let mut parts: Vec<String> = Vec::new();
926
927 if self.terse_mode {
928 parts.push(
929 "[TERSE MODE] Keep responses concise. Use bullet points, avoid filler. Focus on code and actions, not explanations."
930 .to_string(),
931 );
932 }
933
934 if let Some(ref root) = self.project_root {
935 let short = root.rsplit('/').next().unwrap_or(root);
936 parts.push(format!("Project: {short}"));
937 }
938
939 if let Some(ref task) = self.task {
940 let pct = task
941 .progress_pct
942 .map_or(String::new(), |p| format!(" [{p}%]"));
943 parts.push(format!("Task: {}{pct}", task.description));
944 }
945
946 if !self.decisions.is_empty() {
947 let items: Vec<&str> = self
948 .decisions
949 .iter()
950 .rev()
951 .take(5)
952 .map(|d| d.summary.as_str())
953 .collect();
954 parts.push(format!("Decisions: {}", items.join("; ")));
955 }
956
957 if !self.files_touched.is_empty() {
958 let modified: Vec<&str> = self
959 .files_touched
960 .iter()
961 .filter(|f| f.modified)
962 .take(10)
963 .map(|f| f.path.as_str())
964 .collect();
965 if !modified.is_empty() {
966 parts.push(format!("Modified: {}", modified.join(", ")));
967 }
968 }
969
970 if !self.next_steps.is_empty() {
971 let steps: Vec<&str> = self
972 .next_steps
973 .iter()
974 .take(3)
975 .map(std::string::String::as_str)
976 .collect();
977 parts.push(format!("Next: {}", steps.join("; ")));
978 }
979
980 let archives = super::archive::list_entries(Some(&self.id));
981 if !archives.is_empty() {
982 let hints: Vec<String> = archives
983 .iter()
984 .take(5)
985 .map(|a| format!("{}({})", a.id, a.tool))
986 .collect();
987 parts.push(format!("Archives: {}", hints.join(", ")));
988 }
989
990 parts.push(format!(
991 "Stats: {} calls, {} tok saved",
992 self.stats.total_tool_calls, self.stats.total_tokens_saved
993 ));
994
995 format!(
996 "--- SESSION RESUME (post-compaction) ---\n{}\n---",
997 parts.join("\n")
998 )
999 }
1000
1001 pub fn save(&mut self) -> Result<(), String> {
1003 let prepared = self.prepare_save()?;
1004 match prepared.write_to_disk() {
1005 Ok(()) => Ok(()),
1006 Err(e) => {
1007 self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
1008 Err(e)
1009 }
1010 }
1011 }
1012
1013 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
1017 let dir = sessions_dir().ok_or("cannot determine home directory")?;
1018 let compaction_snapshot = if self.stats.total_tool_calls > 0 {
1019 Some(self.build_compaction_snapshot())
1020 } else {
1021 None
1022 };
1023 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
1024 let pointer_json = serde_json::to_string(&LatestPointer {
1025 id: self.id.clone(),
1026 })
1027 .map_err(|e| e.to_string())?;
1028 self.stats.unsaved_changes = 0;
1029 Ok(PreparedSave {
1030 dir,
1031 id: self.id.clone(),
1032 json,
1033 pointer_json,
1034 compaction_snapshot,
1035 })
1036 }
1037
1038 pub fn load_latest() -> Option<Self> {
1044 if let Some(project_root) = std::env::current_dir()
1045 .ok()
1046 .map(|p| p.to_string_lossy().to_string())
1047 {
1048 if let Some(session) = Self::load_latest_for_project_root(&project_root) {
1049 return Some(session);
1050 }
1051 }
1052 let dir = sessions_dir()?;
1053 let latest_path = dir.join("latest.json");
1054 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
1055 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
1056 Self::load_by_id(&pointer.id)
1057 }
1058
1059 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
1061 let dir = sessions_dir()?;
1062 let target_root =
1063 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
1064 let mut latest_match: Option<Self> = None;
1065
1066 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
1067 let path = entry.path();
1068 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1069 continue;
1070 }
1071 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1072 continue;
1073 }
1074
1075 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
1076 continue;
1077 };
1078 let Some(session) = Self::load_by_id(id) else {
1079 continue;
1080 };
1081
1082 if !session_matches_project_root(&session, &target_root) {
1083 continue;
1084 }
1085
1086 if latest_match
1087 .as_ref()
1088 .is_none_or(|existing| session.updated_at > existing.updated_at)
1089 {
1090 latest_match = Some(session);
1091 }
1092 }
1093
1094 latest_match
1095 }
1096
1097 pub fn load_by_id(id: &str) -> Option<Self> {
1099 let dir = sessions_dir()?;
1100 let path = dir.join(format!("{id}.json"));
1101 let json = std::fs::read_to_string(&path).ok()?;
1102 let session: Self = serde_json::from_str(&json).ok()?;
1103 Some(normalize_loaded_session(session))
1104 }
1105
1106 pub fn list_sessions() -> Vec<SessionSummary> {
1108 let Some(dir) = sessions_dir() else {
1109 return Vec::new();
1110 };
1111
1112 let mut summaries = Vec::new();
1113 if let Ok(entries) = std::fs::read_dir(&dir) {
1114 for entry in entries.flatten() {
1115 let path = entry.path();
1116 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1117 continue;
1118 }
1119 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1120 continue;
1121 }
1122 if let Ok(json) = std::fs::read_to_string(&path) {
1123 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1124 summaries.push(SessionSummary {
1125 id: session.id,
1126 started_at: session.started_at,
1127 updated_at: session.updated_at,
1128 version: session.version,
1129 task: session.task.as_ref().map(|t| t.description.clone()),
1130 tool_calls: session.stats.total_tool_calls,
1131 tokens_saved: session.stats.total_tokens_saved,
1132 });
1133 }
1134 }
1135 }
1136 }
1137
1138 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
1139 summaries
1140 }
1141
1142 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
1144 let Some(dir) = sessions_dir() else { return 0 };
1145
1146 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
1147 let latest = Self::load_latest().map(|s| s.id);
1148 let mut removed = 0u32;
1149
1150 if let Ok(entries) = std::fs::read_dir(&dir) {
1151 for entry in entries.flatten() {
1152 let path = entry.path();
1153 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1154 continue;
1155 }
1156 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
1157 if filename == "latest" || filename.starts_with('.') {
1158 continue;
1159 }
1160 if latest.as_deref() == Some(filename) {
1161 continue;
1162 }
1163 if let Ok(json) = std::fs::read_to_string(&path) {
1164 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1165 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
1166 removed += 1;
1167 }
1168 }
1169 }
1170 }
1171 }
1172
1173 removed
1174 }
1175}
1176
1177#[derive(Debug, Clone)]
1179pub struct SessionSummary {
1180 pub id: String,
1181 pub started_at: DateTime<Utc>,
1182 pub updated_at: DateTime<Utc>,
1183 pub version: u32,
1184 pub task: Option<String>,
1185 pub tool_calls: u32,
1186 pub tokens_saved: u64,
1187}
1188
1189fn escape_xml_attr(value: &str) -> String {
1190 value
1191 .replace('&', "&")
1192 .replace('<', "<")
1193 .replace('>', ">")
1194 .replace('"', """)
1195}
1196
1197fn file_stem_search_pattern(path: &str) -> String {
1198 Path::new(path)
1199 .file_stem()
1200 .and_then(|s| s.to_str())
1201 .map(str::trim)
1202 .filter(|s| !s.is_empty() && s.chars().any(char::is_alphanumeric))
1203 .unwrap_or("")
1204 .to_string()
1205}
1206
1207fn parent_dir_slash(path: &str) -> String {
1208 Path::new(path)
1209 .parent()
1210 .and_then(|p| p.to_str())
1211 .map_or_else(
1212 || "./".to_string(),
1213 |p| {
1214 let norm = p.replace('\\', "/");
1215 let trimmed = norm.trim_end_matches('/');
1216 if trimmed.is_empty() {
1217 "./".to_string()
1218 } else {
1219 format!("{trimmed}/")
1220 }
1221 },
1222 )
1223}
1224
1225fn sessions_dir() -> Option<PathBuf> {
1226 crate::core::data_dir::lean_ctx_data_dir()
1227 .ok()
1228 .map(|d| d.join("sessions"))
1229}
1230
1231fn generate_session_id() -> String {
1232 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1233 let now = Utc::now();
1234 let ts = now.format("%Y%m%d-%H%M%S").to_string();
1235 let nanos = now.timestamp_subsec_micros();
1236 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1237 format!("{ts}-{nanos:06}s{seq}")
1238}
1239
1240fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
1243 let first_cmd = command
1244 .split("&&")
1245 .next()
1246 .unwrap_or(command)
1247 .split(';')
1248 .next()
1249 .unwrap_or(command)
1250 .trim();
1251
1252 if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
1253 return None;
1254 }
1255
1256 let target = first_cmd.strip_prefix("cd")?.trim();
1257 if target.is_empty() || target == "~" {
1258 return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
1259 }
1260
1261 let target = target.trim_matches('"').trim_matches('\'');
1262 let path = std::path::Path::new(target);
1263
1264 if path.is_absolute() {
1265 Some(target.to_string())
1266 } else {
1267 let base = std::path::Path::new(base_cwd);
1268 let joined = base.join(target).to_string_lossy().to_string();
1269 Some(joined.replace('\\', "/"))
1270 }
1271}
1272
1273fn shorten_path(path: &str) -> String {
1274 let parts: Vec<&str> = path.split('/').collect();
1275 if parts.len() <= 2 {
1276 return path.to_string();
1277 }
1278 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
1279 format!("…/{}/{}", last_two[1], last_two[0])
1280}
1281
1282fn normalize_loaded_session(mut session: SessionState) -> SessionState {
1283 if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
1284 session.project_root = None;
1285 }
1286 if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
1287 session.shell_cwd = None;
1288 }
1289
1290 if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
1293 let root_p = std::path::Path::new(root);
1294 let cwd_p = std::path::Path::new(cwd);
1295 let root_looks_real = has_project_marker(root_p);
1296 let cwd_looks_real = has_project_marker(cwd_p);
1297
1298 if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
1299 session.project_root = Some(cwd.clone());
1300 }
1301 }
1302
1303 if !session.terse_mode {
1305 let profile_terse = crate::core::profiles::active_profile()
1306 .compression
1307 .terse_mode_effective();
1308 if profile_terse {
1309 session.terse_mode = true;
1310 }
1311 }
1312
1313 session
1314}
1315
1316fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
1317 if let Some(root) = session.project_root.as_deref() {
1318 let root_path =
1319 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
1320 if root_path == target_root {
1321 return true;
1322 }
1323 if has_project_marker(&root_path) {
1324 return false;
1325 }
1326 }
1327
1328 if let Some(cwd) = session.shell_cwd.as_deref() {
1329 let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
1330 return cwd_path == target_root || cwd_path.starts_with(target_root);
1331 }
1332
1333 false
1334}
1335
1336fn has_project_marker(dir: &std::path::Path) -> bool {
1337 const MARKERS: &[&str] = &[
1338 ".git",
1339 ".lean-ctx.toml",
1340 "Cargo.toml",
1341 "package.json",
1342 "go.mod",
1343 "pyproject.toml",
1344 ".planning",
1345 ];
1346 MARKERS.iter().any(|m| dir.join(m).exists())
1347}
1348
1349fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1350 let s = dir.to_string_lossy();
1351 s.contains("/.claude")
1352 || s.contains("/.codex")
1353 || s.contains("/var/folders/")
1354 || s.contains("/tmp/")
1355 || s.contains("\\.claude")
1356 || s.contains("\\.codex")
1357 || s.contains("\\AppData\\Local\\Temp")
1358 || s.contains("\\Temp\\")
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363 use super::*;
1364
1365 #[test]
1366 fn extract_cd_absolute_path() {
1367 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1368 assert_eq!(result, Some("/usr/local/bin".to_string()));
1369 }
1370
1371 #[test]
1372 fn extract_cd_relative_path() {
1373 let result = extract_cd_target("cd subdir", "/home/user");
1374 assert_eq!(result, Some("/home/user/subdir".to_string()));
1375 }
1376
1377 #[test]
1378 fn extract_cd_with_chained_command() {
1379 let result = extract_cd_target("cd /tmp && ls", "/home/user");
1380 assert_eq!(result, Some("/tmp".to_string()));
1381 }
1382
1383 #[test]
1384 fn extract_cd_with_semicolon() {
1385 let result = extract_cd_target("cd /tmp; ls", "/home/user");
1386 assert_eq!(result, Some("/tmp".to_string()));
1387 }
1388
1389 #[test]
1390 fn extract_cd_parent_dir() {
1391 let result = extract_cd_target("cd ..", "/home/user/project");
1392 assert_eq!(result, Some("/home/user/project/..".to_string()));
1393 }
1394
1395 #[test]
1396 fn extract_cd_no_cd_returns_none() {
1397 let result = extract_cd_target("ls -la", "/home/user");
1398 assert!(result.is_none());
1399 }
1400
1401 #[test]
1402 fn extract_cd_bare_cd_goes_home() {
1403 let result = extract_cd_target("cd", "/home/user");
1404 assert!(result.is_some());
1405 }
1406
1407 #[test]
1408 fn effective_cwd_explicit_takes_priority() {
1409 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
1410 let sub = tmp.join("sub");
1411 let _ = std::fs::create_dir_all(&sub);
1412 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1413 .to_string_lossy()
1414 .to_string();
1415 let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
1416 .to_string_lossy()
1417 .to_string();
1418
1419 let mut session = SessionState::new();
1420 session.project_root = Some(root_canon);
1421 let result = session.effective_cwd(Some(&sub_canon));
1422 assert_eq!(result, sub_canon);
1423 let _ = std::fs::remove_dir_all(&tmp);
1424 }
1425
1426 #[test]
1427 fn effective_cwd_explicit_outside_root_is_jailed() {
1428 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
1429 let _ = std::fs::create_dir_all(&tmp);
1430 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1431 .to_string_lossy()
1432 .to_string();
1433
1434 let mut session = SessionState::new();
1435 session.project_root = Some(root_canon.clone());
1436 let result = session.effective_cwd(Some("/nonexistent-outside-path"));
1437 assert_eq!(result, root_canon);
1438 let _ = std::fs::remove_dir_all(&tmp);
1439 }
1440
1441 #[test]
1442 fn effective_cwd_shell_cwd_second_priority() {
1443 let mut session = SessionState::new();
1444 session.project_root = Some("/project".to_string());
1445 session.shell_cwd = Some("/project/src".to_string());
1446 assert_eq!(session.effective_cwd(None), "/project/src");
1447 }
1448
1449 #[test]
1450 fn effective_cwd_project_root_third_priority() {
1451 let mut session = SessionState::new();
1452 session.project_root = Some("/project".to_string());
1453 assert_eq!(session.effective_cwd(None), "/project");
1454 }
1455
1456 #[test]
1457 fn effective_cwd_dot_ignored() {
1458 let mut session = SessionState::new();
1459 session.project_root = Some("/project".to_string());
1460 assert_eq!(session.effective_cwd(Some(".")), "/project");
1461 }
1462
1463 #[test]
1464 fn compaction_snapshot_includes_terse_config_when_enabled() {
1465 let mut session = SessionState::new();
1466 session.terse_mode = true;
1467 session.set_task("x", None);
1468 let snapshot = session.build_compaction_snapshot();
1469 assert!(snapshot.contains("<config terse=\"true\" />"));
1470 }
1471
1472 #[test]
1473 fn resume_block_prefixes_terse_instruction_when_enabled() {
1474 let mut session = SessionState::new();
1475 session.terse_mode = true;
1476 let block = session.build_resume_block();
1477 assert!(block.contains("[TERSE MODE]"));
1478 }
1479
1480 #[test]
1481 fn compaction_snapshot_includes_task() {
1482 let mut session = SessionState::new();
1483 session.set_task("fix auth bug", None);
1484 let snapshot = session.build_compaction_snapshot();
1485 assert!(snapshot.contains("<task>fix auth bug</task>"));
1486 assert!(snapshot.contains("<session_snapshot>"));
1487 assert!(snapshot.contains("</session_snapshot>"));
1488 }
1489
1490 #[test]
1491 fn compaction_snapshot_includes_files() {
1492 let mut session = SessionState::new();
1493 session.touch_file("src/auth.rs", None, "full", 500);
1494 session.files_touched[0].modified = true;
1495 session.touch_file("src/main.rs", None, "map", 100);
1496 let snapshot = session.build_compaction_snapshot();
1497 assert!(snapshot.contains("auth.rs"));
1498 assert!(snapshot.contains("<files>"));
1499 }
1500
1501 #[test]
1502 fn compaction_snapshot_includes_decisions() {
1503 let mut session = SessionState::new();
1504 session.add_decision("Use JWT RS256", None);
1505 let snapshot = session.build_compaction_snapshot();
1506 assert!(snapshot.contains("JWT RS256"));
1507 assert!(snapshot.contains("<decisions>"));
1508 }
1509
1510 #[test]
1511 fn compaction_snapshot_respects_size_limit() {
1512 let mut session = SessionState::new();
1513 session.set_task("a]task", None);
1514 for i in 0..100 {
1515 session.add_finding(
1516 Some(&format!("file{i}.rs")),
1517 Some(i),
1518 &format!("Finding number {i} with some detail text here"),
1519 );
1520 }
1521 let snapshot = session.build_compaction_snapshot();
1522 assert!(snapshot.len() <= 2200);
1523 }
1524
1525 #[test]
1526 fn compaction_snapshot_includes_stats() {
1527 let mut session = SessionState::new();
1528 session.stats.total_tool_calls = 42;
1529 session.stats.total_tokens_saved = 10000;
1530 let snapshot = session.build_compaction_snapshot();
1531 assert!(snapshot.contains("calls=42"));
1532 assert!(snapshot.contains("saved=10000"));
1533 }
1534}