1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::core::intent_protocol::{IntentRecord, IntentSource};
6
7const MAX_FINDINGS: usize = 20;
8const MAX_DECISIONS: usize = 10;
9const MAX_FILES: usize = 50;
10const MAX_EVIDENCE: usize = 500;
11const BATCH_SAVE_INTERVAL: u32 = 5;
12
13#[derive(Serialize, Deserialize, Clone, Debug)]
15pub struct SessionState {
16 pub id: String,
17 pub version: u32,
18 pub started_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20 pub project_root: Option<String>,
21 #[serde(default)]
22 pub shell_cwd: Option<String>,
23 pub task: Option<TaskInfo>,
24 pub findings: Vec<Finding>,
25 pub decisions: Vec<Decision>,
26 pub files_touched: Vec<FileTouched>,
27 pub test_results: Option<TestSnapshot>,
28 pub progress: Vec<ProgressEntry>,
29 pub next_steps: Vec<String>,
30 #[serde(default)]
31 pub evidence: Vec<EvidenceRecord>,
32 #[serde(default)]
33 pub intents: Vec<IntentRecord>,
34 #[serde(default)]
35 pub active_structured_intent: Option<crate::core::intent_engine::StructuredIntent>,
36 pub stats: SessionStats,
37}
38
39#[derive(Serialize, Deserialize, Clone, Debug)]
41pub struct TaskInfo {
42 pub description: String,
43 pub intent: Option<String>,
44 pub progress_pct: Option<u8>,
45}
46
47#[derive(Serialize, Deserialize, Clone, Debug)]
49pub struct Finding {
50 pub file: Option<String>,
51 pub line: Option<u32>,
52 pub summary: String,
53 pub timestamp: DateTime<Utc>,
54}
55
56#[derive(Serialize, Deserialize, Clone, Debug)]
58pub struct Decision {
59 pub summary: String,
60 pub rationale: Option<String>,
61 pub timestamp: DateTime<Utc>,
62}
63
64#[derive(Serialize, Deserialize, Clone, Debug)]
66pub struct FileTouched {
67 pub path: String,
68 pub file_ref: Option<String>,
69 pub read_count: u32,
70 pub modified: bool,
71 pub last_mode: String,
72 pub tokens: usize,
73}
74
75#[derive(Serialize, Deserialize, Clone, Debug)]
77pub struct TestSnapshot {
78 pub command: String,
79 pub passed: u32,
80 pub failed: u32,
81 pub total: u32,
82 pub timestamp: DateTime<Utc>,
83}
84
85#[derive(Serialize, Deserialize, Clone, Debug)]
87pub struct ProgressEntry {
88 pub action: String,
89 pub detail: Option<String>,
90 pub timestamp: DateTime<Utc>,
91}
92
93#[derive(Serialize, Deserialize, Clone, Debug)]
95#[serde(rename_all = "snake_case")]
96pub enum EvidenceKind {
97 ToolCall,
98 Manual,
99}
100
101#[derive(Serialize, Deserialize, Clone, Debug)]
103pub struct EvidenceRecord {
104 pub kind: EvidenceKind,
105 pub key: String,
106 pub value: Option<String>,
107 pub tool: Option<String>,
108 pub input_md5: Option<String>,
109 pub output_md5: Option<String>,
110 pub agent_id: Option<String>,
111 pub client_name: Option<String>,
112 pub timestamp: DateTime<Utc>,
113}
114
115#[derive(Serialize, Deserialize, Clone, Debug, Default)]
117#[serde(default)]
118pub struct SessionStats {
119 pub total_tool_calls: u32,
120 pub total_tokens_saved: u64,
121 pub total_tokens_input: u64,
122 pub cache_hits: u32,
123 pub files_read: u32,
124 pub commands_run: u32,
125 pub intents_inferred: u32,
126 pub intents_explicit: u32,
127 pub unsaved_changes: u32,
128}
129
130#[derive(Serialize, Deserialize, Clone, Debug)]
131struct LatestPointer {
132 id: String,
133}
134
135pub struct PreparedSave {
139 dir: PathBuf,
140 id: String,
141 json: String,
142 pointer_json: String,
143}
144
145impl PreparedSave {
146 pub fn write_to_disk(self) -> Result<(), String> {
148 if !self.dir.exists() {
149 std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
150 }
151 let path = self.dir.join(format!("{}.json", self.id));
152 let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
153 std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
154 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
155
156 let latest_path = self.dir.join("latest.json");
157 let latest_tmp = self.dir.join(".latest.json.tmp");
158 std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
159 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
160 Ok(())
161 }
162}
163
164impl Default for SessionState {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl SessionState {
171 pub fn new() -> Self {
173 let now = Utc::now();
174 Self {
175 id: generate_session_id(),
176 version: 0,
177 started_at: now,
178 updated_at: now,
179 project_root: None,
180 shell_cwd: None,
181 task: None,
182 findings: Vec::new(),
183 decisions: Vec::new(),
184 files_touched: Vec::new(),
185 test_results: None,
186 progress: Vec::new(),
187 next_steps: Vec::new(),
188 evidence: Vec::new(),
189 intents: Vec::new(),
190 active_structured_intent: None,
191 stats: SessionStats::default(),
192 }
193 }
194
195 pub fn increment(&mut self) {
197 self.version += 1;
198 self.updated_at = Utc::now();
199 self.stats.unsaved_changes += 1;
200 }
201
202 pub fn should_save(&self) -> bool {
204 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
205 }
206
207 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
209 self.task = Some(TaskInfo {
210 description: description.to_string(),
211 intent: intent.map(std::string::ToString::to_string),
212 progress_pct: None,
213 });
214
215 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
216 let si = if touched.is_empty() {
217 crate::core::intent_engine::StructuredIntent::from_query(description)
218 } else {
219 crate::core::intent_engine::StructuredIntent::from_query_with_session(
220 description,
221 &touched,
222 )
223 };
224 if si.confidence >= 0.7 {
225 self.active_structured_intent = Some(si);
226 }
227
228 self.increment();
229 }
230
231 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
233 self.findings.push(Finding {
234 file: file.map(std::string::ToString::to_string),
235 line,
236 summary: summary.to_string(),
237 timestamp: Utc::now(),
238 });
239 while self.findings.len() > MAX_FINDINGS {
240 self.findings.remove(0);
241 }
242 self.increment();
243 }
244
245 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
247 self.decisions.push(Decision {
248 summary: summary.to_string(),
249 rationale: rationale.map(std::string::ToString::to_string),
250 timestamp: Utc::now(),
251 });
252 while self.decisions.len() > MAX_DECISIONS {
253 self.decisions.remove(0);
254 }
255 self.increment();
256 }
257
258 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
260 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
261 existing.read_count += 1;
262 existing.last_mode = mode.to_string();
263 existing.tokens = tokens;
264 if let Some(r) = file_ref {
265 existing.file_ref = Some(r.to_string());
266 }
267 } else {
268 self.files_touched.push(FileTouched {
269 path: path.to_string(),
270 file_ref: file_ref.map(std::string::ToString::to_string),
271 read_count: 1,
272 modified: false,
273 last_mode: mode.to_string(),
274 tokens,
275 });
276 while self.files_touched.len() > MAX_FILES {
277 self.files_touched.remove(0);
278 }
279 }
280 self.stats.files_read += 1;
281 self.increment();
282 }
283
284 pub fn mark_modified(&mut self, path: &str) {
286 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
287 existing.modified = true;
288 }
289 self.increment();
290 }
291
292 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
294 self.stats.total_tool_calls += 1;
295 self.stats.total_tokens_saved += tokens_saved;
296 self.stats.total_tokens_input += tokens_input;
297 }
298
299 pub fn record_intent(&mut self, mut intent: IntentRecord) {
301 if intent.occurrences == 0 {
302 intent.occurrences = 1;
303 }
304
305 if let Some(last) = self.intents.last_mut() {
306 if last.fingerprint() == intent.fingerprint() {
307 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
308 last.timestamp = intent.timestamp;
309 match intent.source {
310 IntentSource::Inferred => self.stats.intents_inferred += 1,
311 IntentSource::Explicit => self.stats.intents_explicit += 1,
312 }
313 self.increment();
314 return;
315 }
316 }
317
318 match intent.source {
319 IntentSource::Inferred => self.stats.intents_inferred += 1,
320 IntentSource::Explicit => self.stats.intents_explicit += 1,
321 }
322
323 self.intents.push(intent);
324 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
325 self.intents.remove(0);
326 }
327 self.increment();
328 }
329
330 pub fn record_tool_receipt(
332 &mut self,
333 tool: &str,
334 action: Option<&str>,
335 input_md5: &str,
336 output_md5: &str,
337 agent_id: Option<&str>,
338 client_name: Option<&str>,
339 ) {
340 let now = Utc::now();
341 let mut push = |key: String| {
342 self.evidence.push(EvidenceRecord {
343 kind: EvidenceKind::ToolCall,
344 key,
345 value: None,
346 tool: Some(tool.to_string()),
347 input_md5: Some(input_md5.to_string()),
348 output_md5: Some(output_md5.to_string()),
349 agent_id: agent_id.map(std::string::ToString::to_string),
350 client_name: client_name.map(std::string::ToString::to_string),
351 timestamp: now,
352 });
353 };
354
355 push(format!("tool:{tool}"));
356 if let Some(a) = action {
357 push(format!("tool:{tool}:{a}"));
358 }
359 while self.evidence.len() > MAX_EVIDENCE {
360 self.evidence.remove(0);
361 }
362 self.increment();
363 }
364
365 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
367 self.evidence.push(EvidenceRecord {
368 kind: EvidenceKind::Manual,
369 key: key.to_string(),
370 value: value.map(std::string::ToString::to_string),
371 tool: None,
372 input_md5: None,
373 output_md5: None,
374 agent_id: None,
375 client_name: None,
376 timestamp: Utc::now(),
377 });
378 while self.evidence.len() > MAX_EVIDENCE {
379 self.evidence.remove(0);
380 }
381 self.increment();
382 }
383
384 pub fn has_evidence_key(&self, key: &str) -> bool {
386 self.evidence.iter().any(|e| e.key == key)
387 }
388
389 pub fn record_cache_hit(&mut self) {
391 self.stats.cache_hits += 1;
392 }
393
394 pub fn record_command(&mut self) {
396 self.stats.commands_run += 1;
397 }
398
399 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
402 if let Some(cwd) = explicit_cwd {
403 if !cwd.is_empty() && cwd != "." {
404 return cwd.to_string();
405 }
406 }
407 if let Some(ref cwd) = self.shell_cwd {
408 return cwd.clone();
409 }
410 if let Some(ref root) = self.project_root {
411 return root.clone();
412 }
413 std::env::current_dir()
414 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
415 }
416
417 pub fn update_shell_cwd(&mut self, command: &str) {
421 let base = self.effective_cwd(None);
422 if let Some(new_cwd) = extract_cd_target(command, &base) {
423 let path = std::path::Path::new(&new_cwd);
424 if path.exists() && path.is_dir() {
425 self.shell_cwd = Some(
426 crate::core::pathutil::safe_canonicalize_or_self(path)
427 .to_string_lossy()
428 .to_string(),
429 );
430 }
431 }
432 }
433
434 pub fn format_compact(&self) -> String {
436 let duration = self.updated_at - self.started_at;
437 let hours = duration.num_hours();
438 let mins = duration.num_minutes() % 60;
439 let duration_str = if hours > 0 {
440 format!("{hours}h {mins}m")
441 } else {
442 format!("{mins}m")
443 };
444
445 let mut lines = Vec::new();
446 lines.push(format!(
447 "SESSION v{} | {} | {} calls | {} tok saved",
448 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
449 ));
450
451 if let Some(ref task) = self.task {
452 let pct = task
453 .progress_pct
454 .map_or(String::new(), |p| format!(" [{p}%]"));
455 lines.push(format!("Task: {}{pct}", task.description));
456 }
457
458 if let Some(ref root) = self.project_root {
459 lines.push(format!("Root: {}", shorten_path(root)));
460 }
461
462 if !self.findings.is_empty() {
463 let items: Vec<String> = self
464 .findings
465 .iter()
466 .rev()
467 .take(5)
468 .map(|f| {
469 let loc = match (&f.file, f.line) {
470 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
471 (Some(file), None) => shorten_path(file),
472 _ => String::new(),
473 };
474 if loc.is_empty() {
475 f.summary.clone()
476 } else {
477 format!("{loc} \u{2014} {}", f.summary)
478 }
479 })
480 .collect();
481 lines.push(format!(
482 "Findings ({}): {}",
483 self.findings.len(),
484 items.join(" | ")
485 ));
486 }
487
488 if !self.decisions.is_empty() {
489 let items: Vec<&str> = self
490 .decisions
491 .iter()
492 .rev()
493 .take(3)
494 .map(|d| d.summary.as_str())
495 .collect();
496 lines.push(format!("Decisions: {}", items.join(" | ")));
497 }
498
499 if !self.files_touched.is_empty() {
500 let items: Vec<String> = self
501 .files_touched
502 .iter()
503 .rev()
504 .take(10)
505 .map(|f| {
506 let status = if f.modified { "mod" } else { &f.last_mode };
507 let r = f.file_ref.as_deref().unwrap_or("?");
508 format!("[{r} {} {status}]", shorten_path(&f.path))
509 })
510 .collect();
511 lines.push(format!(
512 "Files ({}): {}",
513 self.files_touched.len(),
514 items.join(" ")
515 ));
516 }
517
518 if let Some(ref tests) = self.test_results {
519 lines.push(format!(
520 "Tests: {}/{} pass ({})",
521 tests.passed, tests.total, tests.command
522 ));
523 }
524
525 if !self.next_steps.is_empty() {
526 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
527 }
528
529 lines.join("\n")
530 }
531
532 pub fn build_compaction_snapshot(&self) -> String {
534 const MAX_SNAPSHOT_BYTES: usize = 2048;
535
536 let mut sections: Vec<(u8, String)> = Vec::new();
537
538 if let Some(ref task) = self.task {
539 let pct = task
540 .progress_pct
541 .map_or(String::new(), |p| format!(" [{p}%]"));
542 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
543 }
544
545 if !self.files_touched.is_empty() {
546 let modified: Vec<&str> = self
547 .files_touched
548 .iter()
549 .filter(|f| f.modified)
550 .map(|f| f.path.as_str())
551 .collect();
552 let read_only: Vec<&str> = self
553 .files_touched
554 .iter()
555 .filter(|f| !f.modified)
556 .take(10)
557 .map(|f| f.path.as_str())
558 .collect();
559 let mut files_section = String::new();
560 if !modified.is_empty() {
561 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
562 }
563 if !read_only.is_empty() {
564 if !files_section.is_empty() {
565 files_section.push_str(" | ");
566 }
567 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
568 }
569 sections.push((1, format!("<files>{files_section}</files>")));
570 }
571
572 if !self.decisions.is_empty() {
573 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
574 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
575 }
576
577 if !self.findings.is_empty() {
578 let items: Vec<String> = self
579 .findings
580 .iter()
581 .rev()
582 .take(5)
583 .map(|f| f.summary.clone())
584 .collect();
585 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
586 }
587
588 if !self.progress.is_empty() {
589 let items: Vec<String> = self
590 .progress
591 .iter()
592 .rev()
593 .take(5)
594 .map(|p| {
595 let detail = p.detail.as_deref().unwrap_or("");
596 if detail.is_empty() {
597 p.action.clone()
598 } else {
599 format!("{}: {detail}", p.action)
600 }
601 })
602 .collect();
603 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
604 }
605
606 if let Some(ref tests) = self.test_results {
607 sections.push((
608 3,
609 format!(
610 "<tests>{}/{} pass ({})</tests>",
611 tests.passed, tests.total, tests.command
612 ),
613 ));
614 }
615
616 if !self.next_steps.is_empty() {
617 sections.push((
618 3,
619 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
620 ));
621 }
622
623 sections.push((
624 4,
625 format!(
626 "<stats>calls={} saved={}tok</stats>",
627 self.stats.total_tool_calls, self.stats.total_tokens_saved
628 ),
629 ));
630
631 sections.sort_by_key(|(priority, _)| *priority);
632
633 let mut snapshot = String::from("<session_snapshot>\n");
634 for (_, section) in §ions {
635 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
636 break;
637 }
638 snapshot.push_str(section);
639 snapshot.push('\n');
640 }
641 snapshot.push_str("</session_snapshot>");
642 snapshot
643 }
644
645 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
647 let snapshot = self.build_compaction_snapshot();
648 let dir = sessions_dir().ok_or("cannot determine home directory")?;
649 if !dir.exists() {
650 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
651 }
652 let path = dir.join(format!("{}_snapshot.txt", self.id));
653 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
654 Ok(snapshot)
655 }
656
657 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
659 let dir = sessions_dir()?;
660 let path = dir.join(format!("{session_id}_snapshot.txt"));
661 std::fs::read_to_string(&path).ok()
662 }
663
664 pub fn load_latest_snapshot() -> Option<String> {
666 let dir = sessions_dir()?;
667 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
668 .ok()?
669 .filter_map(std::result::Result::ok)
670 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
671 .filter_map(|e| {
672 let meta = e.metadata().ok()?;
673 let modified = meta.modified().ok()?;
674 Some((modified, e.path()))
675 })
676 .collect();
677
678 snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
679 snapshots
680 .first()
681 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
682 }
683
684 pub fn build_resume_block(&self) -> String {
687 let mut parts: Vec<String> = Vec::new();
688
689 if let Some(ref root) = self.project_root {
690 let short = root.rsplit('/').next().unwrap_or(root);
691 parts.push(format!("Project: {short}"));
692 }
693
694 if let Some(ref task) = self.task {
695 let pct = task
696 .progress_pct
697 .map_or(String::new(), |p| format!(" [{p}%]"));
698 parts.push(format!("Task: {}{pct}", task.description));
699 }
700
701 if !self.decisions.is_empty() {
702 let items: Vec<&str> = self
703 .decisions
704 .iter()
705 .rev()
706 .take(5)
707 .map(|d| d.summary.as_str())
708 .collect();
709 parts.push(format!("Decisions: {}", items.join("; ")));
710 }
711
712 if !self.files_touched.is_empty() {
713 let modified: Vec<&str> = self
714 .files_touched
715 .iter()
716 .filter(|f| f.modified)
717 .take(10)
718 .map(|f| f.path.as_str())
719 .collect();
720 if !modified.is_empty() {
721 parts.push(format!("Modified: {}", modified.join(", ")));
722 }
723 }
724
725 if !self.next_steps.is_empty() {
726 let steps: Vec<&str> = self
727 .next_steps
728 .iter()
729 .take(3)
730 .map(std::string::String::as_str)
731 .collect();
732 parts.push(format!("Next: {}", steps.join("; ")));
733 }
734
735 let archives = super::archive::list_entries(Some(&self.id));
736 if !archives.is_empty() {
737 let hints: Vec<String> = archives
738 .iter()
739 .take(5)
740 .map(|a| format!("{}({})", a.id, a.tool))
741 .collect();
742 parts.push(format!("Archives: {}", hints.join(", ")));
743 }
744
745 parts.push(format!(
746 "Stats: {} calls, {} tok saved",
747 self.stats.total_tool_calls, self.stats.total_tokens_saved
748 ));
749
750 format!(
751 "--- SESSION RESUME (post-compaction) ---\n{}\n---",
752 parts.join("\n")
753 )
754 }
755
756 pub fn save(&mut self) -> Result<(), String> {
758 let prepared = self.prepare_save()?;
759 match prepared.write_to_disk() {
760 Ok(()) => Ok(()),
761 Err(e) => {
762 self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
763 Err(e)
764 }
765 }
766 }
767
768 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
772 let dir = sessions_dir().ok_or("cannot determine home directory")?;
773 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
774 let pointer_json = serde_json::to_string(&LatestPointer {
775 id: self.id.clone(),
776 })
777 .map_err(|e| e.to_string())?;
778 self.stats.unsaved_changes = 0;
779 Ok(PreparedSave {
780 dir,
781 id: self.id.clone(),
782 json,
783 pointer_json,
784 })
785 }
786
787 pub fn load_latest() -> Option<Self> {
789 let dir = sessions_dir()?;
790 let latest_path = dir.join("latest.json");
791 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
792 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
793 Self::load_by_id(&pointer.id)
794 }
795
796 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
798 let dir = sessions_dir()?;
799 let target_root =
800 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
801 let mut latest_match: Option<Self> = None;
802
803 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
804 let path = entry.path();
805 if path.extension().and_then(|e| e.to_str()) != Some("json") {
806 continue;
807 }
808 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
809 continue;
810 }
811
812 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
813 continue;
814 };
815 let Some(session) = Self::load_by_id(id) else {
816 continue;
817 };
818
819 if !session_matches_project_root(&session, &target_root) {
820 continue;
821 }
822
823 if latest_match
824 .as_ref()
825 .is_none_or(|existing| session.updated_at > existing.updated_at)
826 {
827 latest_match = Some(session);
828 }
829 }
830
831 latest_match
832 }
833
834 pub fn load_by_id(id: &str) -> Option<Self> {
836 let dir = sessions_dir()?;
837 let path = dir.join(format!("{id}.json"));
838 let json = std::fs::read_to_string(&path).ok()?;
839 let session: Self = serde_json::from_str(&json).ok()?;
840 Some(normalize_loaded_session(session))
841 }
842
843 pub fn list_sessions() -> Vec<SessionSummary> {
845 let Some(dir) = sessions_dir() else {
846 return Vec::new();
847 };
848
849 let mut summaries = Vec::new();
850 if let Ok(entries) = std::fs::read_dir(&dir) {
851 for entry in entries.flatten() {
852 let path = entry.path();
853 if path.extension().and_then(|e| e.to_str()) != Some("json") {
854 continue;
855 }
856 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
857 continue;
858 }
859 if let Ok(json) = std::fs::read_to_string(&path) {
860 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
861 summaries.push(SessionSummary {
862 id: session.id,
863 started_at: session.started_at,
864 updated_at: session.updated_at,
865 version: session.version,
866 task: session.task.as_ref().map(|t| t.description.clone()),
867 tool_calls: session.stats.total_tool_calls,
868 tokens_saved: session.stats.total_tokens_saved,
869 });
870 }
871 }
872 }
873 }
874
875 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
876 summaries
877 }
878
879 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
881 let Some(dir) = sessions_dir() else { return 0 };
882
883 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
884 let latest = Self::load_latest().map(|s| s.id);
885 let mut removed = 0u32;
886
887 if let Ok(entries) = std::fs::read_dir(&dir) {
888 for entry in entries.flatten() {
889 let path = entry.path();
890 if path.extension().and_then(|e| e.to_str()) != Some("json") {
891 continue;
892 }
893 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
894 if filename == "latest" || filename.starts_with('.') {
895 continue;
896 }
897 if latest.as_deref() == Some(filename) {
898 continue;
899 }
900 if let Ok(json) = std::fs::read_to_string(&path) {
901 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
902 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
903 removed += 1;
904 }
905 }
906 }
907 }
908 }
909
910 removed
911 }
912}
913
914#[derive(Debug, Clone)]
916pub struct SessionSummary {
917 pub id: String,
918 pub started_at: DateTime<Utc>,
919 pub updated_at: DateTime<Utc>,
920 pub version: u32,
921 pub task: Option<String>,
922 pub tool_calls: u32,
923 pub tokens_saved: u64,
924}
925
926fn sessions_dir() -> Option<PathBuf> {
927 crate::core::data_dir::lean_ctx_data_dir()
928 .ok()
929 .map(|d| d.join("sessions"))
930}
931
932fn generate_session_id() -> String {
933 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
934 let now = Utc::now();
935 let ts = now.format("%Y%m%d-%H%M%S").to_string();
936 let nanos = now.timestamp_subsec_micros();
937 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
938 format!("{ts}-{nanos:06}s{seq}")
939}
940
941fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
944 let first_cmd = command
945 .split("&&")
946 .next()
947 .unwrap_or(command)
948 .split(';')
949 .next()
950 .unwrap_or(command)
951 .trim();
952
953 if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
954 return None;
955 }
956
957 let target = first_cmd.strip_prefix("cd")?.trim();
958 if target.is_empty() || target == "~" {
959 return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
960 }
961
962 let target = target.trim_matches('"').trim_matches('\'');
963 let path = std::path::Path::new(target);
964
965 if path.is_absolute() {
966 Some(target.to_string())
967 } else {
968 let base = std::path::Path::new(base_cwd);
969 let joined = base.join(target).to_string_lossy().to_string();
970 Some(joined.replace('\\', "/"))
971 }
972}
973
974fn shorten_path(path: &str) -> String {
975 let parts: Vec<&str> = path.split('/').collect();
976 if parts.len() <= 2 {
977 return path.to_string();
978 }
979 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
980 format!("…/{}/{}", last_two[1], last_two[0])
981}
982
983fn normalize_loaded_session(mut session: SessionState) -> SessionState {
984 if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
985 session.project_root = None;
986 }
987 if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
988 session.shell_cwd = None;
989 }
990
991 if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
994 let root_p = std::path::Path::new(root);
995 let cwd_p = std::path::Path::new(cwd);
996 let root_looks_real = has_project_marker(root_p);
997 let cwd_looks_real = has_project_marker(cwd_p);
998
999 if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
1000 session.project_root = Some(cwd.clone());
1001 }
1002 }
1003
1004 session
1005}
1006
1007fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
1008 if let Some(root) = session.project_root.as_deref() {
1009 let root_path =
1010 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
1011 if root_path == target_root {
1012 return true;
1013 }
1014 if has_project_marker(&root_path) {
1015 return false;
1016 }
1017 }
1018
1019 if let Some(cwd) = session.shell_cwd.as_deref() {
1020 let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
1021 return cwd_path == target_root || cwd_path.starts_with(target_root);
1022 }
1023
1024 false
1025}
1026
1027fn has_project_marker(dir: &std::path::Path) -> bool {
1028 const MARKERS: &[&str] = &[
1029 ".git",
1030 ".lean-ctx.toml",
1031 "Cargo.toml",
1032 "package.json",
1033 "go.mod",
1034 "pyproject.toml",
1035 ".planning",
1036 ];
1037 MARKERS.iter().any(|m| dir.join(m).exists())
1038}
1039
1040fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1041 let s = dir.to_string_lossy();
1042 s.contains("/.claude")
1043 || s.contains("/.codex")
1044 || s.contains("/var/folders/")
1045 || s.contains("/tmp/")
1046 || s.contains("\\.claude")
1047 || s.contains("\\.codex")
1048 || s.contains("\\AppData\\Local\\Temp")
1049 || s.contains("\\Temp\\")
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 #[test]
1057 fn extract_cd_absolute_path() {
1058 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1059 assert_eq!(result, Some("/usr/local/bin".to_string()));
1060 }
1061
1062 #[test]
1063 fn extract_cd_relative_path() {
1064 let result = extract_cd_target("cd subdir", "/home/user");
1065 assert_eq!(result, Some("/home/user/subdir".to_string()));
1066 }
1067
1068 #[test]
1069 fn extract_cd_with_chained_command() {
1070 let result = extract_cd_target("cd /tmp && ls", "/home/user");
1071 assert_eq!(result, Some("/tmp".to_string()));
1072 }
1073
1074 #[test]
1075 fn extract_cd_with_semicolon() {
1076 let result = extract_cd_target("cd /tmp; ls", "/home/user");
1077 assert_eq!(result, Some("/tmp".to_string()));
1078 }
1079
1080 #[test]
1081 fn extract_cd_parent_dir() {
1082 let result = extract_cd_target("cd ..", "/home/user/project");
1083 assert_eq!(result, Some("/home/user/project/..".to_string()));
1084 }
1085
1086 #[test]
1087 fn extract_cd_no_cd_returns_none() {
1088 let result = extract_cd_target("ls -la", "/home/user");
1089 assert!(result.is_none());
1090 }
1091
1092 #[test]
1093 fn extract_cd_bare_cd_goes_home() {
1094 let result = extract_cd_target("cd", "/home/user");
1095 assert!(result.is_some());
1096 }
1097
1098 #[test]
1099 fn effective_cwd_explicit_takes_priority() {
1100 let mut session = SessionState::new();
1101 session.project_root = Some("/project".to_string());
1102 session.shell_cwd = Some("/project/src".to_string());
1103 assert_eq!(session.effective_cwd(Some("/explicit")), "/explicit");
1104 }
1105
1106 #[test]
1107 fn effective_cwd_shell_cwd_second_priority() {
1108 let mut session = SessionState::new();
1109 session.project_root = Some("/project".to_string());
1110 session.shell_cwd = Some("/project/src".to_string());
1111 assert_eq!(session.effective_cwd(None), "/project/src");
1112 }
1113
1114 #[test]
1115 fn effective_cwd_project_root_third_priority() {
1116 let mut session = SessionState::new();
1117 session.project_root = Some("/project".to_string());
1118 assert_eq!(session.effective_cwd(None), "/project");
1119 }
1120
1121 #[test]
1122 fn effective_cwd_dot_ignored() {
1123 let mut session = SessionState::new();
1124 session.project_root = Some("/project".to_string());
1125 assert_eq!(session.effective_cwd(Some(".")), "/project");
1126 }
1127
1128 #[test]
1129 fn compaction_snapshot_includes_task() {
1130 let mut session = SessionState::new();
1131 session.set_task("fix auth bug", None);
1132 let snapshot = session.build_compaction_snapshot();
1133 assert!(snapshot.contains("<task>fix auth bug</task>"));
1134 assert!(snapshot.contains("<session_snapshot>"));
1135 assert!(snapshot.contains("</session_snapshot>"));
1136 }
1137
1138 #[test]
1139 fn compaction_snapshot_includes_files() {
1140 let mut session = SessionState::new();
1141 session.touch_file("src/auth.rs", None, "full", 500);
1142 session.files_touched[0].modified = true;
1143 session.touch_file("src/main.rs", None, "map", 100);
1144 let snapshot = session.build_compaction_snapshot();
1145 assert!(snapshot.contains("auth.rs"));
1146 assert!(snapshot.contains("<files>"));
1147 }
1148
1149 #[test]
1150 fn compaction_snapshot_includes_decisions() {
1151 let mut session = SessionState::new();
1152 session.add_decision("Use JWT RS256", None);
1153 let snapshot = session.build_compaction_snapshot();
1154 assert!(snapshot.contains("JWT RS256"));
1155 assert!(snapshot.contains("<decisions>"));
1156 }
1157
1158 #[test]
1159 fn compaction_snapshot_respects_size_limit() {
1160 let mut session = SessionState::new();
1161 session.set_task("a]task", None);
1162 for i in 0..100 {
1163 session.add_finding(
1164 Some(&format!("file{i}.rs")),
1165 Some(i),
1166 &format!("Finding number {i} with some detail text here"),
1167 );
1168 }
1169 let snapshot = session.build_compaction_snapshot();
1170 assert!(snapshot.len() <= 2200);
1171 }
1172
1173 #[test]
1174 fn compaction_snapshot_includes_stats() {
1175 let mut session = SessionState::new();
1176 session.stats.total_tool_calls = 42;
1177 session.stats.total_tokens_saved = 10000;
1178 let snapshot = session.build_compaction_snapshot();
1179 assert!(snapshot.contains("calls=42"));
1180 assert!(snapshot.contains("saved=10000"));
1181 }
1182}