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