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                            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
317/// Parse one OpenCode session JSON file.
318pub 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
410/// Scan default (or `OPENCODE_DATA_DIR`) OpenCode storage for sessions tied to `workspace`.
411pub 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}