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 Ok(sessions)
120}
121
122pub fn resume_session(dir: &Path, session_id: &str) -> Result<ResumedSession, SessionCliError> {
124 validate_session_id(session_id)?;
125 let path = dir.join(format!("{session_id}.jsonl"));
126 if !path.exists() {
127 return Err(SessionCliError::NotFound(session_id.into()));
128 }
129
130 let (header, entries, recovery) = opi_agent::session::SessionReader::read_with_recovery(&path)
131 .map_err(|e| SessionCliError::Corrupt(format!("{}: {e}", path.display())))?;
132
133 let skipped_entries = recovery.corrupt_count();
134
135 Ok(ResumedSession {
136 header,
137 entries,
138 path,
139 skipped_entries,
140 })
141}
142
143pub fn delete_session(dir: &Path, session_id: &str) -> Result<(), SessionCliError> {
145 validate_session_id(session_id)?;
146 let path = dir.join(format!("{session_id}.jsonl"));
147 if !path.exists() {
148 return Err(SessionCliError::NotFound(session_id.into()));
149 }
150 std::fs::remove_file(&path)?;
151 Ok(())
152}
153
154pub fn format_sessions(sessions: &[SessionInfo]) -> String {
156 if sessions.is_empty() {
157 return String::new();
158 }
159
160 let mut lines = Vec::new();
161 for s in sessions {
162 let mut line = format!("{} {} {}", s.id, s.timestamp, s.cwd);
163 if let Some(parent) = &s.parent_session {
164 line.push_str(&format!(" (parent: {parent})"));
165 }
166 lines.push(line);
167 }
168 lines.join("\n")
169}
170
171pub fn handle_session_cli(
177 list: bool,
178 resume: Option<&str>,
179 delete: Option<&str>,
180) -> Result<(bool, Option<ResumedSession>), i32> {
181 let dir = session_dir();
182
183 if list {
184 match list_sessions(&dir) {
185 Ok(sessions) => {
186 let output = format_sessions(&sessions);
187 if !output.is_empty() {
188 println!("{output}");
189 }
190 Ok((true, None))
191 }
192 Err(e) => {
193 eprintln!("opi: {e}");
194 Err(1)
195 }
196 }
197 } else if let Some(id) = resume {
198 match resume_session(&dir, id) {
199 Ok(session) => {
200 eprintln!(
202 "Resuming session {} ({} entries, cwd: {})",
203 session.header.id,
204 session.entries.len(),
205 session.header.cwd,
206 );
207 if session.skipped_entries > 0 {
208 eprintln!(
209 "opi: warning: {} corrupt entry/entries skipped in session {}",
210 session.skipped_entries, session.header.id,
211 );
212 }
213 Ok((true, Some(session)))
214 }
215 Err(e) => {
216 eprintln!("opi: {e}");
217 Err(1)
218 }
219 }
220 } else if let Some(id) = delete {
221 match delete_session(&dir, id) {
222 Ok(()) => {
223 println!("Deleted session {id}");
224 Ok((true, None))
225 }
226 Err(e) => {
227 eprintln!("opi: {e}");
228 Err(1)
229 }
230 }
231 } else {
232 Ok((false, None))
233 }
234}
235
236pub fn reconstruct_context(
267 entries: &[opi_agent::session::SessionEntry],
268) -> Vec<opi_agent::message::AgentMessage> {
269 let ordered = select_ordered_entries(entries);
270 apply_entries(&ordered)
271}
272
273pub(crate) fn select_ordered_entries(
283 entries: &[opi_agent::session::SessionEntry],
284) -> Vec<&opi_agent::session::SessionEntry> {
285 use opi_agent::session::SessionEntry;
286
287 let last_leaf_tip: Option<&str> = entries.iter().rev().find_map(|e| match e {
288 SessionEntry::Leaf(l) => Some(l.entry_id.as_str()),
289 _ => None,
290 });
291
292 match last_leaf_tip {
293 Some(tip) => walk_active_branch(entries, tip),
294 None => entries
295 .iter()
296 .filter(|e| !matches!(e, SessionEntry::Leaf(_)))
297 .collect(),
298 }
299}
300
301fn walk_active_branch<'a>(
308 entries: &'a [opi_agent::session::SessionEntry],
309 tip_entry_id: &str,
310) -> Vec<&'a opi_agent::session::SessionEntry> {
311 use std::collections::HashMap;
312
313 use opi_agent::session::SessionEntry;
314
315 let mut by_id: HashMap<&str, &SessionEntry> = HashMap::new();
316 for entry in entries {
317 let id = match entry {
318 SessionEntry::Message(m) => Some(m.id.as_str()),
319 SessionEntry::Compaction(c) => Some(c.id.as_str()),
320 SessionEntry::Leaf(_) => None,
323 _ => None,
324 };
325 if let Some(id) = id {
326 by_id.insert(id, entry);
327 }
328 }
329
330 let mut chain: Vec<&SessionEntry> = Vec::new();
331 let mut cursor: Option<&str> = Some(tip_entry_id);
332 let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
333 while let Some(id) = cursor {
334 if !visited.insert(id) {
335 break;
338 }
339 let Some(entry) = by_id.get(id).copied() else {
340 break;
341 };
342 chain.push(entry);
343 cursor = match entry {
344 SessionEntry::Message(m) => m.parent_id.as_deref(),
345 SessionEntry::Compaction(c) => c.parent_id.as_deref(),
346 _ => None,
347 };
348 }
349 chain.reverse();
350 chain
351}
352
353fn apply_entries(
356 entries: &[&opi_agent::session::SessionEntry],
357) -> Vec<opi_agent::message::AgentMessage> {
358 use opi_agent::message::{AgentMessage, CompactionSummaryMessage};
359 use opi_agent::session::SessionEntry;
360
361 let mut messages: Vec<AgentMessage> = Vec::new();
362 let mut entry_ids: Vec<Option<String>> = Vec::new();
363
364 for entry in entries {
365 match entry {
366 SessionEntry::Message(m) => {
367 messages.push(AgentMessage::Llm(m.message.clone()));
368 entry_ids.push(Some(m.id.clone()));
369 }
370 SessionEntry::Compaction(c) => {
371 let kept_start = entry_ids
372 .iter()
373 .position(|id| id.as_deref() == Some(c.first_kept_entry_id.as_str()));
374
375 let (kept_messages, kept_ids): (Vec<_>, Vec<_>) = match kept_start {
376 Some(idx) => (messages.split_off(idx), entry_ids.split_off(idx)),
377 None => (Vec::new(), Vec::new()),
378 };
379
380 messages.clear();
381 entry_ids.clear();
382 messages.push(AgentMessage::CompactionSummary(CompactionSummaryMessage {
383 summary: c.summary.clone(),
384 first_kept_entry_id: c.first_kept_entry_id.clone(),
385 tokens_before: c.tokens_before,
386 tokens_after: c.tokens_after,
387 }));
388 entry_ids.push(None);
389 messages.extend(kept_messages);
390 entry_ids.extend(kept_ids);
391 }
392 SessionEntry::Leaf(_) => {}
393 _ => {}
394 }
395 }
396
397 messages
398}