Skip to main content

lean_ctx/core/session/
state.rs

1use chrono::Utc;
2
3use crate::core::intent_protocol::{IntentRecord, IntentSource};
4
5use super::paths::{extract_cd_target, generate_session_id};
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9const MAX_FINDINGS: usize = 20;
10const MAX_DECISIONS: usize = 10;
11const MAX_FILES: usize = 50;
12const MAX_EVIDENCE: usize = 500;
13pub(crate) const BATCH_SAVE_INTERVAL: u32 = 5;
14
15impl Default for SessionState {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl SessionState {
22    /// Creates a new session with a unique ID and current timestamp.
23    pub fn new() -> Self {
24        let now = Utc::now();
25        Self {
26            id: generate_session_id(),
27            version: 0,
28            started_at: now,
29            updated_at: now,
30            project_root: None,
31            shell_cwd: None,
32            task: None,
33            findings: Vec::new(),
34            decisions: Vec::new(),
35            files_touched: Vec::new(),
36            test_results: None,
37            progress: Vec::new(),
38            next_steps: Vec::new(),
39            evidence: Vec::new(),
40            intents: Vec::new(),
41            active_structured_intent: None,
42            stats: SessionStats::default(),
43            terse_mode: false,
44            compression_level: String::new(),
45            last_consolidate_ts: None,
46            extra_roots: Vec::new(),
47        }
48        .with_compression_from_config()
49    }
50
51    fn with_compression_from_config(mut self) -> Self {
52        let cfg = crate::core::config::Config::load();
53        let level = crate::core::config::CompressionLevel::effective(&cfg);
54        self.compression_level = level.label().to_string();
55        self.terse_mode = level.is_active();
56        self
57    }
58
59    /// Bumps the version counter and marks the session as dirty.
60    pub fn increment(&mut self) {
61        self.version += 1;
62        self.updated_at = Utc::now();
63        self.stats.unsaved_changes += 1;
64    }
65
66    /// Returns `true` if enough changes have accumulated to warrant a disk save.
67    pub fn should_save(&self) -> bool {
68        self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
69    }
70
71    /// Sets the active task and infers a structured intent from the description.
72    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
73        self.task = Some(TaskInfo {
74            description: description.to_string(),
75            intent: intent.map(std::string::ToString::to_string),
76            progress_pct: None,
77        });
78
79        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
80        let si = if touched.is_empty() {
81            crate::core::intent_engine::StructuredIntent::from_query(description)
82        } else {
83            crate::core::intent_engine::StructuredIntent::from_query_with_session(
84                description,
85                &touched,
86            )
87        };
88        if si.confidence >= 0.7 {
89            self.active_structured_intent = Some(si);
90        }
91
92        self.increment();
93    }
94
95    /// Auto-infers the task from available context (plans, git diff, file patterns).
96    /// Only sets if no explicit task is already set or it's stale.
97    pub fn auto_infer_task(&mut self) {
98        // Don't overwrite explicitly set tasks
99        if self.task.is_some() {
100            return;
101        }
102
103        // Source 1: Active .cursor/plans/*.plan.md
104        if let Some(task_from_plan) = Self::infer_task_from_plans() {
105            self.set_task(&task_from_plan, Some("plan"));
106            return;
107        }
108
109        // Source 2: git diff summary
110        if let Some(ref root) = self.project_root {
111            if let Some(task_from_git) = Self::infer_task_from_git(root) {
112                self.set_task(&task_from_git, Some("git"));
113                return;
114            }
115        }
116
117        // Source 3: File patterns from intent engine
118        if self.files_touched.len() >= 3 {
119            let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
120            let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
121            if intent.confidence >= 0.5 {
122                let dirs: std::collections::HashSet<&str> = touched
123                    .iter()
124                    .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
125                    .collect();
126                let primary_dir = dirs.iter().next().unwrap_or(&".");
127                let desc = format!("Working on {} ({})", primary_dir, intent.task_type.as_str());
128                self.set_task(&desc, Some("inferred"));
129            }
130        }
131    }
132
133    fn infer_task_from_plans() -> Option<String> {
134        let plans_dir = std::path::Path::new(".cursor/plans");
135        if !plans_dir.exists() {
136            return None;
137        }
138
139        let mut newest: Option<(std::time::SystemTime, String)> = None;
140        if let Ok(entries) = std::fs::read_dir(plans_dir) {
141            for entry in entries.flatten() {
142                let path = entry.path();
143                if !path.to_string_lossy().ends_with(".plan.md") {
144                    continue;
145                }
146                let mtime = entry.metadata().ok()?.modified().ok()?;
147                let content = std::fs::read_to_string(&path).ok()?;
148
149                // Check if plan has active (pending/in_progress) todos
150                let has_active =
151                    content.contains("status: pending") || content.contains("status: in_progress");
152                if !has_active {
153                    continue;
154                }
155
156                // Extract plan name from frontmatter
157                let name = content
158                    .lines()
159                    .find(|l| l.starts_with("name:"))
160                    .map_or("Unknown Plan", |l| {
161                        l.trim_start_matches("name:").trim().trim_matches('"')
162                    });
163
164                let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
165                if better {
166                    newest = Some((mtime, name.to_string()));
167                }
168            }
169        }
170
171        newest.map(|(_, name)| name)
172    }
173
174    fn infer_task_from_git(project_root: &str) -> Option<String> {
175        let output = std::process::Command::new("git")
176            .args(["diff", "--stat", "--no-color"])
177            .current_dir(project_root)
178            .output()
179            .ok()?;
180
181        if !output.status.success() {
182            return None;
183        }
184
185        let stat = String::from_utf8_lossy(&output.stdout);
186        let lines: Vec<&str> = stat.lines().collect();
187        if lines.is_empty() {
188            return None;
189        }
190
191        // Last line typically has "N files changed, M insertions(+), K deletions(-)"
192        let summary_line = lines.last()?;
193        if !summary_line.contains("changed") {
194            return None;
195        }
196
197        // Find common directory prefix
198        let file_lines: Vec<&str> = lines[..lines.len() - 1].to_vec();
199        let dirs: std::collections::HashSet<&str> = file_lines
200            .iter()
201            .filter_map(|l| {
202                let path = l.split('|').next()?.trim();
203                std::path::Path::new(path).parent()?.to_str()
204            })
205            .collect();
206
207        let primary = if dirs.len() == 1 {
208            dirs.into_iter().next().unwrap_or(".")
209        } else {
210            "multiple dirs"
211        };
212
213        Some(format!("Modified: {} in {}", summary_line.trim(), primary))
214    }
215
216    /// Records a finding (discovery or observation) in the session log.
217    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
218        let (summary_clean, _) =
219            crate::core::secret_detection::scan_and_redact_from_config(summary);
220        self.findings.push(Finding {
221            file: file.map(std::string::ToString::to_string),
222            line,
223            summary: summary_clean,
224            timestamp: Utc::now(),
225        });
226        while self.findings.len() > MAX_FINDINGS {
227            self.findings.remove(0);
228        }
229        self.increment();
230    }
231
232    /// Records a design or implementation decision with optional rationale.
233    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
234        let (summary_clean, _) =
235            crate::core::secret_detection::scan_and_redact_from_config(summary);
236        let rationale_clean =
237            rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
238        self.decisions.push(Decision {
239            summary: summary_clean,
240            rationale: rationale_clean,
241            timestamp: Utc::now(),
242        });
243        while self.decisions.len() > MAX_DECISIONS {
244            self.decisions.remove(0);
245        }
246        self.increment();
247    }
248
249    /// Records a file read/access in the session, incrementing its read count.
250    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
251        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
252            existing.read_count += 1;
253            existing.last_mode = mode.to_string();
254            existing.tokens = tokens;
255            if let Some(r) = file_ref {
256                existing.file_ref = Some(r.to_string());
257            }
258        } else {
259            let item_id = crate::core::context_field::ContextItemId::from_file(path);
260            self.files_touched.push(FileTouched {
261                path: path.to_string(),
262                file_ref: file_ref.map(std::string::ToString::to_string),
263                read_count: 1,
264                modified: false,
265                last_mode: mode.to_string(),
266                tokens,
267                stale: false,
268                context_item_id: Some(item_id.to_string()),
269                summary: None,
270            });
271            while self.files_touched.len() > MAX_FILES {
272                self.files_touched.remove(0);
273            }
274        }
275        self.stats.files_read += 1;
276        self.increment();
277    }
278
279    /// Marks a previously touched file as modified (written to).
280    pub fn mark_modified(&mut self, path: &str) {
281        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
282            existing.modified = true;
283        }
284        self.increment();
285    }
286
287    /// Sets a one-line content summary for a touched file (max 80 chars).
288    pub fn set_file_summary(&mut self, path: &str, summary: &str) {
289        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
290            let truncated = if summary.len() > 80 {
291                format!("{}…", &summary[..79])
292            } else {
293                summary.to_string()
294            };
295            existing.summary = Some(truncated);
296        }
297    }
298
299    /// Increments the tool call counter and accumulates token savings.
300    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
301        self.stats.total_tool_calls += 1;
302        self.stats.total_tokens_saved += tokens_saved;
303        self.stats.total_tokens_input += tokens_input;
304    }
305
306    /// Records an inferred or explicit intent, coalescing consecutive duplicates.
307    pub fn record_intent(&mut self, mut intent: IntentRecord) {
308        if intent.occurrences == 0 {
309            intent.occurrences = 1;
310        }
311
312        if let Some(last) = self.intents.last_mut() {
313            if last.fingerprint() == intent.fingerprint() {
314                last.occurrences = last.occurrences.saturating_add(intent.occurrences);
315                last.timestamp = intent.timestamp;
316                match intent.source {
317                    IntentSource::Inferred => self.stats.intents_inferred += 1,
318                    IntentSource::Explicit => self.stats.intents_explicit += 1,
319                }
320                self.increment();
321                return;
322            }
323        }
324
325        match intent.source {
326            IntentSource::Inferred => self.stats.intents_inferred += 1,
327            IntentSource::Explicit => self.stats.intents_explicit += 1,
328        }
329
330        self.intents.push(intent);
331        while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
332            self.intents.remove(0);
333        }
334        self.increment();
335    }
336
337    /// Appends an auditable evidence record for a tool invocation.
338    pub fn record_tool_receipt(
339        &mut self,
340        tool: &str,
341        action: Option<&str>,
342        input_md5: &str,
343        output_md5: &str,
344        agent_id: Option<&str>,
345        client_name: Option<&str>,
346    ) {
347        let now = Utc::now();
348        let mut push = |key: String| {
349            self.evidence.push(EvidenceRecord {
350                kind: EvidenceKind::ToolCall,
351                key,
352                value: None,
353                tool: Some(tool.to_string()),
354                input_md5: Some(input_md5.to_string()),
355                output_md5: Some(output_md5.to_string()),
356                agent_id: agent_id.map(std::string::ToString::to_string),
357                client_name: client_name.map(std::string::ToString::to_string),
358                timestamp: now,
359            });
360        };
361
362        push(format!("tool:{tool}"));
363        if let Some(a) = action {
364            push(format!("tool:{tool}:{a}"));
365        }
366        while self.evidence.len() > MAX_EVIDENCE {
367            self.evidence.remove(0);
368        }
369        self.increment();
370    }
371
372    /// Appends a manual (non-tool) evidence record to the audit log.
373    pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
374        self.evidence.push(EvidenceRecord {
375            kind: EvidenceKind::Manual,
376            key: key.to_string(),
377            value: value.map(std::string::ToString::to_string),
378            tool: None,
379            input_md5: None,
380            output_md5: None,
381            agent_id: None,
382            client_name: None,
383            timestamp: Utc::now(),
384        });
385        while self.evidence.len() > MAX_EVIDENCE {
386            self.evidence.remove(0);
387        }
388        self.increment();
389    }
390
391    /// Returns `true` if an evidence record with the given key exists.
392    pub fn has_evidence_key(&self, key: &str) -> bool {
393        self.evidence.iter().any(|e| e.key == key)
394    }
395
396    /// Increments the session-level cache hit counter.
397    pub fn record_cache_hit(&mut self) {
398        self.stats.cache_hits += 1;
399    }
400
401    /// Increments the session-level command counter.
402    pub fn record_command(&mut self) {
403        self.stats.commands_run += 1;
404    }
405
406    /// Returns the effective working directory for shell commands.
407    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd.
408    /// Explicit CWD and stored shell_cwd are jail-checked against the project root
409    /// to prevent MCP clients from escaping the workspace.
410    pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
411        let root = self.project_root.as_deref().unwrap_or(".");
412        if let Some(cwd) = explicit_cwd {
413            if !cwd.is_empty() && cwd != "." {
414                return Self::jail_cwd(cwd, root);
415            }
416        }
417        if let Some(ref cwd) = self.shell_cwd {
418            return cwd.clone();
419        }
420        if let Some(ref r) = self.project_root {
421            return r.clone();
422        }
423        std::env::current_dir()
424            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
425    }
426
427    /// Verifies that `candidate` is within the project jail.
428    /// Falls back to `fallback_root` if the candidate escapes.
429    fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
430        let p = std::path::Path::new(candidate);
431        match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
432            Ok(jailed) => jailed.to_string_lossy().to_string(),
433            Err(_) => fallback_root.to_string(),
434        }
435    }
436
437    /// Updates shell_cwd by detecting `cd` in the command.
438    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
439    /// `cd ..`, and chained commands like `cd foo && ...`.
440    /// The new CWD is jail-checked against the project root.
441    pub fn update_shell_cwd(&mut self, command: &str) {
442        let base = self.effective_cwd(None);
443        if let Some(new_cwd) = extract_cd_target(command, &base) {
444            let path = std::path::Path::new(&new_cwd);
445            if path.exists() && path.is_dir() {
446                let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
447                    .to_string_lossy()
448                    .to_string();
449                let root = self.project_root.as_deref().unwrap_or(".");
450                if crate::core::pathjail::jail_path(
451                    std::path::Path::new(&canonical),
452                    std::path::Path::new(root),
453                )
454                .is_ok()
455                {
456                    self.shell_cwd = Some(canonical);
457                }
458            }
459        }
460    }
461}