Skip to main content

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