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 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 pub fn increment(&mut self) {
59 self.version += 1;
60 self.updated_at = Utc::now();
61 self.stats.unsaved_changes += 1;
62 }
63
64 pub fn should_save(&self) -> bool {
66 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
67 }
68
69 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 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 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 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 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 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 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 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 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 pub fn has_evidence_key(&self, key: &str) -> bool {
251 self.evidence.iter().any(|e| e.key == key)
252 }
253
254 pub fn record_cache_hit(&mut self) {
256 self.stats.cache_hits += 1;
257 }
258
259 pub fn record_command(&mut self) {
261 self.stats.commands_run += 1;
262 }
263
264 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 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 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}