Skip to main content

lean_ctx/core/
session.rs

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