Skip to main content

lean_ctx/core/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_FINDINGS: usize = 20;
6const MAX_DECISIONS: usize = 10;
7const MAX_FILES: usize = 50;
8#[allow(dead_code)]
9const MAX_PROGRESS: usize = 30;
10#[allow(dead_code)]
11const MAX_NEXT_STEPS: usize = 10;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14#[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    pub task: Option<TaskInfo>,
22    pub findings: Vec<Finding>,
23    pub decisions: Vec<Decision>,
24    pub files_touched: Vec<FileTouched>,
25    pub test_results: Option<TestSnapshot>,
26    pub progress: Vec<ProgressEntry>,
27    pub next_steps: Vec<String>,
28    pub stats: SessionStats,
29}
30
31#[derive(Serialize, Deserialize, Clone, Debug)]
32pub struct TaskInfo {
33    pub description: String,
34    pub intent: Option<String>,
35    pub progress_pct: Option<u8>,
36}
37
38#[derive(Serialize, Deserialize, Clone, Debug)]
39pub struct Finding {
40    pub file: Option<String>,
41    pub line: Option<u32>,
42    pub summary: String,
43    pub timestamp: DateTime<Utc>,
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug)]
47pub struct Decision {
48    pub summary: String,
49    pub rationale: Option<String>,
50    pub timestamp: DateTime<Utc>,
51}
52
53#[derive(Serialize, Deserialize, Clone, Debug)]
54pub struct FileTouched {
55    pub path: String,
56    pub file_ref: Option<String>,
57    pub read_count: u32,
58    pub modified: bool,
59    pub last_mode: String,
60    pub tokens: usize,
61}
62
63#[derive(Serialize, Deserialize, Clone, Debug)]
64pub struct TestSnapshot {
65    pub command: String,
66    pub passed: u32,
67    pub failed: u32,
68    pub total: u32,
69    pub timestamp: DateTime<Utc>,
70}
71
72#[derive(Serialize, Deserialize, Clone, Debug)]
73pub struct ProgressEntry {
74    pub action: String,
75    pub detail: Option<String>,
76    pub timestamp: DateTime<Utc>,
77}
78
79#[derive(Serialize, Deserialize, Clone, Debug, Default)]
80pub struct SessionStats {
81    pub total_tool_calls: u32,
82    pub total_tokens_saved: u64,
83    pub total_tokens_input: u64,
84    pub cache_hits: u32,
85    pub files_read: u32,
86    pub commands_run: u32,
87    pub unsaved_changes: u32,
88}
89
90#[derive(Serialize, Deserialize, Clone, Debug)]
91struct LatestPointer {
92    id: String,
93}
94
95impl Default for SessionState {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl SessionState {
102    pub fn new() -> Self {
103        let now = Utc::now();
104        Self {
105            id: generate_session_id(),
106            version: 0,
107            started_at: now,
108            updated_at: now,
109            project_root: None,
110            task: None,
111            findings: Vec::new(),
112            decisions: Vec::new(),
113            files_touched: Vec::new(),
114            test_results: None,
115            progress: Vec::new(),
116            next_steps: Vec::new(),
117            stats: SessionStats::default(),
118        }
119    }
120
121    pub fn increment(&mut self) {
122        self.version += 1;
123        self.updated_at = Utc::now();
124        self.stats.unsaved_changes += 1;
125    }
126
127    pub fn should_save(&self) -> bool {
128        self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
129    }
130
131    pub fn mark_initialized(&mut self) {
132        if self.version == 0 {
133            self.version = 1;
134            self.updated_at = chrono::Utc::now();
135            self.project_root = std::env::current_dir()
136                .ok()
137                .map(|p| p.to_string_lossy().to_string());
138        }
139    }
140
141    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
142        self.task = Some(TaskInfo {
143            description: description.to_string(),
144            intent: intent.map(|s| s.to_string()),
145            progress_pct: None,
146        });
147        self.increment();
148    }
149
150    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
151        self.findings.push(Finding {
152            file: file.map(|s| s.to_string()),
153            line,
154            summary: summary.to_string(),
155            timestamp: Utc::now(),
156        });
157        while self.findings.len() > MAX_FINDINGS {
158            self.findings.remove(0);
159        }
160        self.increment();
161    }
162
163    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
164        self.decisions.push(Decision {
165            summary: summary.to_string(),
166            rationale: rationale.map(|s| s.to_string()),
167            timestamp: Utc::now(),
168        });
169        while self.decisions.len() > MAX_DECISIONS {
170            self.decisions.remove(0);
171        }
172        self.increment();
173    }
174
175    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
176        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
177            existing.read_count += 1;
178            existing.last_mode = mode.to_string();
179            existing.tokens = tokens;
180            if let Some(r) = file_ref {
181                existing.file_ref = Some(r.to_string());
182            }
183        } else {
184            self.files_touched.push(FileTouched {
185                path: path.to_string(),
186                file_ref: file_ref.map(|s| s.to_string()),
187                read_count: 1,
188                modified: false,
189                last_mode: mode.to_string(),
190                tokens,
191            });
192            while self.files_touched.len() > MAX_FILES {
193                self.files_touched.remove(0);
194            }
195        }
196        self.stats.files_read += 1;
197        self.increment();
198    }
199
200    pub fn mark_modified(&mut self, path: &str) {
201        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
202            existing.modified = true;
203        }
204        self.increment();
205    }
206
207    #[allow(dead_code)]
208    pub fn set_test_results(&mut self, command: &str, passed: u32, failed: u32, total: u32) {
209        self.test_results = Some(TestSnapshot {
210            command: command.to_string(),
211            passed,
212            failed,
213            total,
214            timestamp: Utc::now(),
215        });
216        self.increment();
217    }
218
219    #[allow(dead_code)]
220    pub fn add_progress(&mut self, action: &str, detail: Option<&str>) {
221        self.progress.push(ProgressEntry {
222            action: action.to_string(),
223            detail: detail.map(|s| s.to_string()),
224            timestamp: Utc::now(),
225        });
226        while self.progress.len() > MAX_PROGRESS {
227            self.progress.remove(0);
228        }
229        self.increment();
230    }
231
232    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
233        self.stats.total_tool_calls += 1;
234        self.stats.total_tokens_saved += tokens_saved;
235        self.stats.total_tokens_input += tokens_input;
236    }
237
238    pub fn record_cache_hit(&mut self) {
239        self.stats.cache_hits += 1;
240    }
241
242    pub fn record_command(&mut self) {
243        self.stats.commands_run += 1;
244    }
245
246    pub fn format_compact(&self) -> String {
247        let duration = self.updated_at - self.started_at;
248        let hours = duration.num_hours();
249        let mins = duration.num_minutes() % 60;
250        let duration_str = if hours > 0 {
251            format!("{hours}h {mins}m")
252        } else {
253            format!("{mins}m")
254        };
255
256        let mut lines = Vec::new();
257        lines.push(format!(
258            "SESSION v{} | {} | {} calls | {} tok saved",
259            self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
260        ));
261
262        if let Some(ref task) = self.task {
263            let pct = task
264                .progress_pct
265                .map_or(String::new(), |p| format!(" [{p}%]"));
266            lines.push(format!("Task: {}{pct}", task.description));
267        }
268
269        if let Some(ref root) = self.project_root {
270            lines.push(format!("Root: {}", shorten_path(root)));
271        }
272
273        if !self.findings.is_empty() {
274            let items: Vec<String> = self
275                .findings
276                .iter()
277                .rev()
278                .take(5)
279                .map(|f| {
280                    let loc = match (&f.file, f.line) {
281                        (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
282                        (Some(file), None) => shorten_path(file),
283                        _ => String::new(),
284                    };
285                    if loc.is_empty() {
286                        f.summary.clone()
287                    } else {
288                        format!("{loc} \u{2014} {}", f.summary)
289                    }
290                })
291                .collect();
292            lines.push(format!(
293                "Findings ({}): {}",
294                self.findings.len(),
295                items.join(" | ")
296            ));
297        }
298
299        if !self.decisions.is_empty() {
300            let items: Vec<&str> = self
301                .decisions
302                .iter()
303                .rev()
304                .take(3)
305                .map(|d| d.summary.as_str())
306                .collect();
307            lines.push(format!("Decisions: {}", items.join(" | ")));
308        }
309
310        if !self.files_touched.is_empty() {
311            let items: Vec<String> = self
312                .files_touched
313                .iter()
314                .rev()
315                .take(10)
316                .map(|f| {
317                    let status = if f.modified { "mod" } else { &f.last_mode };
318                    let r = f.file_ref.as_deref().unwrap_or("?");
319                    format!("[{r} {} {status}]", shorten_path(&f.path))
320                })
321                .collect();
322            lines.push(format!(
323                "Files ({}): {}",
324                self.files_touched.len(),
325                items.join(" ")
326            ));
327        }
328
329        if let Some(ref tests) = self.test_results {
330            lines.push(format!(
331                "Tests: {}/{} pass ({})",
332                tests.passed, tests.total, tests.command
333            ));
334        }
335
336        if !self.next_steps.is_empty() {
337            lines.push(format!("Next: {}", self.next_steps.join(" | ")));
338        }
339
340        lines.join("\n")
341    }
342
343    pub fn build_compaction_snapshot(&self) -> String {
344        const MAX_SNAPSHOT_BYTES: usize = 2048;
345
346        let mut sections: Vec<(u8, String)> = Vec::new();
347
348        if let Some(ref task) = self.task {
349            let pct = task
350                .progress_pct
351                .map_or(String::new(), |p| format!(" [{p}%]"));
352            sections.push((1, format!("<task>{}{pct}</task>", task.description)));
353        }
354
355        if !self.files_touched.is_empty() {
356            let modified: Vec<&str> = self
357                .files_touched
358                .iter()
359                .filter(|f| f.modified)
360                .map(|f| f.path.as_str())
361                .collect();
362            let read_only: Vec<&str> = self
363                .files_touched
364                .iter()
365                .filter(|f| !f.modified)
366                .take(10)
367                .map(|f| f.path.as_str())
368                .collect();
369            let mut files_section = String::new();
370            if !modified.is_empty() {
371                files_section.push_str(&format!("Modified: {}", modified.join(", ")));
372            }
373            if !read_only.is_empty() {
374                if !files_section.is_empty() {
375                    files_section.push_str(" | ");
376                }
377                files_section.push_str(&format!("Read: {}", read_only.join(", ")));
378            }
379            sections.push((1, format!("<files>{files_section}</files>")));
380        }
381
382        if !self.decisions.is_empty() {
383            let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
384            sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
385        }
386
387        if !self.findings.is_empty() {
388            let items: Vec<String> = self
389                .findings
390                .iter()
391                .rev()
392                .take(5)
393                .map(|f| f.summary.clone())
394                .collect();
395            sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
396        }
397
398        if !self.progress.is_empty() {
399            let items: Vec<String> = self
400                .progress
401                .iter()
402                .rev()
403                .take(5)
404                .map(|p| {
405                    let detail = p.detail.as_deref().unwrap_or("");
406                    if detail.is_empty() {
407                        p.action.clone()
408                    } else {
409                        format!("{}: {detail}", p.action)
410                    }
411                })
412                .collect();
413            sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
414        }
415
416        if let Some(ref tests) = self.test_results {
417            sections.push((
418                3,
419                format!(
420                    "<tests>{}/{} pass ({})</tests>",
421                    tests.passed, tests.total, tests.command
422                ),
423            ));
424        }
425
426        if !self.next_steps.is_empty() {
427            sections.push((
428                3,
429                format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
430            ));
431        }
432
433        sections.push((
434            4,
435            format!(
436                "<stats>calls={} saved={}tok</stats>",
437                self.stats.total_tool_calls, self.stats.total_tokens_saved
438            ),
439        ));
440
441        sections.sort_by_key(|(priority, _)| *priority);
442
443        let mut snapshot = String::from("<session_snapshot>\n");
444        for (_, section) in &sections {
445            if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
446                break;
447            }
448            snapshot.push_str(section);
449            snapshot.push('\n');
450        }
451        snapshot.push_str("</session_snapshot>");
452        snapshot
453    }
454
455    pub fn save_compaction_snapshot(&self) -> Result<String, String> {
456        let snapshot = self.build_compaction_snapshot();
457        let dir = sessions_dir().ok_or("cannot determine home directory")?;
458        if !dir.exists() {
459            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
460        }
461        let path = dir.join(format!("{}_snapshot.txt", self.id));
462        std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
463        Ok(snapshot)
464    }
465
466    pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
467        let dir = sessions_dir()?;
468        let path = dir.join(format!("{session_id}_snapshot.txt"));
469        std::fs::read_to_string(&path).ok()
470    }
471
472    pub fn load_latest_snapshot() -> Option<String> {
473        let dir = sessions_dir()?;
474        let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
475            .ok()?
476            .filter_map(|e| e.ok())
477            .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
478            .filter_map(|e| {
479                let meta = e.metadata().ok()?;
480                let modified = meta.modified().ok()?;
481                Some((modified, e.path()))
482            })
483            .collect();
484
485        snapshots.sort_by(|a, b| b.0.cmp(&a.0));
486        snapshots
487            .first()
488            .and_then(|(_, path)| std::fs::read_to_string(path).ok())
489    }
490
491    pub fn save(&mut self) -> Result<(), String> {
492        let dir = sessions_dir().ok_or("cannot determine home directory")?;
493        if !dir.exists() {
494            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
495        }
496
497        let path = dir.join(format!("{}.json", self.id));
498        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
499
500        let tmp = dir.join(format!(".{}.json.tmp", self.id));
501        std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
502        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
503
504        let pointer = LatestPointer {
505            id: self.id.clone(),
506        };
507        let pointer_json = serde_json::to_string(&pointer).map_err(|e| e.to_string())?;
508        let latest_path = dir.join("latest.json");
509        let latest_tmp = dir.join(".latest.json.tmp");
510        std::fs::write(&latest_tmp, &pointer_json).map_err(|e| e.to_string())?;
511        std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
512
513        self.stats.unsaved_changes = 0;
514        Ok(())
515    }
516
517    pub fn load_latest() -> Option<Self> {
518        let dir = sessions_dir()?;
519        let latest_path = dir.join("latest.json");
520        let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
521        let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
522        Self::load_by_id(&pointer.id)
523    }
524
525    pub fn load_by_id(id: &str) -> Option<Self> {
526        let dir = sessions_dir()?;
527        let path = dir.join(format!("{id}.json"));
528        let json = std::fs::read_to_string(&path).ok()?;
529        serde_json::from_str(&json).ok()
530    }
531
532    pub fn list_sessions() -> Vec<SessionSummary> {
533        let dir = match sessions_dir() {
534            Some(d) => d,
535            None => return Vec::new(),
536        };
537
538        let mut summaries = Vec::new();
539        if let Ok(entries) = std::fs::read_dir(&dir) {
540            for entry in entries.flatten() {
541                let path = entry.path();
542                if path.extension().and_then(|e| e.to_str()) != Some("json") {
543                    continue;
544                }
545                if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
546                    continue;
547                }
548                if let Ok(json) = std::fs::read_to_string(&path) {
549                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
550                        summaries.push(SessionSummary {
551                            id: session.id,
552                            started_at: session.started_at,
553                            updated_at: session.updated_at,
554                            version: session.version,
555                            task: session.task.as_ref().map(|t| t.description.clone()),
556                            tool_calls: session.stats.total_tool_calls,
557                            tokens_saved: session.stats.total_tokens_saved,
558                        });
559                    }
560                }
561            }
562        }
563
564        summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
565        summaries
566    }
567
568    pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
569        let dir = match sessions_dir() {
570            Some(d) => d,
571            None => return 0,
572        };
573
574        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
575        let latest = Self::load_latest().map(|s| s.id);
576        let mut removed = 0u32;
577
578        if let Ok(entries) = std::fs::read_dir(&dir) {
579            for entry in entries.flatten() {
580                let path = entry.path();
581                if path.extension().and_then(|e| e.to_str()) != Some("json") {
582                    continue;
583                }
584                let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
585                if filename == "latest" || filename.starts_with('.') {
586                    continue;
587                }
588                if latest.as_deref() == Some(filename) {
589                    continue;
590                }
591                if let Ok(json) = std::fs::read_to_string(&path) {
592                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
593                        if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
594                            removed += 1;
595                        }
596                    }
597                }
598            }
599        }
600
601        removed
602    }
603}
604
605#[derive(Debug, Clone)]
606#[allow(dead_code)]
607pub struct SessionSummary {
608    pub id: String,
609    pub started_at: DateTime<Utc>,
610    pub updated_at: DateTime<Utc>,
611    pub version: u32,
612    pub task: Option<String>,
613    pub tool_calls: u32,
614    pub tokens_saved: u64,
615}
616
617fn sessions_dir() -> Option<PathBuf> {
618    dirs::home_dir().map(|h| h.join(".lean-ctx").join("sessions"))
619}
620
621fn generate_session_id() -> String {
622    let now = Utc::now();
623    let ts = now.format("%Y%m%d-%H%M%S").to_string();
624    let random: u32 = (std::time::SystemTime::now()
625        .duration_since(std::time::UNIX_EPOCH)
626        .unwrap_or_default()
627        .subsec_nanos())
628        % 10000;
629    format!("{ts}-{random:04}")
630}
631
632fn shorten_path(path: &str) -> String {
633    let parts: Vec<&str> = path.split('/').collect();
634    if parts.len() <= 2 {
635        return path.to_string();
636    }
637    let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
638    format!("…/{}/{}", last_two[1], last_two[0])
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn compaction_snapshot_includes_task() {
647        let mut session = SessionState::new();
648        session.set_task("fix auth bug", None);
649        let snapshot = session.build_compaction_snapshot();
650        assert!(snapshot.contains("<task>fix auth bug</task>"));
651        assert!(snapshot.contains("<session_snapshot>"));
652        assert!(snapshot.contains("</session_snapshot>"));
653    }
654
655    #[test]
656    fn compaction_snapshot_includes_files() {
657        let mut session = SessionState::new();
658        session.touch_file("src/auth.rs", None, "full", 500);
659        session.files_touched[0].modified = true;
660        session.touch_file("src/main.rs", None, "map", 100);
661        let snapshot = session.build_compaction_snapshot();
662        assert!(snapshot.contains("auth.rs"));
663        assert!(snapshot.contains("<files>"));
664    }
665
666    #[test]
667    fn compaction_snapshot_includes_decisions() {
668        let mut session = SessionState::new();
669        session.add_decision("Use JWT RS256", None);
670        let snapshot = session.build_compaction_snapshot();
671        assert!(snapshot.contains("JWT RS256"));
672        assert!(snapshot.contains("<decisions>"));
673    }
674
675    #[test]
676    fn compaction_snapshot_respects_size_limit() {
677        let mut session = SessionState::new();
678        session.set_task("a]task", None);
679        for i in 0..100 {
680            session.add_finding(
681                Some(&format!("file{i}.rs")),
682                Some(i),
683                &format!("Finding number {i} with some detail text here"),
684            );
685        }
686        let snapshot = session.build_compaction_snapshot();
687        assert!(snapshot.len() <= 2200);
688    }
689
690    #[test]
691    fn compaction_snapshot_includes_stats() {
692        let mut session = SessionState::new();
693        session.stats.total_tool_calls = 42;
694        session.stats.total_tokens_saved = 10000;
695        let snapshot = session.build_compaction_snapshot();
696        assert!(snapshot.contains("calls=42"));
697        assert!(snapshot.contains("saved=10000"));
698    }
699}