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