Skip to main content

sivtr_core/
opencode.rs

1use anyhow::{Context, Result};
2use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
3use serde_json::Value;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crate::ai::{
8    extract_content_text, normalize_path_for_match, pretty_json_value, push_block, AgentBlockKind,
9    AgentProvider, AgentSession, AgentSessionInfo, AgentSessionProvider,
10};
11
12const PROVIDER_NAME: &str = "OpenCode";
13const SESSION_PATH_PREFIX: &str = "opencode-session-";
14const SESSION_PATH_SUFFIX: &str = ".json";
15
16#[derive(Debug, Clone, Default)]
17pub struct OpenCodeProvider {
18    db_path: Option<PathBuf>,
19}
20
21impl OpenCodeProvider {
22    #[cfg(test)]
23    fn with_db_path(path: PathBuf) -> Self {
24        Self {
25            db_path: Some(path),
26        }
27    }
28
29    fn db_path(&self) -> PathBuf {
30        self.db_path.clone().unwrap_or_else(opencode_db_path)
31    }
32}
33
34impl AgentSessionProvider for OpenCodeProvider {
35    fn provider(&self) -> AgentProvider {
36        AgentProvider::OpenCode
37    }
38
39    fn list_recent_sessions(&self, cwd: Option<&Path>) -> Result<Vec<AgentSessionInfo>> {
40        let db_path = self.db_path();
41        if !db_path.exists() {
42            return Ok(Vec::new());
43        }
44
45        let wanted = cwd.map(normalize_path_for_match);
46        let conn = open_readonly_db(&db_path)?;
47        let mut stmt = conn.prepare(
48            "select id, directory, time_updated, title from session order by time_updated desc, id desc",
49        )?;
50        let rows = stmt.query_map([], |row| {
51            let id: String = row.get(0)?;
52            let cwd: String = row.get(1)?;
53            let updated: i64 = row.get(2)?;
54            let title: String = row.get(3)?;
55            Ok((id, cwd, updated, title))
56        })?;
57
58        let mut sessions = Vec::new();
59        for row in rows {
60            let (id, session_cwd, updated, title) = row?;
61            if let Some(wanted) = wanted.as_deref() {
62                if normalize_path_for_match(Path::new(&session_cwd)) != wanted {
63                    continue;
64                }
65            }
66
67            sessions.push(AgentSessionInfo {
68                modified: system_time_from_millis(updated),
69                path: opencode_session_path(&id),
70                id: Some(id),
71                cwd: Some(session_cwd),
72                title: Some(title).filter(|title| !title.trim().is_empty()),
73            });
74        }
75
76        Ok(sessions)
77    }
78
79    fn parse_session_file(&self, path: &Path) -> Result<AgentSession> {
80        let session_id = session_id_from_path(path).with_context(|| {
81            format!(
82                "OpenCode session path `{}` does not contain a session id",
83                path.display()
84            )
85        })?;
86        self.parse_session_by_id(&session_id)
87    }
88
89    fn find_session_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
90        let db_path = self.db_path();
91        if !db_path.exists() {
92            return Ok(None);
93        }
94
95        let conn = open_readonly_db(&db_path)?;
96        let exact = conn
97            .query_row("select id from session where id = ?1", params![id], |row| {
98                row.get::<_, String>(0)
99            })
100            .optional()?;
101        if let Some(id) = exact {
102            return Ok(Some(opencode_session_path(&id)));
103        }
104
105        let prefix = format!("{id}%");
106        let prefix_match = conn
107            .query_row(
108                "select id from session where id like ?1 order by time_updated desc limit 1",
109                params![prefix],
110                |row| row.get::<_, String>(0),
111            )
112            .optional()?;
113        Ok(prefix_match.map(|id| opencode_session_path(&id)))
114    }
115}
116
117impl OpenCodeProvider {
118    fn parse_session_by_id(&self, session_id: &str) -> Result<AgentSession> {
119        let db_path = self.db_path();
120        let conn = open_readonly_db(&db_path)?;
121        let meta = conn
122            .query_row(
123                "select id, directory, title from session where id = ?1",
124                params![session_id],
125                |row| {
126                    Ok((
127                        row.get::<_, String>(0)?,
128                        row.get::<_, String>(1)?,
129                        row.get::<_, String>(2)?,
130                    ))
131                },
132            )
133            .optional()?
134            .with_context(|| format!("OpenCode session `{session_id}` was not found"))?;
135
136        let mut session = AgentSession {
137            path: opencode_session_path(&meta.0),
138            id: Some(meta.0),
139            cwd: Some(meta.1),
140            title: Some(meta.2).filter(|title| !title.trim().is_empty()),
141            blocks: Vec::new(),
142        };
143
144        apply_message_rows(&conn, session_id, &mut session)?;
145        if session.blocks.is_empty() {
146            apply_session_message_rows(&conn, session_id, &mut session)?;
147        }
148
149        Ok(session)
150    }
151}
152
153pub fn opencode_db_path() -> PathBuf {
154    if let Ok(path) = std::env::var("OPENCODE_DB_PATH") {
155        return PathBuf::from(path);
156    }
157
158    if let Ok(path) = std::env::var("XDG_DATA_HOME") {
159        return PathBuf::from(path).join("opencode").join("opencode.db");
160    }
161
162    dirs::home_dir()
163        .unwrap_or_else(|| PathBuf::from("."))
164        .join(".local")
165        .join("share")
166        .join("opencode")
167        .join("opencode.db")
168}
169
170fn open_readonly_db(path: &Path) -> Result<Connection> {
171    Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
172        .with_context(|| format!("Failed to open OpenCode database {}", path.display()))
173}
174
175fn opencode_session_path(id: &str) -> PathBuf {
176    PathBuf::from(format!("{SESSION_PATH_PREFIX}{id}{SESSION_PATH_SUFFIX}"))
177}
178
179fn session_id_from_path(path: &Path) -> Option<String> {
180    let name = path.file_name()?.to_str()?;
181    name.strip_prefix(SESSION_PATH_PREFIX)?
182        .strip_suffix(SESSION_PATH_SUFFIX)
183        .map(str::to_string)
184}
185
186fn apply_message_rows(
187    conn: &Connection,
188    session_id: &str,
189    session: &mut AgentSession,
190) -> Result<()> {
191    let mut stmt = conn.prepare(
192        "select id, time_created, data from message where session_id = ?1 order by time_created, id",
193    )?;
194    let rows = stmt.query_map(params![session_id], |row| {
195        Ok((
196            row.get::<_, String>(0)?,
197            row.get::<_, i64>(1)?,
198            row.get::<_, String>(2)?,
199        ))
200    })?;
201
202    for row in rows {
203        let (message_id, timestamp, data) = row?;
204        let message = parse_json(&data)?;
205        let parts = load_parts(conn, session_id, &message_id)?;
206        apply_message(session, &message, timestamp, &parts);
207    }
208
209    Ok(())
210}
211
212fn load_parts(conn: &Connection, session_id: &str, message_id: &str) -> Result<Vec<Value>> {
213    let mut stmt = conn.prepare(
214        "select data from part where session_id = ?1 and message_id = ?2 order by time_created, id",
215    )?;
216    let rows = stmt.query_map(params![session_id, message_id], |row| {
217        row.get::<_, String>(0)
218    })?;
219
220    let mut parts = Vec::new();
221    for row in rows {
222        parts.push(parse_json(&row?)?);
223    }
224    Ok(parts)
225}
226
227fn apply_session_message_rows(
228    conn: &Connection,
229    session_id: &str,
230    session: &mut AgentSession,
231) -> Result<()> {
232    let mut stmt = conn.prepare(
233        "select time_created, data from session_message where session_id = ?1 order by time_created, id",
234    )?;
235    let rows = stmt.query_map(params![session_id], |row| {
236        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
237    })?;
238
239    for row in rows {
240        let (timestamp, data) = row?;
241        let message = parse_json(&data)?;
242        apply_message(session, &message, timestamp, &[]);
243    }
244
245    Ok(())
246}
247
248fn parse_json(text: &str) -> Result<Value> {
249    serde_json::from_str(text).with_context(|| format!("Failed to parse {PROVIDER_NAME} JSON data"))
250}
251
252fn apply_message(session: &mut AgentSession, message: &Value, timestamp_ms: i64, parts: &[Value]) {
253    let timestamp = Some(timestamp_ms.to_string());
254    let Some(kind) = message_kind(message) else {
255        return;
256    };
257
258    if parts.is_empty() {
259        push_message_fallback(session, kind, timestamp, message);
260        return;
261    }
262
263    for part in parts {
264        apply_part(session, kind, timestamp.clone(), part);
265    }
266}
267
268fn message_kind(message: &Value) -> Option<AgentBlockKind> {
269    match message
270        .get("role")
271        .or_else(|| {
272            message
273                .get("message")
274                .and_then(|message| message.get("role"))
275        })
276        .and_then(Value::as_str)
277    {
278        Some("user") => Some(AgentBlockKind::User),
279        Some("assistant") => Some(AgentBlockKind::Assistant),
280        _ => None,
281    }
282}
283
284fn apply_part(
285    session: &mut AgentSession,
286    message_kind: AgentBlockKind,
287    timestamp: Option<String>,
288    part: &Value,
289) {
290    match part.get("type").and_then(Value::as_str) {
291        Some("text") => push_block(
292            session,
293            message_kind,
294            timestamp,
295            None,
296            extract_opencode_text(part),
297        ),
298        Some("tool" | "tool_call" | "tool-call") => push_tool_part(session, timestamp, part),
299        Some("tool_result" | "tool-result") => push_block(
300            session,
301            AgentBlockKind::ToolOutput,
302            timestamp,
303            None,
304            extract_opencode_text(part),
305        ),
306        Some("reasoning" | "step-start" | "step_finish" | "snapshot" | "file") => {}
307        _ => {}
308    }
309}
310
311fn push_tool_part(session: &mut AgentSession, timestamp: Option<String>, part: &Value) {
312    let state = part.get("state").unwrap_or(part);
313    let label = part
314        .get("tool")
315        .or_else(|| part.get("name"))
316        .and_then(Value::as_str)
317        .map(str::to_string);
318
319    if let Some(input) = state.get("input").or_else(|| part.get("input")) {
320        push_block(
321            session,
322            AgentBlockKind::ToolCall,
323            timestamp.clone(),
324            label.clone(),
325            pretty_json_value(input),
326        );
327    }
328
329    if let Some(output) = state
330        .get("output")
331        .or_else(|| state.get("error"))
332        .or_else(|| part.get("output"))
333    {
334        push_block(
335            session,
336            AgentBlockKind::ToolOutput,
337            timestamp,
338            None,
339            extract_opencode_text(output),
340        );
341    }
342}
343
344fn push_message_fallback(
345    session: &mut AgentSession,
346    kind: AgentBlockKind,
347    timestamp: Option<String>,
348    message: &Value,
349) {
350    let content = message
351        .get("content")
352        .or_else(|| message.get("text"))
353        .or_else(|| message.get("message"))
354        .unwrap_or(message);
355    push_block(
356        session,
357        kind,
358        timestamp,
359        None,
360        extract_opencode_text(content),
361    );
362}
363
364fn extract_opencode_text(value: &Value) -> String {
365    let text = value
366        .get("text")
367        .and_then(Value::as_str)
368        .or_else(|| value.get("content").and_then(Value::as_str))
369        .or_else(|| value.get("output").and_then(Value::as_str));
370
371    text.map(str::to_string)
372        .unwrap_or_else(|| extract_content_text(value))
373}
374
375fn system_time_from_millis(value: i64) -> SystemTime {
376    if value <= 0 {
377        return UNIX_EPOCH;
378    }
379
380    UNIX_EPOCH + Duration::from_millis(value as u64)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::OpenCodeProvider;
386    use crate::ai::{AgentBlockKind, AgentSessionProvider};
387    use rusqlite::{params, Connection};
388
389    fn test_provider() -> (tempfile::TempDir, OpenCodeProvider) {
390        let dir = tempfile::tempdir().unwrap();
391        let path = dir.path().join("opencode.db");
392        let conn = Connection::open(&path).unwrap();
393        conn.execute_batch(
394            r#"
395            create table session (
396                id text primary key,
397                directory text not null,
398                title text not null,
399                time_updated integer not null
400            );
401            create table message (
402                id text primary key,
403                session_id text not null,
404                time_created integer not null,
405                data text not null
406            );
407            create table part (
408                id text primary key,
409                message_id text not null,
410                session_id text not null,
411                time_created integer not null,
412                data text not null
413            );
414            create table session_message (
415                id text primary key,
416                session_id text not null,
417                type text not null,
418                time_created integer not null,
419                data text not null
420            );
421            "#,
422        )
423        .unwrap();
424        conn.execute(
425            "insert into session (id, directory, title, time_updated) values (?1, ?2, ?3, ?4)",
426            params!["open-session", "D:\\sivtr", "Greeting", 1000_i64],
427        )
428        .unwrap();
429        conn.execute(
430            "insert into message (id, session_id, time_created, data) values (?1, ?2, ?3, ?4)",
431            params!["m1", "open-session", 1001_i64, r#"{"role":"user"}"#],
432        )
433        .unwrap();
434        conn.execute(
435            "insert into part (id, message_id, session_id, time_created, data) values (?1, ?2, ?3, ?4, ?5)",
436            params!["p1", "m1", "open-session", 1001_i64, r#"{"type":"text","text":"hello"}"#],
437        )
438        .unwrap();
439        conn.execute(
440            "insert into message (id, session_id, time_created, data) values (?1, ?2, ?3, ?4)",
441            params!["m2", "open-session", 1002_i64, r#"{"role":"assistant"}"#],
442        )
443        .unwrap();
444        conn.execute(
445            "insert into part (id, message_id, session_id, time_created, data) values (?1, ?2, ?3, ?4, ?5)",
446            params!["p2", "m2", "open-session", 1002_i64, r#"{"type":"reasoning","text":"hidden"}"#],
447        )
448        .unwrap();
449        conn.execute(
450            "insert into part (id, message_id, session_id, time_created, data) values (?1, ?2, ?3, ?4, ?5)",
451            params!["p3", "m2", "open-session", 1003_i64, r#"{"type":"tool","tool":"bash","state":{"input":{"command":"echo hi"},"output":"hi"}}"#],
452        )
453        .unwrap();
454        conn.execute(
455            "insert into part (id, message_id, session_id, time_created, data) values (?1, ?2, ?3, ?4, ?5)",
456            params!["p4", "m2", "open-session", 1004_i64, r#"{"type":"text","text":"done"}"#],
457        )
458        .unwrap();
459        drop(conn);
460
461        let provider = OpenCodeProvider::with_db_path(path);
462        (dir, provider)
463    }
464
465    #[test]
466    fn lists_opencode_sessions_from_database() {
467        let (_dir, provider) = test_provider();
468
469        let sessions = provider
470            .list_recent_sessions(Some(std::path::Path::new("D:\\sivtr")))
471            .unwrap();
472
473        assert_eq!(sessions.len(), 1);
474        assert_eq!(sessions[0].id.as_deref(), Some("open-session"));
475        assert_eq!(sessions[0].cwd.as_deref(), Some("D:\\sivtr"));
476        assert_eq!(sessions[0].title.as_deref(), Some("Greeting"));
477    }
478
479    #[test]
480    fn parses_opencode_messages_and_tools() {
481        let (_dir, provider) = test_provider();
482        let path = provider
483            .find_session_by_id("open-session")
484            .unwrap()
485            .unwrap();
486
487        let session = provider.parse_session_file(&path).unwrap();
488
489        assert_eq!(session.id.as_deref(), Some("open-session"));
490        assert_eq!(session.cwd.as_deref(), Some("D:\\sivtr"));
491        assert_eq!(session.title.as_deref(), Some("Greeting"));
492        assert_eq!(session.blocks.len(), 4);
493        assert_eq!(session.blocks[0].kind, AgentBlockKind::User);
494        assert_eq!(session.blocks[0].text, "hello");
495        assert_eq!(session.blocks[1].kind, AgentBlockKind::ToolCall);
496        assert_eq!(session.blocks[1].label.as_deref(), Some("bash"));
497        assert!(session.blocks[1].text.contains("echo hi"));
498        assert_eq!(session.blocks[2].kind, AgentBlockKind::ToolOutput);
499        assert_eq!(session.blocks[2].text, "hi");
500        assert_eq!(session.blocks[3].kind, AgentBlockKind::Assistant);
501        assert_eq!(session.blocks[3].text, "done");
502    }
503}