Skip to main content

oxi/
session.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use uuid::Uuid;
5
6/// A single entry in a session conversation
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SessionEntry {
9    pub id: Uuid,
10    /// Parent session ID for branched sessions (None = root session)
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub parent_id: Option<Uuid>,
13    pub message: AgentMessage,
14    /// Optional label for this entry (e.g., for bookmarks)
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub label: Option<String>,
17    pub timestamp: i64,
18}
19
20impl SessionEntry {
21    /// Create a new entry
22    pub fn new(message: AgentMessage) -> Self {
23        Self {
24            id: Uuid::new_v4(),
25            parent_id: None,
26            message,
27            label: None,
28            timestamp: chrono::Utc::now().timestamp_millis(),
29        }
30    }
31
32    /// Create a branched entry with a parent reference
33    pub fn branched(message: AgentMessage, parent_id: Uuid) -> Self {
34        Self {
35            id: Uuid::new_v4(),
36            parent_id: Some(parent_id),
37            message,
38            label: None,
39            timestamp: chrono::Utc::now().timestamp_millis(),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "type")]
46pub enum AgentMessage {
47    User { content: String },
48    Assistant { content: String },
49    System { content: String },
50}
51
52impl AgentMessage {
53    /// Get the content of the message
54    pub fn content(&self) -> &str {
55        match self {
56            AgentMessage::User { content } => content,
57            AgentMessage::Assistant { content } => content,
58            AgentMessage::System { content } => content,
59        }
60    }
61}
62
63/// Session metadata stored separately from entries
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SessionMeta {
66    pub id: Uuid,
67    pub parent_id: Option<Uuid>,          // Root session that this branched from (if any)
68    pub root_id: Option<Uuid>,           // Original root session (for deep branches)
69    pub branch_point: Option<Uuid>,       // Entry ID where branching occurred
70    pub created_at: i64,
71    pub updated_at: i64,
72    pub name: Option<String>,
73}
74
75impl SessionMeta {
76    pub fn new(id: Uuid) -> Self {
77        let now = chrono::Utc::now().timestamp_millis();
78        Self {
79            id,
80            parent_id: None,
81            root_id: None,
82            branch_point: None,
83            created_at: now,
84            updated_at: now,
85            name: None,
86        }
87    }
88
89    pub fn branched_from(parent_id: Uuid, root_id: Option<Uuid>, branch_point: Uuid) -> Self {
90        let now = chrono::Utc::now().timestamp_millis();
91        Self {
92            id: Uuid::new_v4(),
93            parent_id: Some(parent_id),
94            root_id: root_id.or(Some(parent_id)),
95            branch_point: Some(branch_point),
96            created_at: now,
97            updated_at: now,
98            name: None,
99        }
100    }
101}
102
103pub struct SessionManager {
104    sessions_dir: PathBuf,
105    meta_dir: PathBuf,
106}
107
108impl SessionManager {
109    pub async fn new() -> Result<Self> {
110        let home = dirs::home_dir().context("Cannot find home directory")?;
111        let base_dir = home.join(".oxi");
112        let sessions_dir = base_dir.join("sessions");
113        let meta_dir = base_dir.join("meta");
114        tokio::fs::create_dir_all(&sessions_dir).await?;
115        tokio::fs::create_dir_all(&meta_dir).await?;
116        Ok(Self { sessions_dir, meta_dir })
117    }
118
119    pub async fn save(&self, id: Uuid, entries: &[SessionEntry]) -> Result<()> {
120        let path = self.session_path(&id);
121        let json = serde_json::to_string_pretty(entries)?;
122        tokio::fs::write(&path, json).await?;
123        Ok(())
124    }
125
126    pub async fn load(&self, id: Uuid) -> Result<Vec<SessionEntry>> {
127        let path = self.session_path(&id);
128        if !path.exists() {
129            return Ok(Vec::new());
130        }
131        let contents = tokio::fs::read_to_string(&path).await?;
132        let entries: Vec<SessionEntry> = serde_json::from_str(&contents)?;
133        Ok(entries)
134    }
135
136    pub fn session_path(&self, id: &Uuid) -> PathBuf {
137        self.sessions_dir.join(format!("{}.json", id))
138    }
139
140    /// Get the path for session metadata
141    fn meta_path(&self, id: &Uuid) -> PathBuf {
142        self.meta_dir.join(format!("{}.json", id))
143    }
144
145    /// List all session IDs
146    pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
147        let mut entries = tokio::fs::read_dir(&self.meta_dir).await?;
148        let mut metas = Vec::new();
149
150        while let Some(entry) = entries.next_entry().await? {
151            let path = entry.path();
152            if path.extension().and_then(|s| s.to_str()) == Some("json") {
153                if let Ok(contents) = tokio::fs::read_to_string(&path).await {
154                    if let Ok(meta) = serde_json::from_str::<SessionMeta>(&contents) {
155                        metas.push(meta);
156                    }
157                }
158            }
159        }
160
161        metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
162        Ok(metas)
163    }
164
165    /// Save session metadata
166    pub async fn save_meta(&self, meta: &SessionMeta) -> Result<()> {
167        let path = self.meta_path(&meta.id);
168        let json = serde_json::to_string_pretty(meta)?;
169        tokio::fs::write(&path, json).await?;
170        Ok(())
171    }
172
173    /// Load session metadata
174    pub async fn load_meta(&self, id: Uuid) -> Result<Option<SessionMeta>> {
175        let path = self.meta_path(&id);
176        if !path.exists() {
177            return Ok(None);
178        }
179        let contents = tokio::fs::read_to_string(&path).await?;
180        let meta: SessionMeta = serde_json::from_str(&contents)?;
181        Ok(Some(meta))
182    }
183
184    /// Create a new session
185    pub async fn create(&self) -> Result<SessionMeta> {
186        let id = Uuid::new_v4();
187        let meta = SessionMeta::new(id);
188        self.save_meta(&meta).await?;
189        Ok(meta)
190    }
191
192    /// Create a branch from an existing session at a given entry
193    pub async fn branch_from(&self, parent_id: Uuid, entry_id: Uuid) -> Result<(Uuid, Vec<SessionEntry>)> {
194        // Load parent entries
195        let parent_entries = self.load(parent_id).await?;
196
197        // Find the entry index
198        let entry_idx = parent_entries
199            .iter()
200            .position(|e| e.id == entry_id)
201            .with_context(|| format!("Entry {} not found in session {}", entry_id, parent_id))?;
202
203        // Load parent metadata to get root info
204        let parent_meta = self.load_meta(parent_id).await?
205            .with_context(|| format!("Parent session {} not found", parent_id))?;
206
207        // Create new session
208        let new_id = Uuid::new_v4();
209        let meta = SessionMeta::branched_from(parent_id, parent_meta.root_id.or(Some(parent_id)), entry_id);
210
211        // Copy entries up to and including the branch point
212        let mut new_entries: Vec<SessionEntry> = parent_entries[..=entry_idx]
213            .iter()
214            .map(|e| {
215                let mut new_entry = e.clone();
216                new_entry.id = Uuid::new_v4();
217                new_entry
218            })
219            .collect();
220
221        // Update the last entry to have parent reference
222        if let Some(last) = new_entries.last_mut() {
223            last.parent_id = Some(entry_id);
224        }
225
226        // Save the new session
227        self.save_meta(&meta).await?;
228        self.save(new_id, &new_entries).await?;
229
230        Ok((new_id, new_entries))
231    }
232
233    /// Get all entries in a session
234    pub async fn get_entries(&self, session_id: Uuid) -> Result<Vec<SessionEntry>> {
235        self.load(session_id).await
236    }
237
238    /// Get all entries in tree order (depth-first traversal from root to this session)
239    pub async fn get_tree(&self, session_id: Uuid) -> Result<Vec<(Uuid, SessionEntry)>> {
240        let mut tree = Vec::new();
241        let mut current_id = Some(session_id);
242
243        while let Some(id) = current_id {
244            let meta = match self.load_meta(id).await? {
245                Some(m) => m,
246                None => break,
247            };
248
249            // Load entries for this session
250            let entries = self.load(id).await?;
251            for entry in entries {
252                tree.push((id, entry));
253            }
254
255            // Move to parent
256            current_id = meta.parent_id;
257        }
258
259        Ok(tree)
260    }
261
262    /// Get all direct branches from a given entry across all sessions
263    pub async fn get_branches_from_entry(&self, entry_id: Uuid) -> Result<Vec<(Uuid, SessionEntry)>> {
264        let mut branches = Vec::new();
265        let metas = self.list_sessions().await?;
266
267        for meta in metas {
268            // Check if this session branched from the given entry
269            if meta.branch_point == Some(entry_id) || meta.parent_id == Some(entry_id) {
270                // Get first entry of this branch
271                let entries = self.load(meta.id).await?;
272                if let Some(first) = entries.first() {
273                    branches.push((meta.id, first.clone()));
274                }
275            }
276        }
277
278        Ok(branches)
279    }
280
281    /// Get branch point info for a session
282    pub async fn get_branch_info(&self, session_id: Uuid) -> Result<Option<BranchInfo>> {
283        let meta = match self.load_meta(session_id).await? {
284            Some(m) => m,
285            None => return Ok(None),
286        };
287
288        if meta.parent_id.is_none() {
289            return Ok(None);
290        }
291
292        let parent_meta = self.load_meta(meta.parent_id.unwrap()).await?;
293        Ok(Some(BranchInfo {
294            session_id,
295            parent_session_id: meta.parent_id,
296            root_session_id: meta.root_id,
297            branch_point_entry_id: meta.branch_point,
298            parent_session_name: parent_meta.as_ref().and_then(|m| m.name.clone()),
299        }))
300    }
301
302    /// Delete a session
303    pub async fn delete(&self, id: Uuid) -> Result<()> {
304        tokio::fs::remove_file(self.session_path(&id)).await.ok();
305        tokio::fs::remove_file(self.meta_path(&id)).await.ok();
306        Ok(())
307    }
308}
309
310/// Information about where a session branched from
311#[derive(Debug, Clone)]
312pub struct BranchInfo {
313    pub session_id: Uuid,
314    pub parent_session_id: Option<Uuid>,
315    pub root_session_id: Option<Uuid>,
316    pub branch_point_entry_id: Option<Uuid>,
317    pub parent_session_name: Option<String>,
318}