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        }
46        .with_compression_from_config()
47    }
48
49    fn with_compression_from_config(mut self) -> Self {
50        let cfg = crate::core::config::Config::load();
51        let level = crate::core::config::CompressionLevel::effective(&cfg);
52        self.compression_level = level.label().to_string();
53        self.terse_mode = level.is_active();
54        self
55    }
56
57    /// Bumps the version counter and marks the session as dirty.
58    pub fn increment(&mut self) {
59        self.version += 1;
60        self.updated_at = Utc::now();
61        self.stats.unsaved_changes += 1;
62    }
63
64    /// Returns `true` if enough changes have accumulated to warrant a disk save.
65    pub fn should_save(&self) -> bool {
66        self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
67    }
68
69    /// Sets the active task and infers a structured intent from the description.
70    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
71        self.task = Some(TaskInfo {
72            description: description.to_string(),
73            intent: intent.map(std::string::ToString::to_string),
74            progress_pct: None,
75        });
76
77        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
78        let si = if touched.is_empty() {
79            crate::core::intent_engine::StructuredIntent::from_query(description)
80        } else {
81            crate::core::intent_engine::StructuredIntent::from_query_with_session(
82                description,
83                &touched,
84            )
85        };
86        if si.confidence >= 0.7 {
87            self.active_structured_intent = Some(si);
88        }
89
90        self.increment();
91    }
92
93    /// Records a finding (discovery or observation) in the session log.
94    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
95        self.findings.push(Finding {
96            file: file.map(std::string::ToString::to_string),
97            line,
98            summary: summary.to_string(),
99            timestamp: Utc::now(),
100        });
101        while self.findings.len() > MAX_FINDINGS {
102            self.findings.remove(0);
103        }
104        self.increment();
105    }
106
107    /// Records a design or implementation decision with optional rationale.
108    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
109        self.decisions.push(Decision {
110            summary: summary.to_string(),
111            rationale: rationale.map(std::string::ToString::to_string),
112            timestamp: Utc::now(),
113        });
114        while self.decisions.len() > MAX_DECISIONS {
115            self.decisions.remove(0);
116        }
117        self.increment();
118    }
119
120    /// Records a file read/access in the session, incrementing its read count.
121    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
122        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
123            existing.read_count += 1;
124            existing.last_mode = mode.to_string();
125            existing.tokens = tokens;
126            if let Some(r) = file_ref {
127                existing.file_ref = Some(r.to_string());
128            }
129        } else {
130            let item_id = crate::core::context_field::ContextItemId::from_file(path);
131            self.files_touched.push(FileTouched {
132                path: path.to_string(),
133                file_ref: file_ref.map(std::string::ToString::to_string),
134                read_count: 1,
135                modified: false,
136                last_mode: mode.to_string(),
137                tokens,
138                stale: false,
139                context_item_id: Some(item_id.to_string()),
140            });
141            while self.files_touched.len() > MAX_FILES {
142                self.files_touched.remove(0);
143            }
144        }
145        self.stats.files_read += 1;
146        self.increment();
147    }
148
149    /// Marks a previously touched file as modified (written to).
150    pub fn mark_modified(&mut self, path: &str) {
151        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
152            existing.modified = true;
153        }
154        self.increment();
155    }
156
157    /// Increments the tool call counter and accumulates token savings.
158    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
159        self.stats.total_tool_calls += 1;
160        self.stats.total_tokens_saved += tokens_saved;
161        self.stats.total_tokens_input += tokens_input;
162    }
163
164    /// Records an inferred or explicit intent, coalescing consecutive duplicates.
165    pub fn record_intent(&mut self, mut intent: IntentRecord) {
166        if intent.occurrences == 0 {
167            intent.occurrences = 1;
168        }
169
170        if let Some(last) = self.intents.last_mut() {
171            if last.fingerprint() == intent.fingerprint() {
172                last.occurrences = last.occurrences.saturating_add(intent.occurrences);
173                last.timestamp = intent.timestamp;
174                match intent.source {
175                    IntentSource::Inferred => self.stats.intents_inferred += 1,
176                    IntentSource::Explicit => self.stats.intents_explicit += 1,
177                }
178                self.increment();
179                return;
180            }
181        }
182
183        match intent.source {
184            IntentSource::Inferred => self.stats.intents_inferred += 1,
185            IntentSource::Explicit => self.stats.intents_explicit += 1,
186        }
187
188        self.intents.push(intent);
189        while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
190            self.intents.remove(0);
191        }
192        self.increment();
193    }
194
195    /// Appends an auditable evidence record for a tool invocation.
196    pub fn record_tool_receipt(
197        &mut self,
198        tool: &str,
199        action: Option<&str>,
200        input_md5: &str,
201        output_md5: &str,
202        agent_id: Option<&str>,
203        client_name: Option<&str>,
204    ) {
205        let now = Utc::now();
206        let mut push = |key: String| {
207            self.evidence.push(EvidenceRecord {
208                kind: EvidenceKind::ToolCall,
209                key,
210                value: None,
211                tool: Some(tool.to_string()),
212                input_md5: Some(input_md5.to_string()),
213                output_md5: Some(output_md5.to_string()),
214                agent_id: agent_id.map(std::string::ToString::to_string),
215                client_name: client_name.map(std::string::ToString::to_string),
216                timestamp: now,
217            });
218        };
219
220        push(format!("tool:{tool}"));
221        if let Some(a) = action {
222            push(format!("tool:{tool}:{a}"));
223        }
224        while self.evidence.len() > MAX_EVIDENCE {
225            self.evidence.remove(0);
226        }
227        self.increment();
228    }
229
230    /// Appends a manual (non-tool) evidence record to the audit log.
231    pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
232        self.evidence.push(EvidenceRecord {
233            kind: EvidenceKind::Manual,
234            key: key.to_string(),
235            value: value.map(std::string::ToString::to_string),
236            tool: None,
237            input_md5: None,
238            output_md5: None,
239            agent_id: None,
240            client_name: None,
241            timestamp: Utc::now(),
242        });
243        while self.evidence.len() > MAX_EVIDENCE {
244            self.evidence.remove(0);
245        }
246        self.increment();
247    }
248
249    /// Returns `true` if an evidence record with the given key exists.
250    pub fn has_evidence_key(&self, key: &str) -> bool {
251        self.evidence.iter().any(|e| e.key == key)
252    }
253
254    /// Increments the session-level cache hit counter.
255    pub fn record_cache_hit(&mut self) {
256        self.stats.cache_hits += 1;
257    }
258
259    /// Increments the session-level command counter.
260    pub fn record_command(&mut self) {
261        self.stats.commands_run += 1;
262    }
263
264    /// Returns the effective working directory for shell commands.
265    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd.
266    /// Explicit CWD and stored shell_cwd are jail-checked against the project root
267    /// to prevent MCP clients from escaping the workspace.
268    pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
269        let root = self.project_root.as_deref().unwrap_or(".");
270        if let Some(cwd) = explicit_cwd {
271            if !cwd.is_empty() && cwd != "." {
272                return Self::jail_cwd(cwd, root);
273            }
274        }
275        if let Some(ref cwd) = self.shell_cwd {
276            return cwd.clone();
277        }
278        if let Some(ref r) = self.project_root {
279            return r.clone();
280        }
281        std::env::current_dir()
282            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
283    }
284
285    /// Verifies that `candidate` is within the project jail.
286    /// Falls back to `fallback_root` if the candidate escapes.
287    fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
288        let p = std::path::Path::new(candidate);
289        match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
290            Ok(jailed) => jailed.to_string_lossy().to_string(),
291            Err(_) => fallback_root.to_string(),
292        }
293    }
294
295    /// Updates shell_cwd by detecting `cd` in the command.
296    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
297    /// `cd ..`, and chained commands like `cd foo && ...`.
298    /// The new CWD is jail-checked against the project root.
299    pub fn update_shell_cwd(&mut self, command: &str) {
300        let base = self.effective_cwd(None);
301        if let Some(new_cwd) = extract_cd_target(command, &base) {
302            let path = std::path::Path::new(&new_cwd);
303            if path.exists() && path.is_dir() {
304                let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
305                    .to_string_lossy()
306                    .to_string();
307                let root = self.project_root.as_deref().unwrap_or(".");
308                if crate::core::pathjail::jail_path(
309                    std::path::Path::new(&canonical),
310                    std::path::Path::new(root),
311                )
312                .is_ok()
313                {
314                    self.shell_cwd = Some(canonical);
315                }
316            }
317        }
318    }
319}