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