1use std::path::{Path, PathBuf};
7
8use thiserror::Error;
9
10#[derive(Debug, Error)]
12pub enum SessionCliError {
13 #[error("session not found: {0}")]
14 NotFound(String),
15 #[error("session directory error: {0}")]
16 Io(#[from] std::io::Error),
17 #[error("session file corrupt: {0}")]
18 Corrupt(String),
19}
20
21#[derive(Debug, Clone)]
23pub struct SessionInfo {
24 pub id: String,
25 pub timestamp: String,
26 pub cwd: String,
27 pub parent_session: Option<String>,
28}
29
30pub struct ResumedSession {
32 pub header: opi_agent::session::SessionHeader,
33 pub entries: Vec<opi_agent::session::SessionEntry>,
34 pub path: PathBuf,
37 pub skipped_entries: usize,
39}
40
41pub fn session_dir() -> PathBuf {
49 if let Ok(dir) = std::env::var("OPI_SESSIONS_DIR") {
50 return PathBuf::from(dir);
51 }
52 if cfg!(windows) {
53 std::env::var("LOCALAPPDATA")
54 .map(|p| PathBuf::from(p).join("opi").join("sessions"))
55 .unwrap_or_else(|_| PathBuf::from(".opi").join("sessions"))
56 } else {
57 dirs_home()
58 .map(|h| h.join(".local").join("share").join("opi").join("sessions"))
59 .unwrap_or_else(|| PathBuf::from(".opi").join("sessions"))
60 }
61}
62
63fn dirs_home() -> Option<PathBuf> {
64 std::env::var("HOME").ok().map(PathBuf::from)
65}
66
67fn validate_session_id(id: &str) -> Result<(), SessionCliError> {
70 if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
71 return Err(SessionCliError::NotFound(id.into()));
72 }
73 Ok(())
74}
75
76pub fn list_sessions(dir: &Path) -> Result<Vec<SessionInfo>, SessionCliError> {
81 if !dir.exists() {
82 return Ok(vec![]);
83 }
84
85 let mut sessions = Vec::new();
86 let entries = std::fs::read_dir(dir)?;
87
88 for entry in entries {
89 let entry = entry?;
90 let path = entry.path();
91
92 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
93 continue;
94 }
95
96 let content = match std::fs::read_to_string(&path) {
97 Ok(c) => c,
98 Err(_) => continue,
99 };
100
101 let first_line = match content.lines().next() {
102 Some(line) => line,
103 None => continue,
104 };
105
106 let header: opi_agent::session::SessionHeader = match serde_json::from_str(first_line) {
107 Ok(h) => h,
108 Err(_) => continue,
109 };
110
111 sessions.push(SessionInfo {
112 id: header.id,
113 timestamp: header.timestamp,
114 cwd: header.cwd,
115 parent_session: header.parent_session,
116 });
117 }
118
119 sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
120 Ok(sessions)
121}
122
123pub fn resume_session(dir: &Path, session_id: &str) -> Result<ResumedSession, SessionCliError> {
125 validate_session_id(session_id)?;
126 let path = dir.join(format!("{session_id}.jsonl"));
127 if !path.exists() {
128 return Err(SessionCliError::NotFound(session_id.into()));
129 }
130
131 let (header, entries, recovery) = opi_agent::session::SessionReader::read_with_recovery(&path)
132 .map_err(|e| SessionCliError::Corrupt(format!("{}: {e}", path.display())))?;
133
134 let skipped_entries = recovery.corrupt_count();
135
136 Ok(ResumedSession {
137 header,
138 entries,
139 path,
140 skipped_entries,
141 })
142}
143
144pub fn delete_session(dir: &Path, session_id: &str) -> Result<(), SessionCliError> {
146 validate_session_id(session_id)?;
147 let path = dir.join(format!("{session_id}.jsonl"));
148 if !path.exists() {
149 return Err(SessionCliError::NotFound(session_id.into()));
150 }
151 std::fs::remove_file(&path)?;
152 Ok(())
153}
154
155pub fn format_sessions(sessions: &[SessionInfo]) -> String {
157 if sessions.is_empty() {
158 return String::new();
159 }
160
161 let mut lines = Vec::new();
162 for s in sessions {
163 let mut line = format!("{} {} {}", s.id, s.timestamp, s.cwd);
164 if let Some(parent) = &s.parent_session {
165 line.push_str(&format!(" (parent: {parent})"));
166 }
167 lines.push(line);
168 }
169 lines.join("\n")
170}
171
172pub fn handle_session_cli(
178 list: bool,
179 resume: Option<&str>,
180 delete: Option<&str>,
181) -> Result<(bool, Option<ResumedSession>), i32> {
182 let dir = session_dir();
183
184 if list {
185 match list_sessions(&dir) {
186 Ok(sessions) => {
187 let output = format_sessions(&sessions);
188 if !output.is_empty() {
189 println!("{output}");
190 }
191 Ok((true, None))
192 }
193 Err(e) => {
194 eprintln!("opi: {e}");
195 Err(1)
196 }
197 }
198 } else if let Some(id) = resume {
199 match resume_session(&dir, id) {
200 Ok(session) => {
201 eprintln!(
203 "Resuming session {} ({} entries, cwd: {})",
204 session.header.id,
205 session.entries.len(),
206 session.header.cwd,
207 );
208 if session.skipped_entries > 0 {
209 eprintln!(
210 "opi: warning: {} corrupt entry/entries skipped in session {}",
211 session.skipped_entries, session.header.id,
212 );
213 }
214 Ok((true, Some(session)))
215 }
216 Err(e) => {
217 eprintln!("opi: {e}");
218 Err(1)
219 }
220 }
221 } else if let Some(id) = delete {
222 match delete_session(&dir, id) {
223 Ok(()) => {
224 println!("Deleted session {id}");
225 Ok((true, None))
226 }
227 Err(e) => {
228 eprintln!("opi: {e}");
229 Err(1)
230 }
231 }
232 } else {
233 Ok((false, None))
234 }
235}
236
237pub fn reconstruct_context(
268 entries: &[opi_agent::session::SessionEntry],
269) -> Vec<opi_agent::message::AgentMessage> {
270 let ordered = select_ordered_entries(entries);
271 apply_entries(&ordered)
272}
273
274pub(crate) fn select_ordered_entries(
284 entries: &[opi_agent::session::SessionEntry],
285) -> Vec<&opi_agent::session::SessionEntry> {
286 use opi_agent::session::SessionEntry;
287
288 let last_leaf_tip: Option<&str> = entries.iter().rev().find_map(|e| match e {
289 SessionEntry::Leaf(l) => Some(l.entry_id.as_str()),
290 _ => None,
291 });
292
293 match last_leaf_tip {
294 Some(tip) => walk_active_branch(entries, tip),
295 None => entries
296 .iter()
297 .filter(|e| !matches!(e, SessionEntry::Leaf(_)))
298 .collect(),
299 }
300}
301
302fn walk_active_branch<'a>(
309 entries: &'a [opi_agent::session::SessionEntry],
310 tip_entry_id: &str,
311) -> Vec<&'a opi_agent::session::SessionEntry> {
312 use std::collections::HashMap;
313
314 use opi_agent::session::SessionEntry;
315
316 let mut by_id: HashMap<&str, &SessionEntry> = HashMap::new();
317 for entry in entries {
318 let id = match entry {
319 SessionEntry::Message(m) => Some(m.id.as_str()),
320 SessionEntry::Compaction(c) => Some(c.id.as_str()),
321 SessionEntry::Leaf(_) => None,
324 _ => None,
325 };
326 if let Some(id) = id {
327 by_id.insert(id, entry);
328 }
329 }
330
331 let mut chain: Vec<&SessionEntry> = Vec::new();
332 let mut cursor: Option<&str> = Some(tip_entry_id);
333 let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
334 while let Some(id) = cursor {
335 if !visited.insert(id) {
336 break;
339 }
340 let Some(entry) = by_id.get(id).copied() else {
341 break;
342 };
343 chain.push(entry);
344 cursor = match entry {
345 SessionEntry::Message(m) => m.parent_id.as_deref(),
346 SessionEntry::Compaction(c) => c.parent_id.as_deref(),
347 _ => None,
348 };
349 }
350 chain.reverse();
351 chain
352}
353
354fn apply_entries(
357 entries: &[&opi_agent::session::SessionEntry],
358) -> Vec<opi_agent::message::AgentMessage> {
359 use opi_agent::message::{AgentMessage, CompactionSummaryMessage};
360 use opi_agent::session::SessionEntry;
361
362 let mut messages: Vec<AgentMessage> = Vec::new();
363 let mut entry_ids: Vec<Option<String>> = Vec::new();
364
365 for entry in entries {
366 match entry {
367 SessionEntry::Message(m) => {
368 messages.push(AgentMessage::Llm(m.message.clone()));
369 entry_ids.push(Some(m.id.clone()));
370 }
371 SessionEntry::Compaction(c) => {
372 let kept_start = entry_ids
373 .iter()
374 .position(|id| id.as_deref() == Some(c.first_kept_entry_id.as_str()));
375
376 let (kept_messages, kept_ids): (Vec<_>, Vec<_>) = match kept_start {
377 Some(idx) => (messages.split_off(idx), entry_ids.split_off(idx)),
378 None => (Vec::new(), Vec::new()),
379 };
380
381 messages.clear();
382 entry_ids.clear();
383 messages.push(AgentMessage::CompactionSummary(CompactionSummaryMessage {
384 summary: c.summary.clone(),
385 first_kept_entry_id: c.first_kept_entry_id.clone(),
386 tokens_before: c.tokens_before,
387 tokens_after: c.tokens_after,
388 }));
389 entry_ids.push(None);
390 messages.extend(kept_messages);
391 entry_ids.extend(kept_ids);
392 }
393 SessionEntry::Leaf(_) => {}
394 _ => {}
395 }
396 }
397
398 messages
399}