Skip to main content

codetether_agent/opencode/
mod.rs

1//! OpenCode session integration
2//!
3//! This module enables CodeTether to read and resume OpenCode sessions
4//! by parsing OpenCode's storage format.
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12/// Default OpenCode storage directory
13pub fn opencode_storage_dir() -> Option<PathBuf> {
14    directories::BaseDirs::new().map(|b| b.data_local_dir().join("opencode").join("storage"))
15}
16
17/// OpenCode session metadata
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct OpenCodeSession {
20    pub id: String,
21    pub version: String,
22    #[serde(rename = "projectID")]
23    pub project_id: String,
24    pub directory: String,
25    pub title: String,
26    pub time: OpenCodeTime,
27    #[serde(default)]
28    pub summary: Option<OpenCodeSummary>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct OpenCodeTime {
33    pub created: i64,
34    pub updated: i64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct OpenCodeSummary {
39    #[serde(default)]
40    pub additions: i64,
41    #[serde(default)]
42    pub deletions: i64,
43    #[serde(default)]
44    pub files: i64,
45}
46
47/// OpenCode message
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct OpenCodeMessage {
50    pub id: String,
51    #[serde(rename = "sessionID")]
52    pub session_id: String,
53    pub role: String,
54    pub time: OpenCodeMessageTime,
55    #[serde(default)]
56    pub summary: Option<OpenCodeMessageSummary>,
57    #[serde(rename = "parentID", default)]
58    pub parent_id: Option<String>,
59    #[serde(rename = "modelID", default)]
60    pub model_id: Option<String>,
61    #[serde(rename = "providerID", default)]
62    pub provider_id: Option<String>,
63    #[serde(default)]
64    pub mode: Option<String>,
65    #[serde(default)]
66    pub path: Option<OpenCodeMessagePath>,
67    #[serde(default)]
68    pub cost: Option<f64>,
69    #[serde(default)]
70    pub tokens: Option<OpenCodeTokens>,
71    #[serde(default)]
72    pub agent: Option<String>,
73    #[serde(default)]
74    pub model: Option<OpenCodeModel>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct OpenCodeMessageTime {
79    pub created: i64,
80    #[serde(rename = "completed", default)]
81    pub completed: Option<i64>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct OpenCodeMessageSummary {
86    #[serde(default)]
87    pub title: Option<String>,
88    #[serde(default)]
89    pub diffs: Vec<serde_json::Value>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct OpenCodeMessagePath {
94    pub cwd: String,
95    pub root: String,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct OpenCodeTokens {
100    #[serde(default)]
101    pub input: i64,
102    #[serde(default)]
103    pub output: i64,
104    #[serde(default)]
105    pub reasoning: i64,
106    #[serde(default)]
107    pub cache: Option<OpenCodeCacheTokens>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct OpenCodeCacheTokens {
112    #[serde(default)]
113    pub read: i64,
114    #[serde(default)]
115    pub write: i64,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct OpenCodeModel {
120    #[serde(rename = "providerID")]
121    pub provider_id: String,
122    #[serde(rename = "modelID")]
123    pub model_id: String,
124}
125
126/// OpenCode message part (content)
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OpenCodePart {
129    pub id: String,
130    #[serde(rename = "sessionID")]
131    pub session_id: String,
132    #[serde(rename = "messageID")]
133    pub message_id: String,
134    #[serde(rename = "type")]
135    pub part_type: String,
136    #[serde(default)]
137    pub text: Option<String>,
138    #[serde(default)]
139    pub time: Option<OpenCodePartTime>,
140    // Tool call fields
141    #[serde(rename = "toolCallID", default)]
142    pub tool_call_id: Option<String>,
143    #[serde(default)]
144    pub name: Option<String>,
145    #[serde(default)]
146    pub arguments: Option<String>,
147    // Tool result fields
148    #[serde(default)]
149    pub content: Option<String>,
150    #[serde(default)]
151    pub error: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct OpenCodePartTime {
156    #[serde(default)]
157    pub start: Option<i64>,
158    #[serde(default)]
159    pub end: Option<i64>,
160}
161
162/// OpenCode todo item
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct OpenCodeTodo {
165    pub id: String,
166    pub content: String,
167    pub status: String,
168    pub priority: String,
169}
170
171/// Summary of an OpenCode session for listing
172#[derive(Debug, Clone)]
173pub struct OpenCodeSessionSummary {
174    pub id: String,
175    pub title: String,
176    pub directory: String,
177    pub created_at: DateTime<Utc>,
178    pub updated_at: DateTime<Utc>,
179    pub message_count: usize,
180}
181
182/// Reader for OpenCode storage
183pub struct OpenCodeStorage {
184    storage_dir: PathBuf,
185}
186
187impl OpenCodeStorage {
188    /// Create a new OpenCode storage reader with default location
189    pub fn new() -> Option<Self> {
190        opencode_storage_dir().map(|dir| Self { storage_dir: dir })
191    }
192
193    /// Create a new OpenCode storage reader with custom location
194    pub fn with_path(path: impl AsRef<Path>) -> Self {
195        Self {
196            storage_dir: path.as_ref().to_path_buf(),
197        }
198    }
199
200    /// Check if OpenCode storage exists
201    pub fn exists(&self) -> bool {
202        self.storage_dir.exists()
203    }
204
205    /// Get the storage directory path
206    pub fn path(&self) -> &Path {
207        &self.storage_dir
208    }
209
210    /// List all available sessions
211    pub async fn list_sessions(&self) -> Result<Vec<OpenCodeSessionSummary>> {
212        let session_dir = self.storage_dir.join("session");
213
214        if !session_dir.exists() {
215            return Ok(Vec::new());
216        }
217
218        let mut summaries = Vec::new();
219        let mut entries = fs::read_dir(&session_dir).await?;
220
221        while let Some(entry) = entries.next_entry().await? {
222            let path = entry.path();
223            if path.extension().map(|e| e == "json").unwrap_or(false) {
224                if let Ok(content) = fs::read_to_string(&path).await {
225                    if let Ok(session) = serde_json::from_str::<OpenCodeSession>(&content) {
226                        let message_count = self.count_messages(&session.id).await.unwrap_or(0);
227
228                        summaries.push(OpenCodeSessionSummary {
229                            id: session.id.clone(),
230                            title: session.title,
231                            directory: session.directory,
232                            created_at: DateTime::from_timestamp_millis(session.time.created)
233                                .unwrap_or_else(|| Utc::now()),
234                            updated_at: DateTime::from_timestamp_millis(session.time.updated)
235                                .unwrap_or_else(|| Utc::now()),
236                            message_count,
237                        });
238                    }
239                }
240            }
241        }
242
243        // Sort by updated_at descending
244        summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
245        Ok(summaries)
246    }
247
248    /// List sessions for a specific directory
249    pub async fn list_sessions_for_directory(
250        &self,
251        dir: &Path,
252    ) -> Result<Vec<OpenCodeSessionSummary>> {
253        let all = self.list_sessions().await?;
254        let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
255
256        Ok(all
257            .into_iter()
258            .filter(|s| {
259                let session_dir = Path::new(&s.directory);
260                let canonical_session = session_dir
261                    .canonicalize()
262                    .unwrap_or_else(|_| session_dir.to_path_buf());
263                canonical_session == canonical_dir
264            })
265            .collect())
266    }
267
268    /// Load a session by ID
269    pub async fn load_session(&self, session_id: &str) -> Result<OpenCodeSession> {
270        let path = self
271            .storage_dir
272            .join("session")
273            .join(format!("{}.json", session_id));
274        let content = fs::read_to_string(&path)
275            .await
276            .with_context(|| format!("Failed to read session file: {}", path.display()))?;
277        let session: OpenCodeSession = serde_json::from_str(&content)
278            .with_context(|| format!("Failed to parse session JSON: {}", session_id))?;
279        Ok(session)
280    }
281
282    /// Load all messages for a session
283    pub async fn load_messages(&self, session_id: &str) -> Result<Vec<OpenCodeMessage>> {
284        let message_dir = self.storage_dir.join("message").join(session_id);
285
286        if !message_dir.exists() {
287            return Ok(Vec::new());
288        }
289
290        let mut messages = Vec::new();
291        let mut entries = fs::read_dir(&message_dir).await?;
292
293        while let Some(entry) = entries.next_entry().await? {
294            let path = entry.path();
295            if path.extension().map(|e| e == "json").unwrap_or(false) {
296                if let Ok(content) = fs::read_to_string(&path).await {
297                    if let Ok(message) = serde_json::from_str::<OpenCodeMessage>(&content) {
298                        messages.push(message);
299                    }
300                }
301            }
302        }
303
304        // Sort by creation time
305        messages.sort_by_key(|m| m.time.created);
306        Ok(messages)
307    }
308
309    /// Load parts for a message
310    pub async fn load_parts(&self, message_id: &str) -> Result<Vec<OpenCodePart>> {
311        let part_dir = self.storage_dir.join("part").join(message_id);
312
313        if !part_dir.exists() {
314            return Ok(Vec::new());
315        }
316
317        let mut parts = Vec::new();
318        let mut entries = fs::read_dir(&part_dir).await?;
319
320        while let Some(entry) = entries.next_entry().await? {
321            let path = entry.path();
322            if path.extension().map(|e| e == "json").unwrap_or(false) {
323                if let Ok(content) = fs::read_to_string(&path).await {
324                    if let Ok(part) = serde_json::from_str::<OpenCodePart>(&content) {
325                        parts.push(part);
326                    }
327                }
328            }
329        }
330
331        // Sort by start time if available
332        parts.sort_by_key(|p| p.time.as_ref().and_then(|t| t.start).unwrap_or(0));
333        Ok(parts)
334    }
335
336    /// Load todos for a session
337    pub async fn load_todos(&self, session_id: &str) -> Result<Vec<OpenCodeTodo>> {
338        let todo_path = self
339            .storage_dir
340            .join("todo")
341            .join(format!("{}.json", session_id));
342
343        if !todo_path.exists() {
344            return Ok(Vec::new());
345        }
346
347        let content = fs::read_to_string(&todo_path).await?;
348        let todos: Vec<OpenCodeTodo> = serde_json::from_str(&content)?;
349        Ok(todos)
350    }
351
352    /// Count messages in a session
353    async fn count_messages(&self, session_id: &str) -> Result<usize> {
354        let message_dir = self.storage_dir.join("message").join(session_id);
355
356        if !message_dir.exists() {
357            return Ok(0);
358        }
359
360        let mut count = 0;
361        let mut entries = fs::read_dir(&message_dir).await?;
362
363        while let Some(entry) = entries.next_entry().await? {
364            let path = entry.path();
365            if path.extension().map(|e| e == "json").unwrap_or(false) {
366                count += 1;
367            }
368        }
369
370        Ok(count)
371    }
372
373    /// Get the most recent session
374    pub async fn last_session(&self) -> Result<OpenCodeSession> {
375        let sessions = self.list_sessions().await?;
376
377        if let Some(first) = sessions.first() {
378            self.load_session(&first.id).await
379        } else {
380            anyhow::bail!("No OpenCode sessions found")
381        }
382    }
383
384    /// Get the most recent session for a directory
385    pub async fn last_session_for_directory(&self, dir: &Path) -> Result<OpenCodeSession> {
386        let sessions = self.list_sessions_for_directory(dir).await?;
387
388        if let Some(first) = sessions.first() {
389            self.load_session(&first.id).await
390        } else {
391            anyhow::bail!(
392                "No OpenCode sessions found for directory: {}",
393                dir.display()
394            )
395        }
396    }
397}
398
399/// Convert OpenCode session to CodeTether session
400pub mod convert {
401    use super::*;
402    use crate::provider::{ContentPart, Message, Role};
403    use crate::session::{Session, SessionMetadata};
404
405    /// Convert an OpenCode session and its messages to a CodeTether session
406    pub async fn to_codetether_session(
407        opencode_session: &OpenCodeSession,
408        messages: Vec<(OpenCodeMessage, Vec<OpenCodePart>)>,
409    ) -> Result<Session> {
410        let mut codetether_messages = Vec::new();
411
412        for (msg, parts) in messages {
413            let role = match msg.role.as_str() {
414                "user" => Role::User,
415                "assistant" => Role::Assistant,
416                _ => Role::User, // Default to user for unknown roles
417            };
418
419            let content = convert_parts_to_content(&parts);
420
421            codetether_messages.push(Message { role, content });
422        }
423
424        let session = Session {
425            id: format!("opencode_{}", opencode_session.id),
426            title: Some(opencode_session.title.clone()),
427            created_at: DateTime::from_timestamp_millis(opencode_session.time.created)
428                .unwrap_or_else(|| Utc::now()),
429            updated_at: DateTime::from_timestamp_millis(opencode_session.time.updated)
430                .unwrap_or_else(|| Utc::now()),
431            messages: codetether_messages,
432            tool_uses: Vec::new(), // OpenCode doesn't store tool uses separately
433            usage: Default::default(),
434            agent: "build".to_string(), // Default agent
435            metadata: SessionMetadata {
436                directory: Some(PathBuf::from(&opencode_session.directory)),
437                model: None, // Could extract from messages
438                shared: false,
439                share_url: None,
440            },
441        };
442
443        Ok(session)
444    }
445
446    /// Convert OpenCode parts to CodeTether content parts
447    fn convert_parts_to_content(parts: &[OpenCodePart]) -> Vec<ContentPart> {
448        let mut content = Vec::new();
449
450        for part in parts {
451            match part.part_type.as_str() {
452                "text" => {
453                    if let Some(text) = &part.text {
454                        content.push(ContentPart::Text { text: text.clone() });
455                    }
456                }
457                "tool-call" => {
458                    if let (Some(name), Some(args)) = (&part.name, &part.arguments) {
459                        content.push(ContentPart::ToolCall {
460                            id: part.tool_call_id.clone().unwrap_or_default(),
461                            name: name.clone(),
462                            arguments: args.clone(),
463                        });
464                    }
465                }
466                "tool-result" => {
467                    if let Some(tool_content) = &part.content {
468                        content.push(ContentPart::ToolResult {
469                            tool_call_id: part.tool_call_id.clone().unwrap_or_default(),
470                            content: tool_content.clone(),
471                        });
472                    }
473                }
474                _ => {
475                    // Unknown part type, try to extract text if available
476                    if let Some(text) = &part.text {
477                        content.push(ContentPart::Text { text: text.clone() });
478                    }
479                }
480            }
481        }
482
483        content
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn test_opencode_storage_dir() {
493        let dir = opencode_storage_dir();
494        assert!(dir.is_some());
495        let dir = dir.unwrap();
496        assert!(dir.to_string_lossy().contains("opencode"));
497        assert!(dir.to_string_lossy().contains("storage"));
498    }
499
500    #[tokio::test]
501    async fn test_storage_exists() {
502        if let Some(storage) = OpenCodeStorage::new() {
503            // Just test that we can create the storage reader
504            // Don't assume OpenCode is installed
505            let _ = storage.exists();
506        }
507    }
508}