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 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 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 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 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 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 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 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 new_record.timestamp = original_record.timestamp;
283
284 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 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 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 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 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 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 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 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 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 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}