1use crate::collect::model_from_json;
5use crate::core::event::{Event, EventKind, EventSource, SessionRecord, SessionStatus};
6use anyhow::Result;
7use serde_json::Value;
8use std::path::{Path, PathBuf};
9
10const AGENT: &str = "opencode";
11
12fn data_dir() -> PathBuf {
13 if let Ok(p) = std::env::var("OPENCODE_DATA_DIR") {
14 return PathBuf::from(p);
15 }
16 if let Ok(home) = std::env::var("HOME") {
17 return PathBuf::from(home).join(".local/share/opencode");
18 }
19 PathBuf::from(".local/share/opencode")
20}
21
22fn canonical(p: &Path) -> PathBuf {
23 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
24}
25
26fn paths_equal(a: &Path, b: &Path) -> bool {
27 canonical(a) == canonical(b)
28}
29
30fn session_root_matches_workspace(session_file: &Path, workspace: &Path) -> bool {
32 let ws = canonical(workspace);
33 let mut cur = session_file.parent();
34 let mut depth = 0u8;
35 while let Some(p) = cur {
36 if depth > 12 {
37 break;
38 }
39 if paths_equal(p, &ws) {
40 return true;
41 }
42 if let Ok(read) = std::fs::read_to_string(p.join("workspace.json"))
43 && workspace_json_folder_matches(&read, workspace)
44 {
45 return true;
46 }
47 cur = p.parent();
48 depth += 1;
49 }
50 false
51}
52
53fn path_from_uri_or_path(s: &str) -> PathBuf {
54 let p = s.strip_prefix("file://").unwrap_or(s);
55 PathBuf::from(p)
56}
57
58fn workspace_json_folder_matches(json: &str, workspace: &Path) -> bool {
59 let Ok(v) = serde_json::from_str::<Value>(json) else {
60 return false;
61 };
62 let folder = v.get("folder").and_then(|f| f.as_str()).or_else(|| {
63 v.get("workspace")
64 .and_then(|w| w.get("folder"))
65 .and_then(|f| f.as_str())
66 });
67 let Some(f) = folder else {
68 return false;
69 };
70 paths_equal(&path_from_uri_or_path(f), workspace)
71}
72
73fn session_json_directory_field(v: &Value, workspace: &Path) -> bool {
74 let ws_str = workspace.to_string_lossy();
75 for key in [
76 "directory",
77 "projectPath",
78 "cwd",
79 "root",
80 "workspacePath",
81 "workspaceRoot",
82 ] {
83 if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
84 if paths_equal(Path::new(s), workspace) {
85 return true;
86 }
87 if s == ws_str.as_ref() {
88 return true;
89 }
90 }
91 }
92 if let Some(folder) = v.get("folder").and_then(|f| f.as_str())
93 && paths_equal(&path_from_uri_or_path(folder), workspace)
94 {
95 return true;
96 }
97 false
98}
99
100fn events_from_messages_array(session_id: &str, messages: &[Value]) -> Vec<Event> {
101 let mut events = Vec::new();
102 let mut seq: u64 = 0;
103 for msg in messages {
104 let ts_ms = msg
105 .get("time")
106 .or_else(|| msg.get("timestamp"))
107 .and_then(|t| t.as_u64())
108 .or_else(|| {
109 msg.get("createdAt")
110 .and_then(|t| t.as_u64())
111 .map(|u| u.saturating_mul(1000))
112 })
113 .unwrap_or_else(|| seq.saturating_mul(100));
114
115 if let Some(parts) = msg.get("parts").and_then(|p| p.as_array()) {
116 for part in parts {
117 let typ = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
118 match typ {
119 "tool-call" | "tool-invocation" | "tool_call" => {
120 let tool = part
121 .get("toolName")
122 .or_else(|| part.get("tool"))
123 .or_else(|| part.get("name"))
124 .and_then(|x| x.as_str())
125 .unwrap_or("")
126 .to_string();
127 let id = part
128 .get("toolCallId")
129 .or_else(|| part.get("tool_call_id"))
130 .or_else(|| part.get("id"))
131 .and_then(|x| x.as_str())
132 .unwrap_or("")
133 .to_string();
134 events.push(Event {
135 session_id: session_id.to_string(),
136 seq,
137 ts_ms,
138 ts_exact: false,
139 kind: EventKind::ToolCall,
140 source: EventSource::Tail,
141 tool: Some(tool),
142 tool_call_id: Some(id),
143 tokens_in: None,
144 tokens_out: None,
145 reasoning_tokens: None,
146 cost_usd_e6: None,
147 payload: part.clone(),
148 });
149 seq += 1;
150 }
151 "tool-result" | "tool_result" => {
152 let id = part
153 .get("toolCallId")
154 .or_else(|| part.get("tool_call_id"))
155 .and_then(|x| x.as_str())
156 .unwrap_or("")
157 .to_string();
158 events.push(Event {
159 session_id: session_id.to_string(),
160 seq,
161 ts_ms,
162 ts_exact: false,
163 kind: EventKind::ToolResult,
164 source: EventSource::Tail,
165 tool: None,
166 tool_call_id: Some(id),
167 tokens_in: None,
168 tokens_out: None,
169 reasoning_tokens: None,
170 cost_usd_e6: None,
171 payload: part.clone(),
172 });
173 seq += 1;
174 }
175 _ => {}
176 }
177 }
178 }
179
180 if let Some(tc) = msg.get("toolCalls").and_then(|t| t.as_array()) {
181 for call in tc {
182 let tool = call
183 .get("name")
184 .or_else(|| call.get("function").and_then(|f| f.get("name")))
185 .and_then(|x| x.as_str())
186 .unwrap_or("")
187 .to_string();
188 let id = call
189 .get("id")
190 .or_else(|| call.get("toolCallId"))
191 .and_then(|x| x.as_str())
192 .unwrap_or("")
193 .to_string();
194 events.push(Event {
195 session_id: session_id.to_string(),
196 seq,
197 ts_ms,
198 ts_exact: false,
199 kind: EventKind::ToolCall,
200 source: EventSource::Tail,
201 tool: Some(tool),
202 tool_call_id: Some(id),
203 tokens_in: None,
204 tokens_out: None,
205 reasoning_tokens: None,
206 cost_usd_e6: None,
207 payload: call.clone(),
208 });
209 seq += 1;
210 }
211 }
212
213 if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
214 for block in content {
215 let typ = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
216 if typ == "tool_use" || typ == "tool-call" {
217 let tool = block
218 .get("name")
219 .and_then(|n| n.as_str())
220 .unwrap_or("")
221 .to_string();
222 let id = block
223 .get("id")
224 .and_then(|x| x.as_str())
225 .unwrap_or("")
226 .to_string();
227 events.push(Event {
228 session_id: session_id.to_string(),
229 seq,
230 ts_ms,
231 ts_exact: false,
232 kind: EventKind::ToolCall,
233 source: EventSource::Tail,
234 tool: Some(tool),
235 tool_call_id: Some(id),
236 tokens_in: None,
237 tokens_out: None,
238 reasoning_tokens: None,
239 cost_usd_e6: None,
240 payload: block.clone(),
241 });
242 seq += 1;
243 } else if typ == "tool_result" {
244 let id = block
245 .get("tool_use_id")
246 .and_then(|x| x.as_str())
247 .unwrap_or("")
248 .to_string();
249 events.push(Event {
250 session_id: session_id.to_string(),
251 seq,
252 ts_ms,
253 ts_exact: false,
254 kind: EventKind::ToolResult,
255 source: EventSource::Tail,
256 tool: None,
257 tool_call_id: Some(id),
258 tokens_in: None,
259 tokens_out: None,
260 reasoning_tokens: None,
261 cost_usd_e6: None,
262 payload: block.clone(),
263 });
264 seq += 1;
265 }
266 }
267 }
268 }
269 events
270}
271
272pub fn parse_opencode_session_file(
274 path: &Path,
275 workspace: &Path,
276) -> Result<Option<(SessionRecord, Vec<Event>)>> {
277 let text = std::fs::read_to_string(path)?;
278 let v: Value = serde_json::from_str(&text)?;
279 if !session_json_directory_field(&v, workspace)
280 && !session_root_matches_workspace(path, workspace)
281 {
282 return Ok(None);
283 }
284 let session_id = v
285 .get("id")
286 .or_else(|| v.get("sessionId"))
287 .and_then(|x| x.as_str())
288 .map(ToOwned::to_owned)
289 .or_else(|| {
290 path.file_stem()
291 .and_then(|s| s.to_str())
292 .map(ToOwned::to_owned)
293 })
294 .unwrap_or_else(|| "opencode-session".to_string());
295
296 let messages = v
297 .get("messages")
298 .and_then(|m| m.as_array())
299 .cloned()
300 .unwrap_or_default();
301 if messages.is_empty() {
302 return Ok(None);
303 }
304
305 let model = v
306 .get("model")
307 .and_then(|m| m.as_str())
308 .map(ToOwned::to_owned)
309 .or_else(|| model_from_json::from_value(&v));
310
311 let events = events_from_messages_array(&session_id, &messages);
312 if events.is_empty() {
313 return Ok(None);
314 }
315
316 let started_at_ms = events.first().map(|e| e.ts_ms).unwrap_or(0);
317 Ok(Some((
318 SessionRecord {
319 id: session_id,
320 agent: AGENT.to_string(),
321 model,
322 workspace: workspace.to_string_lossy().to_string(),
323 started_at_ms,
324 ended_at_ms: None,
325 status: SessionStatus::Done,
326 trace_path: path.to_string_lossy().to_string(),
327 start_commit: None,
328 end_commit: None,
329 branch: None,
330 dirty_start: None,
331 dirty_end: None,
332 repo_binding_source: None,
333 },
334 events,
335 )))
336}
337
338fn walk_json_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
339 if depth > 14 {
340 return;
341 }
342 let Ok(rd) = std::fs::read_dir(dir) else {
343 return;
344 };
345 for e in rd.flatten() {
346 let p = e.path();
347 if p.is_dir() {
348 walk_json_files(&p, out, depth + 1);
349 } else if p.extension().and_then(|x| x.to_str()) == Some("json")
350 && let Ok(m) = p.metadata()
351 && m.len() > 32
352 {
353 out.push(p);
354 }
355 }
356}
357
358pub fn scan_opencode_workspace(workspace: &Path) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
360 let root = data_dir();
361 let project = root.join("project");
362 let storage = root.join("storage");
363 let mut files = Vec::new();
364 let local_opencode = workspace.join(".opencode");
365 if local_opencode.is_dir() {
366 walk_json_files(&local_opencode, &mut files, 0);
367 }
368 if project.is_dir() {
369 walk_json_files(&project, &mut files, 0);
370 }
371 if storage.is_dir() {
372 walk_json_files(&storage, &mut files, 0);
373 }
374 let mut sessions = Vec::new();
375 for f in files {
376 if let Ok(Some(pair)) = parse_opencode_session_file(&f, workspace) {
377 sessions.push(pair);
378 }
379 }
380 Ok(sessions)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use tempfile::TempDir;
387
388 #[test]
389 fn opencode_fixture_parts_tool() {
390 let dir = TempDir::new().unwrap();
391 let ws = dir.path().join("myws");
392 std::fs::create_dir_all(&ws).unwrap();
393 let ws_canon = std::fs::canonicalize(&ws).unwrap();
394
395 let session_path = dir.path().join("session.json");
396 let body = format!(
397 r#"{{
398 "id": "oc-1",
399 "directory": "{}",
400 "model": "anthropic/claude-sonnet",
401 "messages": [
402 {{
403 "role": "assistant",
404 "parts": [
405 {{"type": "tool-call", "toolName": "bash", "toolCallId": "c1"}}
406 ]
407 }}
408 ]
409 }}"#,
410 ws_canon.to_string_lossy().replace('\\', "\\\\")
411 );
412 std::fs::write(&session_path, body).unwrap();
413
414 let pair = parse_opencode_session_file(&session_path, &ws_canon)
415 .unwrap()
416 .expect("session");
417 assert_eq!(pair.0.agent, "opencode");
418 assert_eq!(pair.1[0].kind, EventKind::ToolCall);
419 assert_eq!(pair.1[0].tool.as_deref(), Some("bash"));
420 }
421}