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 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 pub fn increment(&mut self) {
75 self.version += 1;
76 self.updated_at = Utc::now();
77 self.stats.unsaved_changes += 1;
78 }
79
80 pub fn should_save(&self) -> bool {
82 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
83 }
84
85 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 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 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 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 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 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 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 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 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 pub fn has_evidence_key(&self, key: &str) -> bool {
267 self.evidence.iter().any(|e| e.key == key)
268 }
269
270 pub fn record_cache_hit(&mut self) {
272 self.stats.cache_hits += 1;
273 }
274
275 pub fn record_command(&mut self) {
277 self.stats.commands_run += 1;
278 }
279
280 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 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 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}