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