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