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 stop_reason: None,
148 latency_ms: None,
149 ttft_ms: None,
150 retry_count: None,
151 context_used_tokens: None,
152 context_max_tokens: None,
153 cache_creation_tokens: None,
154 cache_read_tokens: None,
155 system_prompt_tokens: None,
156 payload: part.clone(),
157 });
158 seq += 1;
159 }
160 "tool-result" | "tool_result" => {
161 let id = part
162 .get("toolCallId")
163 .or_else(|| part.get("tool_call_id"))
164 .and_then(|x| x.as_str())
165 .unwrap_or("")
166 .to_string();
167 events.push(Event {
168 session_id: session_id.to_string(),
169 seq,
170 ts_ms,
171 ts_exact: false,
172 kind: EventKind::ToolResult,
173 source: EventSource::Tail,
174 tool: None,
175 tool_call_id: Some(id),
176 tokens_in: None,
177 tokens_out: None,
178 reasoning_tokens: None,
179 cost_usd_e6: None,
180 stop_reason: None,
181 latency_ms: None,
182 ttft_ms: None,
183 retry_count: None,
184 context_used_tokens: None,
185 context_max_tokens: None,
186 cache_creation_tokens: None,
187 cache_read_tokens: None,
188 system_prompt_tokens: None,
189 payload: part.clone(),
190 });
191 seq += 1;
192 }
193 _ => {}
194 }
195 }
196 }
197
198 if let Some(tc) = msg.get("toolCalls").and_then(|t| t.as_array()) {
199 for call in tc {
200 let tool = call
201 .get("name")
202 .or_else(|| call.get("function").and_then(|f| f.get("name")))
203 .and_then(|x| x.as_str())
204 .unwrap_or("")
205 .to_string();
206 let id = call
207 .get("id")
208 .or_else(|| call.get("toolCallId"))
209 .and_then(|x| x.as_str())
210 .unwrap_or("")
211 .to_string();
212 events.push(Event {
213 session_id: session_id.to_string(),
214 seq,
215 ts_ms,
216 ts_exact: false,
217 kind: EventKind::ToolCall,
218 source: EventSource::Tail,
219 tool: Some(tool),
220 tool_call_id: Some(id),
221 tokens_in: None,
222 tokens_out: None,
223 reasoning_tokens: None,
224 cost_usd_e6: None,
225 stop_reason: None,
226 latency_ms: None,
227 ttft_ms: None,
228 retry_count: None,
229 context_used_tokens: None,
230 context_max_tokens: None,
231 cache_creation_tokens: None,
232 cache_read_tokens: None,
233 system_prompt_tokens: None,
234 payload: call.clone(),
235 });
236 seq += 1;
237 }
238 }
239
240 if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
241 for block in content {
242 let typ = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
243 if typ == "tool_use" || typ == "tool-call" {
244 let tool = block
245 .get("name")
246 .and_then(|n| n.as_str())
247 .unwrap_or("")
248 .to_string();
249 let id = block
250 .get("id")
251 .and_then(|x| x.as_str())
252 .unwrap_or("")
253 .to_string();
254 events.push(Event {
255 session_id: session_id.to_string(),
256 seq,
257 ts_ms,
258 ts_exact: false,
259 kind: EventKind::ToolCall,
260 source: EventSource::Tail,
261 tool: Some(tool),
262 tool_call_id: Some(id),
263 tokens_in: None,
264 tokens_out: None,
265 reasoning_tokens: None,
266 cost_usd_e6: None,
267 stop_reason: None,
268 latency_ms: None,
269 ttft_ms: None,
270 retry_count: None,
271 context_used_tokens: None,
272 context_max_tokens: None,
273 cache_creation_tokens: None,
274 cache_read_tokens: None,
275 system_prompt_tokens: None,
276 payload: block.clone(),
277 });
278 seq += 1;
279 } else if typ == "tool_result" {
280 let id = block
281 .get("tool_use_id")
282 .and_then(|x| x.as_str())
283 .unwrap_or("")
284 .to_string();
285 events.push(Event {
286 session_id: session_id.to_string(),
287 seq,
288 ts_ms,
289 ts_exact: false,
290 kind: EventKind::ToolResult,
291 source: EventSource::Tail,
292 tool: None,
293 tool_call_id: Some(id),
294 tokens_in: None,
295 tokens_out: None,
296 reasoning_tokens: None,
297 cost_usd_e6: None,
298 stop_reason: None,
299 latency_ms: None,
300 ttft_ms: None,
301 retry_count: None,
302 context_used_tokens: None,
303 context_max_tokens: None,
304 cache_creation_tokens: None,
305 cache_read_tokens: None,
306 system_prompt_tokens: None,
307 payload: block.clone(),
308 });
309 seq += 1;
310 }
311 }
312 }
313 }
314 events
315}
316
317pub fn parse_opencode_session_file(
319 path: &Path,
320 workspace: &Path,
321) -> Result<Option<(SessionRecord, Vec<Event>)>> {
322 let text = std::fs::read_to_string(path)?;
323 let v: Value = serde_json::from_str(&text)?;
324 if !session_json_directory_field(&v, workspace)
325 && !session_root_matches_workspace(path, workspace)
326 {
327 return Ok(None);
328 }
329 let session_id = v
330 .get("id")
331 .or_else(|| v.get("sessionId"))
332 .and_then(|x| x.as_str())
333 .map(ToOwned::to_owned)
334 .or_else(|| {
335 path.file_stem()
336 .and_then(|s| s.to_str())
337 .map(ToOwned::to_owned)
338 })
339 .unwrap_or_else(|| "opencode-session".to_string());
340
341 let messages = v
342 .get("messages")
343 .and_then(|m| m.as_array())
344 .cloned()
345 .unwrap_or_default();
346 if messages.is_empty() {
347 return Ok(None);
348 }
349
350 let model = v
351 .get("model")
352 .and_then(|m| m.as_str())
353 .map(ToOwned::to_owned)
354 .or_else(|| model_from_json::from_value(&v));
355
356 let events = events_from_messages_array(&session_id, &messages);
357 if events.is_empty() {
358 return Ok(None);
359 }
360
361 let started_at_ms = events.first().map(|e| e.ts_ms).unwrap_or(0);
362 Ok(Some((
363 SessionRecord {
364 id: session_id,
365 agent: AGENT.to_string(),
366 model,
367 workspace: workspace.to_string_lossy().to_string(),
368 started_at_ms,
369 ended_at_ms: None,
370 status: SessionStatus::Done,
371 trace_path: path.to_string_lossy().to_string(),
372 start_commit: None,
373 end_commit: None,
374 branch: None,
375 dirty_start: None,
376 dirty_end: None,
377 repo_binding_source: None,
378 prompt_fingerprint: None,
379 parent_session_id: None,
380 agent_version: None,
381 os: None,
382 arch: None,
383 repo_file_count: None,
384 repo_total_loc: None,
385 },
386 events,
387 )))
388}
389
390fn walk_json_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
391 if depth > 14 {
392 return;
393 }
394 let Ok(rd) = std::fs::read_dir(dir) else {
395 return;
396 };
397 for e in rd.flatten() {
398 let p = e.path();
399 if p.is_dir() {
400 walk_json_files(&p, out, depth + 1);
401 } else if p.extension().and_then(|x| x.to_str()) == Some("json")
402 && let Ok(m) = p.metadata()
403 && m.len() > 32
404 {
405 out.push(p);
406 }
407 }
408}
409
410pub fn scan_opencode_workspace(workspace: &Path) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
412 let root = data_dir();
413 let project = root.join("project");
414 let storage = root.join("storage");
415 let mut files = Vec::new();
416 let local_opencode = workspace.join(".opencode");
417 if local_opencode.is_dir() {
418 walk_json_files(&local_opencode, &mut files, 0);
419 }
420 if project.is_dir() {
421 walk_json_files(&project, &mut files, 0);
422 }
423 if storage.is_dir() {
424 walk_json_files(&storage, &mut files, 0);
425 }
426 let mut sessions = Vec::new();
427 for f in files {
428 if let Ok(Some(pair)) = parse_opencode_session_file(&f, workspace) {
429 sessions.push(pair);
430 }
431 }
432 Ok(sessions)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use tempfile::TempDir;
439
440 #[test]
441 fn opencode_fixture_parts_tool() {
442 let dir = TempDir::new().unwrap();
443 let ws = dir.path().join("myws");
444 std::fs::create_dir_all(&ws).unwrap();
445 let ws_canon = std::fs::canonicalize(&ws).unwrap();
446
447 let session_path = dir.path().join("session.json");
448 let body = format!(
449 r#"{{
450 "id": "oc-1",
451 "directory": "{}",
452 "model": "anthropic/claude-sonnet",
453 "messages": [
454 {{
455 "role": "assistant",
456 "parts": [
457 {{"type": "tool-call", "toolName": "bash", "toolCallId": "c1"}}
458 ]
459 }}
460 ]
461 }}"#,
462 ws_canon.to_string_lossy().replace('\\', "\\\\")
463 );
464 std::fs::write(&session_path, body).unwrap();
465
466 let pair = parse_opencode_session_file(&session_path, &ws_canon)
467 .unwrap()
468 .expect("session");
469 assert_eq!(pair.0.agent, "opencode");
470 assert_eq!(pair.1[0].kind, EventKind::ToolCall);
471 assert_eq!(pair.1[0].tool.as_deref(), Some("bash"));
472 }
473}