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}