Skip to main content

kaizen/collect/tail/
opencode.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Ingest OpenCode session JSON from `~/.local/share/opencode` (or `OPENCODE_DATA_DIR`).
3
4use 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
30/// Session root directory for this workspace (path contains workspace id / folder).
31fn 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
272/// Parse one OpenCode session JSON file.
273pub 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            prompt_fingerprint: None,
334        },
335        events,
336    )))
337}
338
339fn walk_json_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
340    if depth > 14 {
341        return;
342    }
343    let Ok(rd) = std::fs::read_dir(dir) else {
344        return;
345    };
346    for e in rd.flatten() {
347        let p = e.path();
348        if p.is_dir() {
349            walk_json_files(&p, out, depth + 1);
350        } else if p.extension().and_then(|x| x.to_str()) == Some("json")
351            && let Ok(m) = p.metadata()
352            && m.len() > 32
353        {
354            out.push(p);
355        }
356    }
357}
358
359/// Scan default (or `OPENCODE_DATA_DIR`) OpenCode storage for sessions tied to `workspace`.
360pub fn scan_opencode_workspace(workspace: &Path) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
361    let root = data_dir();
362    let project = root.join("project");
363    let storage = root.join("storage");
364    let mut files = Vec::new();
365    let local_opencode = workspace.join(".opencode");
366    if local_opencode.is_dir() {
367        walk_json_files(&local_opencode, &mut files, 0);
368    }
369    if project.is_dir() {
370        walk_json_files(&project, &mut files, 0);
371    }
372    if storage.is_dir() {
373        walk_json_files(&storage, &mut files, 0);
374    }
375    let mut sessions = Vec::new();
376    for f in files {
377        if let Ok(Some(pair)) = parse_opencode_session_file(&f, workspace) {
378            sessions.push(pair);
379        }
380    }
381    Ok(sessions)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use tempfile::TempDir;
388
389    #[test]
390    fn opencode_fixture_parts_tool() {
391        let dir = TempDir::new().unwrap();
392        let ws = dir.path().join("myws");
393        std::fs::create_dir_all(&ws).unwrap();
394        let ws_canon = std::fs::canonicalize(&ws).unwrap();
395
396        let session_path = dir.path().join("session.json");
397        let body = format!(
398            r#"{{
399            "id": "oc-1",
400            "directory": "{}",
401            "model": "anthropic/claude-sonnet",
402            "messages": [
403                {{
404                    "role": "assistant",
405                    "parts": [
406                        {{"type": "tool-call", "toolName": "bash", "toolCallId": "c1"}}
407                    ]
408                }}
409            ]
410        }}"#,
411            ws_canon.to_string_lossy().replace('\\', "\\\\")
412        );
413        std::fs::write(&session_path, body).unwrap();
414
415        let pair = parse_opencode_session_file(&session_path, &ws_canon)
416            .unwrap()
417            .expect("session");
418        assert_eq!(pair.0.agent, "opencode");
419        assert_eq!(pair.1[0].kind, EventKind::ToolCall);
420        assert_eq!(pair.1[0].tool.as_deref(), Some("bash"));
421    }
422}