Skip to main content

lean_ctx/core/session/
state.rs

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