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 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 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 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 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 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 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 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 new_record.timestamp = original_record.timestamp;
273
274 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 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 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 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}