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}