Skip to main content

supercode_interchange/session/
detect.rs

1//! Source detection for a session's on-disk form.
2
3use super::*;
4
5// ---- detection ------------------------------------------------------------
6
7pub(super) fn detect_source(text: &str) -> Option<SessionSource> {
8    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
9    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
10    // pretty-printed, MULTI-LINE JSON document, unlike every other format
11    // this crate reads. It cannot be recognized by the per-line loop below
12    // (no individual line of a pretty-printed document is itself valid
13    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
14    // attempt: a real JSONL file (many newline-separated objects) fails this
15    // parse immediately (trailing-data error) and falls through unaffected.
16    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
17        if v.get("conversation").and_then(Value::as_array).is_some()
18            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
19        {
20            return Some(SessionSource::Goose);
21        }
22        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
23            return Some(SessionSource::OpenCode);
24        }
25    }
26    for line in non_empty_lines(text) {
27        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
28        // than abandoning detection — the loaders themselves skip bad lines, so
29        // bailing here would silently misroute an otherwise-valid Codex file.
30        let Ok(v) = serde_json::from_str::<Value>(line) else {
31            continue;
32        };
33        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
34        // one record per line — the synthesized raw-capture unit for the
35        // JSON-tree/SQLite generations alike. No other format's lines carry
36        // both a top-level `key` ARRAY and a `value` field, so this is
37        // unambiguous against Codex/Pi/Claude Code.
38        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
39            return Some(SessionSource::OpenCode);
40        }
41        // Codex envelopes always carry a `payload`; Claude Code lines never do.
42        if v.get("payload").is_some() {
43            return Some(SessionSource::Codex);
44        }
45        // Gemini CLI starts with an untyped session header. Its project hash
46        // and timestamps distinguish it from Claude Code records that also
47        // carry `sessionId`.
48        if v.get("sessionId").and_then(Value::as_str).is_some()
49            && (v.get("projectHash").is_some()
50                || v.get("startTime").is_some()
51                || v.get("lastUpdated").is_some())
52            && v.get("type").is_none()
53        {
54            return Some(SessionSource::Gemini);
55        }
56        // Grok's resumable `chat_history.jsonl` stores the role/type and
57        // content directly on each record. Claude Code uses a nested
58        // `message` envelope for the overlapping `user`/`assistant` tags.
59        let tag = v.get("type").and_then(Value::as_str);
60        if tag == Some("gemini") && v.get("content").is_some() {
61            return Some(SessionSource::Gemini);
62        }
63        if v.get("message").is_none()
64            && v.get("uuid").is_none()
65            && v.get("sessionId").is_none()
66            && matches!(
67                tag,
68                Some(
69                    "system"
70                        | "user"
71                        | "assistant"
72                        | "tool_result"
73                        | "reasoning"
74                        | "backend_tool_call"
75                )
76            )
77            && (v.get("content").is_some()
78                || v.get("tool_calls").is_some()
79                || v.get("tool_call_id").is_some()
80                || v.get("encrypted_content").is_some()
81                || v.get("kind").is_some())
82        {
83            return Some(SessionSource::Grok);
84        }
85        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
86        // (the session id) with no `message`/`uuid` — Claude Code's own
87        // `type`-bearing lines always carry one or the other, never a
88        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
89        // §1).
90        if v.get("type").and_then(Value::as_str) == Some("session")
91            && v.get("id").and_then(Value::as_str).is_some()
92            && v.get("message").is_none()
93            && v.get("uuid").is_none()
94        {
95            // OpenClaw (>= 2026.7) writes pi-v3 with an IDENTICAL header; the
96            // dialect discriminants live in the body: a `type:"leaf"`
97            // navigation entry, or a vendor-namespaced `__openclaw` object on
98            // a message payload. Structural (parse-level) checks — a session
99            // merely DISCUSSING openclaw in text content never matches.
100            for body in non_empty_lines(text) {
101                let Ok(entry) = serde_json::from_str::<Value>(body) else {
102                    continue;
103                };
104                match entry.get("type").and_then(Value::as_str) {
105                    Some("leaf") if entry.get("targetId").is_some() => {
106                        return Some(SessionSource::OpenClaw);
107                    }
108                    Some("message")
109                        if entry
110                            .get("message")
111                            .and_then(|message| message.get("__openclaw"))
112                            .is_some() =>
113                    {
114                        return Some(SessionSource::OpenClaw);
115                    }
116                    _ => {}
117                }
118            }
119            return Some(SessionSource::Pi);
120        }
121        if v.get("type").is_some() || v.get("message").is_some() {
122            return Some(SessionSource::ClaudeCode);
123        }
124    }
125    None
126}
127
128/// Which on-disk OpenCode storage surface is present under a data root
129/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
130/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
131/// generation A. This is a **filesystem classifier only** — it answers
132/// "which generation is this?" for a corpus-discovery tool; it does not
133/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
134/// for the envelope form any of these three surfaces synthesizes into, and
135/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
136/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
137/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
138/// round-trips via the JSON store per upstream's own behavior even on a
139/// SQLite install, so nothing is silently lost by not reading the legacy
140/// trees directly).
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum OpenCodeStorageSurface {
143    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
144    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
145    /// [`opencode_sqlite_corpus_envelope_text`].
146    Sqlite,
147    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
148    /// marker file `storage/migration`.
149    JsonTreeB,
150    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
151    JsonTreeA,
152}
153
154/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
155/// storage surface present, per the discovery rules frozen in
156/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
157/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
158/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
159/// tree generation-B marker (`storage/migration`); otherwise generation-A's
160/// `project/` subtree. Returns `None` if nothing is found.
161pub fn detect_opencode_storage_surface(
162    data_root: &Path,
163) -> Option<(OpenCodeStorageSurface, PathBuf)> {
164    if let Ok(p) = std::env::var("OPENCODE_DB") {
165        let pb = PathBuf::from(p);
166        if pb.is_file() {
167            return Some((OpenCodeStorageSurface::Sqlite, pb));
168        }
169    }
170    if let Ok(entries) = std::fs::read_dir(data_root) {
171        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
172        // NOT deterministic — a store with both a default-channel
173        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
174        // are legal, e.g. after switching install channels) previously
175        // returned "whichever the OS happened to list first", which could
176        // differ between two `inspect`/`audit`/`convert` runs against the
177        // exact same directory. Collect every `opencode*.db` candidate and
178        // pick deterministically: the exact `opencode.db` name wins if
179        // present (the default/most-common channel); otherwise the
180        // lexicographically-smallest match, so repeated runs always agree.
181        let mut candidates: Vec<PathBuf> = entries
182            .flatten()
183            .map(|entry| entry.path())
184            .filter(|p| {
185                p.file_name()
186                    .and_then(|n| n.to_str())
187                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
188            })
189            .collect();
190        candidates.sort();
191        if let Some(exact) = candidates
192            .iter()
193            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
194        {
195            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
196        }
197        if let Some(first) = candidates.into_iter().next() {
198            return Some((OpenCodeStorageSurface::Sqlite, first));
199        }
200    }
201    let storage = data_root.join("storage");
202    if storage.join("migration").is_file() {
203        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
204    }
205    let project_dir = data_root.join("project");
206    if project_dir.is_dir() {
207        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
208    }
209    None
210}
211
212/// First 16 bytes of every SQLite database file — the format's own magic,
213/// independent of file extension.
214const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
215
216/// Whether `path` should be routed to the OpenCode SQLite loader instead of
217/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
218/// the SQLite magic, OR its extension is `.db` — the latter so a
219/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
220/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
221/// A non-existent path is NOT considered SQLite here — the missing-file
222/// diagnostic in that case comes from the normal load path (`with_context`
223/// at the CLI call sites), which already names the path clearly.
224pub fn looks_like_sqlite(path: &Path) -> bool {
225    if !path.is_file() {
226        return false;
227    }
228    if path.extension().and_then(|e| e.to_str()) == Some("db") {
229        return true;
230    }
231    use std::io::Read;
232    let Ok(mut f) = std::fs::File::open(path) else {
233        return false;
234    };
235    let mut buf = [0u8; 16];
236    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
237}