use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SessionCliError {
#[error("session not found: {0}")]
NotFound(String),
#[error("session directory error: {0}")]
Io(#[from] std::io::Error),
#[error("session file corrupt: {0}")]
Corrupt(String),
}
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub id: String,
pub timestamp: String,
pub cwd: String,
pub parent_session: Option<String>,
}
pub struct ResumedSession {
pub header: opi_agent::session::SessionHeader,
pub entries: Vec<opi_agent::session::SessionEntry>,
pub path: PathBuf,
pub skipped_entries: usize,
}
pub fn session_dir() -> PathBuf {
if let Ok(dir) = std::env::var("OPI_SESSIONS_DIR") {
return PathBuf::from(dir);
}
if cfg!(windows) {
std::env::var("LOCALAPPDATA")
.map(|p| PathBuf::from(p).join("opi").join("sessions"))
.unwrap_or_else(|_| PathBuf::from(".opi").join("sessions"))
} else {
dirs_home()
.map(|h| h.join(".local").join("share").join("opi").join("sessions"))
.unwrap_or_else(|| PathBuf::from(".opi").join("sessions"))
}
}
fn dirs_home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
fn validate_session_id(id: &str) -> Result<(), SessionCliError> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(SessionCliError::NotFound(id.into()));
}
Ok(())
}
pub fn list_sessions(dir: &Path) -> Result<Vec<SessionInfo>, SessionCliError> {
if !dir.exists() {
return Ok(vec![]);
}
let mut sessions = Vec::new();
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let first_line = match content.lines().next() {
Some(line) => line,
None => continue,
};
let header: opi_agent::session::SessionHeader = match serde_json::from_str(first_line) {
Ok(h) => h,
Err(_) => continue,
};
sessions.push(SessionInfo {
id: header.id,
timestamp: header.timestamp,
cwd: header.cwd,
parent_session: header.parent_session,
});
}
Ok(sessions)
}
pub fn resume_session(dir: &Path, session_id: &str) -> Result<ResumedSession, SessionCliError> {
validate_session_id(session_id)?;
let path = dir.join(format!("{session_id}.jsonl"));
if !path.exists() {
return Err(SessionCliError::NotFound(session_id.into()));
}
let (header, entries, recovery) = opi_agent::session::SessionReader::read_with_recovery(&path)
.map_err(|e| SessionCliError::Corrupt(format!("{}: {e}", path.display())))?;
let skipped_entries = recovery.corrupt_count();
Ok(ResumedSession {
header,
entries,
path,
skipped_entries,
})
}
pub fn delete_session(dir: &Path, session_id: &str) -> Result<(), SessionCliError> {
validate_session_id(session_id)?;
let path = dir.join(format!("{session_id}.jsonl"));
if !path.exists() {
return Err(SessionCliError::NotFound(session_id.into()));
}
std::fs::remove_file(&path)?;
Ok(())
}
pub fn format_sessions(sessions: &[SessionInfo]) -> String {
if sessions.is_empty() {
return String::new();
}
let mut lines = Vec::new();
for s in sessions {
let mut line = format!("{} {} {}", s.id, s.timestamp, s.cwd);
if let Some(parent) = &s.parent_session {
line.push_str(&format!(" (parent: {parent})"));
}
lines.push(line);
}
lines.join("\n")
}
pub fn handle_session_cli(
list: bool,
resume: Option<&str>,
delete: Option<&str>,
) -> Result<(bool, Option<ResumedSession>), i32> {
let dir = session_dir();
if list {
match list_sessions(&dir) {
Ok(sessions) => {
let output = format_sessions(&sessions);
if !output.is_empty() {
println!("{output}");
}
Ok((true, None))
}
Err(e) => {
eprintln!("opi: {e}");
Err(1)
}
}
} else if let Some(id) = resume {
match resume_session(&dir, id) {
Ok(session) => {
eprintln!(
"Resuming session {} ({} entries, cwd: {})",
session.header.id,
session.entries.len(),
session.header.cwd,
);
if session.skipped_entries > 0 {
eprintln!(
"opi: warning: {} corrupt entry/entries skipped in session {}",
session.skipped_entries, session.header.id,
);
}
Ok((true, Some(session)))
}
Err(e) => {
eprintln!("opi: {e}");
Err(1)
}
}
} else if let Some(id) = delete {
match delete_session(&dir, id) {
Ok(()) => {
println!("Deleted session {id}");
Ok((true, None))
}
Err(e) => {
eprintln!("opi: {e}");
Err(1)
}
}
} else {
Ok((false, None))
}
}
pub fn reconstruct_context(
entries: &[opi_agent::session::SessionEntry],
) -> Vec<opi_agent::message::AgentMessage> {
let ordered = select_ordered_entries(entries);
apply_entries(&ordered)
}
pub(crate) fn select_ordered_entries(
entries: &[opi_agent::session::SessionEntry],
) -> Vec<&opi_agent::session::SessionEntry> {
use opi_agent::session::SessionEntry;
let last_leaf_tip: Option<&str> = entries.iter().rev().find_map(|e| match e {
SessionEntry::Leaf(l) => Some(l.entry_id.as_str()),
_ => None,
});
match last_leaf_tip {
Some(tip) => walk_active_branch(entries, tip),
None => entries
.iter()
.filter(|e| !matches!(e, SessionEntry::Leaf(_)))
.collect(),
}
}
fn walk_active_branch<'a>(
entries: &'a [opi_agent::session::SessionEntry],
tip_entry_id: &str,
) -> Vec<&'a opi_agent::session::SessionEntry> {
use std::collections::HashMap;
use opi_agent::session::SessionEntry;
let mut by_id: HashMap<&str, &SessionEntry> = HashMap::new();
for entry in entries {
let id = match entry {
SessionEntry::Message(m) => Some(m.id.as_str()),
SessionEntry::Compaction(c) => Some(c.id.as_str()),
SessionEntry::Leaf(_) => None,
_ => None,
};
if let Some(id) = id {
by_id.insert(id, entry);
}
}
let mut chain: Vec<&SessionEntry> = Vec::new();
let mut cursor: Option<&str> = Some(tip_entry_id);
let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
while let Some(id) = cursor {
if !visited.insert(id) {
break;
}
let Some(entry) = by_id.get(id).copied() else {
break;
};
chain.push(entry);
cursor = match entry {
SessionEntry::Message(m) => m.parent_id.as_deref(),
SessionEntry::Compaction(c) => c.parent_id.as_deref(),
_ => None,
};
}
chain.reverse();
chain
}
fn apply_entries(
entries: &[&opi_agent::session::SessionEntry],
) -> Vec<opi_agent::message::AgentMessage> {
use opi_agent::message::{AgentMessage, CompactionSummaryMessage};
use opi_agent::session::SessionEntry;
let mut messages: Vec<AgentMessage> = Vec::new();
let mut entry_ids: Vec<Option<String>> = Vec::new();
for entry in entries {
match entry {
SessionEntry::Message(m) => {
messages.push(AgentMessage::Llm(m.message.clone()));
entry_ids.push(Some(m.id.clone()));
}
SessionEntry::Compaction(c) => {
let kept_start = entry_ids
.iter()
.position(|id| id.as_deref() == Some(c.first_kept_entry_id.as_str()));
let (kept_messages, kept_ids): (Vec<_>, Vec<_>) = match kept_start {
Some(idx) => (messages.split_off(idx), entry_ids.split_off(idx)),
None => (Vec::new(), Vec::new()),
};
messages.clear();
entry_ids.clear();
messages.push(AgentMessage::CompactionSummary(CompactionSummaryMessage {
summary: c.summary.clone(),
first_kept_entry_id: c.first_kept_entry_id.clone(),
tokens_before: c.tokens_before,
tokens_after: c.tokens_after,
}));
entry_ids.push(None);
messages.extend(kept_messages);
entry_ids.extend(kept_ids);
}
SessionEntry::Leaf(_) => {}
_ => {}
}
}
messages
}