aico/
session.rs

1use crate::consts::SESSION_FILE_NAME;
2use crate::exceptions::AicoError;
3use crate::fs::atomic_write_json;
4use crate::historystore::store::HistoryStore;
5use crate::models::ActiveWindowSummary;
6use crate::models::{HistoryRecord, SessionPointer, SessionView};
7use crossterm::style::Stylize;
8use std::env;
9use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12#[derive(Debug)]
13pub struct Session {
14    pub file_path: PathBuf,
15    pub root: PathBuf,
16    pub view_path: PathBuf,
17    pub view: SessionView,
18    pub store: HistoryStore,
19    pub context_content: std::collections::HashMap<String, String>,
20    pub history: std::collections::HashMap<usize, crate::models::MessageWithContext>,
21}
22
23impl Session {
24    /// Loads the session from the environment or current working directory.
25    pub fn load_active() -> Result<Self, AicoError> {
26        if let Ok(env_path) = env::var("AICO_SESSION_FILE") {
27            let path = PathBuf::from(env_path);
28            if !path.is_absolute() {
29                return Err(AicoError::Session(
30                    "AICO_SESSION_FILE must be an absolute path".into(),
31                ));
32            }
33            if !path.exists() {
34                return Err(AicoError::Session(
35                    "Session file specified in AICO_SESSION_FILE does not exist".into(),
36                ));
37            }
38            return Self::load(path);
39        }
40
41        let session_file = find_session_file().ok_or_else(|| {
42            AicoError::Session(format!("No session file '{}' found.", SESSION_FILE_NAME))
43        })?;
44
45        Self::load(session_file)
46    }
47
48    /// Loads a session from a specific pointer file path.
49    pub fn load(session_file: PathBuf) -> Result<Self, AicoError> {
50        let root = session_file
51            .parent()
52            .unwrap_or_else(|| Path::new("."))
53            .to_path_buf();
54
55        let pointer_json = std::fs::read_to_string(&session_file)?;
56
57        if pointer_json.trim().is_empty() {
58            return Err(AicoError::SessionIntegrity(format!(
59                "Session file '{}' is empty.",
60                SESSION_FILE_NAME
61            )));
62        }
63
64        if !pointer_json.contains("aico_session_pointer_v1") {
65            return Err(AicoError::SessionIntegrity(format!(
66                "Detected a legacy session file at {}.\n\
67                This version of aico only supports the Shared History format.\n\
68                Please run 'aico migrate-shared-history' (using the Python version) to upgrade your project.",
69                session_file.display()
70            )));
71        }
72
73        let pointer: SessionPointer = serde_json::from_str(&pointer_json)
74            .map_err(|_| AicoError::SessionIntegrity("Invalid pointer file format".into()))?;
75
76        // Resolve View Path (relative to pointer file)
77        let view_path = root.join(&pointer.path);
78        if !view_path.exists() {
79            return Err(AicoError::Session(format!(
80                "Missing view file: {}",
81                view_path.display()
82            )));
83        }
84
85        let view_json = std::fs::read_to_string(&view_path)?;
86        let view: SessionView = serde_json::from_str(&view_json)?;
87
88        let history_root = root.join(".aico").join("history");
89        let store = HistoryStore::new(history_root);
90
91        // --- Eager Loading ---
92        let context_content: std::collections::HashMap<String, String> = view
93            .context_files
94            .iter()
95            .filter_map(|rel_path| {
96                let abs_path = root.join(rel_path);
97                std::fs::read_to_string(&abs_path)
98                    .ok()
99                    .map(|content| (rel_path.clone(), content))
100            })
101            .collect();
102
103        let history = std::collections::HashMap::new();
104
105        Ok(Self {
106            file_path: session_file,
107            root,
108            view_path,
109            view,
110            store,
111            context_content,
112            history,
113        })
114    }
115
116    pub fn save_view(&self) -> Result<(), AicoError> {
117        crate::fs::atomic_write_json(&self.view_path, &self.view)
118    }
119
120    pub fn sessions_dir(&self) -> PathBuf {
121        self.root.join(".aico").join("sessions")
122    }
123
124    pub fn get_view_path(&self, name: &str) -> PathBuf {
125        self.sessions_dir().join(format!("{}.json", name))
126    }
127
128    pub fn switch_to_view(&self, new_view_path: &Path) -> Result<(), AicoError> {
129        // Calculate relative path for the pointer
130        // simple approach: .aico/sessions/<name>.json
131        let file_name = new_view_path
132            .file_name()
133            .ok_or_else(|| AicoError::Session("Invalid view path".into()))?;
134
135        let rel_path = Path::new(".aico").join("sessions").join(file_name);
136
137        let pointer = SessionPointer {
138            pointer_type: "aico_session_pointer_v1".to_string(),
139            path: rel_path.to_string_lossy().replace('\\', "/"),
140        };
141
142        atomic_write_json(&self.file_path, &pointer)?;
143
144        Ok(())
145    }
146
147    pub fn num_pairs(&self) -> usize {
148        self.view.message_indices.len() / 2
149    }
150
151    pub fn resolve_pair_index(&self, index_str: &str) -> Result<usize, AicoError> {
152        self.resolve_pair_index_internal(index_str, false)
153    }
154
155    pub fn resolve_indices(&self, indices: &[String]) -> Result<Vec<usize>, AicoError> {
156        let num_pairs = self.num_pairs();
157        let mut result = Vec::new();
158        // Default to last if empty
159        if indices.is_empty() {
160            if num_pairs == 0 {
161                return Err(AicoError::InvalidInput(
162                    "No message pairs found in history.".into(),
163                ));
164            }
165            result.push(num_pairs - 1);
166            return Ok(result);
167        }
168
169        for arg in indices {
170            // Handle ranges "0..2"
171            if let Some((start_str, end_str)) = arg.split_once("..") {
172                let is_start_neg = start_str.starts_with('-');
173                let is_end_neg = end_str.starts_with('-');
174
175                if is_start_neg != is_end_neg {
176                    return Err(AicoError::InvalidInput(format!(
177                        "Invalid index '{}'. Mixed positive and negative indices in a range are not supported.",
178                        arg
179                    )));
180                }
181
182                let start_idx = self.resolve_pair_index_internal(start_str, false)? as isize;
183                let end_idx = self.resolve_pair_index_internal(end_str, false)? as isize;
184
185                let step = if start_idx <= end_idx { 1 } else { -1 };
186                let len = (start_idx - end_idx).unsigned_abs() + 1;
187
188                result.extend(
189                    std::iter::successors(Some(start_idx), move |&n| Some(n + step))
190                        .take(len)
191                        .map(|i| i as usize),
192                );
193            } else {
194                result.push(self.resolve_pair_index_internal(arg, false)?);
195            }
196        }
197        result.sort();
198        result.dedup();
199        Ok(result)
200    }
201
202    pub fn resolve_pair_index_internal(
203        &self,
204        index_str: &str,
205        allow_past_end: bool,
206    ) -> Result<usize, AicoError> {
207        let num_pairs = self.num_pairs();
208        if num_pairs == 0 {
209            return Err(AicoError::InvalidInput(
210                "No message pairs found in history.".into(),
211            ));
212        }
213
214        let index = index_str.parse::<isize>().map_err(|_| {
215            AicoError::InvalidInput(format!(
216                "Invalid index '{}'. Must be an integer.",
217                index_str
218            ))
219        })?;
220
221        let resolved = if index < 0 {
222            (num_pairs as isize) + index
223        } else {
224            index
225        };
226
227        let max = if allow_past_end {
228            num_pairs
229        } else {
230            if num_pairs == 0 {
231                return Err(AicoError::InvalidInput(
232                    "No message pairs found in history.".into(),
233                ));
234            }
235            num_pairs - 1
236        };
237
238        if resolved < 0 || resolved > max as isize {
239            let range = if num_pairs == 1 && !allow_past_end {
240                "Valid indices are in the range 0 (or -1).".to_string()
241            } else {
242                let mut base = format!(
243                    "Valid indices are in the range 0 to {} (or -1 to -{})",
244                    num_pairs - 1,
245                    num_pairs
246                );
247
248                if allow_past_end {
249                    base.push_str(&format!(" (or {} to clear context)", num_pairs));
250                }
251                base
252            };
253
254            return Err(AicoError::InvalidInput(format!(
255                "Index out of bounds. {}",
256                range
257            )));
258        }
259
260        Ok(resolved as usize)
261    }
262
263    pub fn edit_message(
264        &mut self,
265        message_index: usize,
266        new_content: String,
267    ) -> Result<(), AicoError> {
268        if message_index >= self.view.message_indices.len() {
269            return Err(AicoError::Session("Message index out of bounds".into()));
270        }
271
272        let original_global_idx = self.view.message_indices[message_index];
273        let original_records = self.store.read_many(&[original_global_idx])?;
274        let original_record = original_records
275            .first()
276            .ok_or_else(|| AicoError::SessionIntegrity("Record not found".into()))?;
277
278        let mut new_record = original_record.clone();
279        new_record.content = new_content;
280        new_record.edit_of = Some(original_global_idx);
281        // We preserve the original timestamp to keep the context horizon stable.
282        new_record.timestamp = original_record.timestamp;
283
284        // Recompute derived content if it's an assistant message
285        if new_record.role == crate::models::Role::Assistant {
286            new_record.derived = self.compute_derived_content(&new_record.content);
287        } else {
288            new_record.derived = None;
289        }
290
291        let new_global_idx = self.store.append(&new_record)?;
292        self.view.message_indices[message_index] = new_global_idx;
293
294        // Synchronize in-memory history map for this specific message
295        if let Some(msg) = self.history.get_mut(&original_global_idx) {
296            msg.record = new_record.clone();
297            msg.global_index = new_global_idx;
298        }
299        self.history.insert(
300            new_global_idx,
301            crate::models::MessageWithContext {
302                record: new_record,
303                global_index: new_global_idx,
304                pair_index: message_index / 2,
305                is_excluded: self.view.excluded_pairs.contains(&(message_index / 2)),
306            },
307        );
308
309        self.save_view()?;
310        Ok(())
311    }
312
313    pub fn compute_derived_content(&self, content: &str) -> Option<crate::models::DerivedContent> {
314        use crate::diffing::parser::StreamParser;
315
316        let mut parser = StreamParser::new(&self.context_content);
317        // Ensure content ends with a newline to trigger complete parsing of the final block
318        let gated_content = if content.ends_with('\n') {
319            content.to_string()
320        } else {
321            format!("{}\n", content)
322        };
323        parser.feed(&gated_content);
324
325        let (diff, display_items, _warnings) = parser.final_resolve(&self.root);
326
327        // Only create derived content if there is a meaningful diff, or if the structured
328        // display items are different from the raw content.
329        let has_structural_diversity = !diff.is_empty()
330            || display_items.iter().any(|item| match item {
331                crate::models::DisplayItem::Markdown(m) => m.trim() != content.trim(),
332                _ => true,
333            });
334
335        if has_structural_diversity {
336            Some(crate::models::DerivedContent {
337                unified_diff: if diff.is_empty() { None } else { Some(diff) },
338                display_content: Some(display_items),
339            })
340        } else {
341            None
342        }
343    }
344
345    pub fn summarize_active_window(
346        &self,
347        history_vec: &[crate::models::MessageWithContext],
348    ) -> Result<Option<ActiveWindowSummary>, AicoError> {
349        if history_vec.is_empty() {
350            return Ok(None);
351        }
352
353        let mut total_pairs = 0;
354        let mut excluded_in_window = 0;
355        let mut has_dangling = false;
356
357        let mut i = 0;
358        while i < history_vec.len() {
359            let current = &history_vec[i];
360            if current.record.role == crate::models::Role::User
361                && let Some(next) = history_vec.get(i + 1)
362                && next.record.role == crate::models::Role::Assistant
363                && next.pair_index == current.pair_index
364            {
365                total_pairs += 1;
366                if current.is_excluded {
367                    excluded_in_window += 1;
368                }
369                i += 2;
370            } else {
371                has_dangling = true;
372                i += 1;
373            }
374        }
375
376        Ok(Some(ActiveWindowSummary {
377            active_pairs: total_pairs,
378            active_start_id: self.view.history_start_pair,
379            active_end_id: self.view.message_indices.len().saturating_sub(1) / 2,
380            excluded_in_window,
381            pairs_sent: total_pairs.saturating_sub(excluded_in_window),
382            has_dangling,
383        }))
384    }
385
386    pub fn get_context_files(&self) -> Vec<String> {
387        self.view.context_files.clone()
388    }
389
390    pub fn warn_missing_files(&self) {
391        // Collect references (&String) instead of cloning
392        let mut missing: Vec<&String> = self
393            .view
394            .context_files
395            .iter()
396            .filter(|f| !self.context_content.contains_key(*f))
397            .collect();
398
399        if !missing.is_empty() {
400            missing.sort();
401            let joined = missing
402                .iter()
403                .map(|s| s.as_str())
404                .collect::<Vec<_>>()
405                .join(" ");
406
407            eprintln!(
408                "{}",
409                format!("Warning: Context files not found on disk: {}", joined).yellow()
410            );
411        }
412    }
413
414    pub fn fetch_pair(
415        &self,
416        index: usize,
417    ) -> Result<(HistoryRecord, HistoryRecord, usize, usize), AicoError> {
418        let u_abs = index * 2;
419        let a_abs = u_abs + 1;
420
421        if a_abs >= self.view.message_indices.len() {
422            return Err(AicoError::InvalidInput(format!(
423                "Pair index {} is out of bounds.",
424                index
425            )));
426        }
427
428        let u_global = self.view.message_indices[u_abs];
429        let a_global = self.view.message_indices[a_abs];
430
431        // 1. Memory Strategy: Use HashMap for O(1) lookup
432        if let (Some(u_msg), Some(a_msg)) =
433            (self.history.get(&u_global), self.history.get(&a_global))
434        {
435            return Ok((
436                u_msg.record.clone(),
437                a_msg.record.clone(),
438                u_global,
439                a_global,
440            ));
441        }
442
443        // 2. Fallback Strategy: Hit the store surgically
444        let records = self.store.read_many(&[u_global, a_global])?;
445        if records.len() != 2 {
446            return Err(AicoError::SessionIntegrity(
447                "Failed to fetch full pair from store".into(),
448            ));
449        }
450
451        Ok((records[0].clone(), records[1].clone(), u_global, a_global))
452    }
453
454    pub fn append_record_to_view(&mut self, record: HistoryRecord) -> Result<(), AicoError> {
455        let pair_index = self.view.message_indices.len() / 2;
456        let global_idx = self.store.append(&record)?;
457        self.view.message_indices.push(global_idx);
458
459        // Update in-memory map lazily
460        self.history.insert(
461            global_idx,
462            crate::models::MessageWithContext {
463                record,
464                global_index: global_idx,
465                pair_index,
466                is_excluded: self.view.excluded_pairs.contains(&pair_index),
467            },
468        );
469
470        Ok(())
471    }
472
473    pub fn append_pair(
474        &mut self,
475        user_record: HistoryRecord,
476        assistant_record: HistoryRecord,
477    ) -> Result<(), AicoError> {
478        self.append_record_to_view(user_record)?;
479        self.append_record_to_view(assistant_record)?;
480        self.save_view()
481    }
482
483    pub fn resolve_context_state(
484        &self,
485        history: &[crate::models::MessageWithContext],
486    ) -> Result<crate::models::ContextState<'_>, AicoError> {
487        let horizon = history
488            .first()
489            .map(|m| m.record.timestamp)
490            .unwrap_or_else(|| {
491                "3000-01-01T00:00:00Z"
492                    .parse::<chrono::DateTime<chrono::Utc>>()
493                    .unwrap()
494            });
495
496        let mut static_files = vec![];
497        let mut floating_files = vec![];
498        let mut latest_floating_mtime = chrono::DateTime::<chrono::Utc>::MIN_UTC;
499
500        for (rel_path, content) in &self.context_content {
501            let abs_path = self.root.join(rel_path);
502            if let Ok(meta) = std::fs::metadata(&abs_path) {
503                // Parity with math.ceil(mtime) from Python
504                let duration = meta
505                    .modified()
506                    .map_err(|e| AicoError::Session(e.to_string()))?
507                    .duration_since(UNIX_EPOCH)
508                    .map_err(|e| AicoError::Session(e.to_string()))?;
509
510                let mtime_secs = duration.as_secs_f64().ceil() as i64;
511                let mtime = chrono::TimeZone::timestamp_opt(&chrono::Utc, mtime_secs, 0).unwrap();
512
513                if mtime < horizon {
514                    static_files.push((rel_path.as_str(), content.as_str()));
515                } else {
516                    if mtime > latest_floating_mtime {
517                        latest_floating_mtime = mtime;
518                    }
519                    floating_files.push((rel_path.as_str(), content.as_str()));
520                }
521            }
522        }
523
524        // Determine Splice Point
525        let splice_idx = if floating_files.is_empty() {
526            history.len()
527        } else {
528            history
529                .iter()
530                .position(|item| item.record.timestamp > latest_floating_mtime)
531                .unwrap_or(history.len())
532        };
533
534        Ok(crate::models::ContextState {
535            static_files,
536            floating_files,
537            splice_idx,
538        })
539    }
540}
541
542pub fn find_session_file() -> Option<PathBuf> {
543    let mut current = env::current_dir().ok()?;
544    loop {
545        let check = current.join(SESSION_FILE_NAME);
546        if check.is_file() {
547            return Some(check);
548        }
549        if !current.pop() {
550            break;
551        }
552    }
553    None
554}