use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use serde_json::Value;
use crate::{
ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
};
mod claude_code;
pub(crate) use claude_code::ClaudeAppendState;
#[doc(hidden)]
pub use claude_code::ClaudeReadIndex;
mod codex;
mod detect;
mod gemini;
mod goose;
mod grok;
mod helpers;
mod hermes;
mod native;
mod openclaw;
mod opencode;
mod pi;
mod residue;
pub(crate) use claude_code::*;
use codex::*;
pub use detect::*;
use gemini::*;
use grok::*;
pub use helpers::*;
pub use hermes::*;
use native::*;
pub use openclaw::*;
pub use opencode::*;
pub use pi::*;
pub use residue::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSource {
ClaudeCode,
Codex,
OpenCode,
Pi,
Grok,
Gemini,
Goose,
OpenClaw,
Hermes,
Native,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionFormat {
ClaudeCode,
Codex,
OpenCode,
Pi,
Grok,
Gemini,
Goose,
}
impl SessionFormat {
pub fn source(self) -> SessionSource {
match self {
SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
SessionFormat::Codex => SessionSource::Codex,
SessionFormat::OpenCode => SessionSource::OpenCode,
SessionFormat::Pi => SessionSource::Pi,
SessionFormat::Grok => SessionSource::Grok,
SessionFormat::Gemini => SessionSource::Gemini,
SessionFormat::Goose => SessionSource::Goose,
}
}
pub fn for_source(source: SessionSource) -> Option<Self> {
[
SessionFormat::ClaudeCode,
SessionFormat::Codex,
SessionFormat::OpenCode,
SessionFormat::Pi,
SessionFormat::Grok,
SessionFormat::Gemini,
SessionFormat::Goose,
]
.into_iter()
.find(|format| format.source() == source)
}
}
pub use crate::ontology::surface::{
CrossSurface, Recurrence, SurfaceKey, Trigger, WorkspaceKind, WorkspaceRef,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrchestrationNouns {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger: Option<Trigger>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface: Option<SurfaceKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurrence: Option<Recurrence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cross_surface: Option<CrossSurface>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<WorkspaceRef>,
}
impl OrchestrationNouns {
pub fn from_meta(meta: &SessionMeta) -> Self {
Self {
trigger: Some(meta.trigger_or_default()),
surface: meta.surface.clone(),
profile: meta.profile.clone(),
recurrence: meta.recurrence.clone(),
cross_surface: meta.cross_surface.clone(),
workspace: Some(meta.workspace_ref()),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SessionMeta {
pub source: SessionSource,
pub session_id: Option<String>,
pub ended_at: Option<String>,
pub end_reason: Option<String>,
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub system_prompt: Option<String>,
pub codex_headers: Vec<Value>,
pub codex_provenance: Vec<Value>,
pub native_residue: Vec<Value>,
pub native_residue_source: Option<String>,
pub message_records: Vec<Option<usize>>,
pub opencode_headers: Vec<Value>,
pub goose_header: Option<Value>,
pub agent_id: Option<String>,
pub parent_tool_use_id: Option<String>,
pub lineage: std::collections::BTreeMap<String, String>,
pub trigger: Option<Trigger>,
pub surface: Option<SurfaceKey>,
pub profile: Option<String>,
pub recurrence: Option<Recurrence>,
pub cross_surface: Option<CrossSurface>,
}
impl SessionMeta {
pub(crate) fn new(source: SessionSource) -> Self {
SessionMeta {
source,
session_id: None,
ended_at: None,
end_reason: None,
model: None,
cwd: None,
system_prompt: None,
codex_headers: Vec::new(),
codex_provenance: Vec::new(),
native_residue: Vec::new(),
native_residue_source: None,
message_records: Vec::new(),
opencode_headers: Vec::new(),
goose_header: None,
agent_id: None,
parent_tool_use_id: None,
lineage: std::collections::BTreeMap::new(),
trigger: None,
surface: None,
profile: None,
recurrence: None,
cross_surface: None,
}
}
pub fn trigger_or_default(&self) -> Trigger {
if let Some(t) = self.trigger {
return t;
}
let delegate = self
.lineage
.get("hermes_lineage_kind")
.map(|k| k == "delegate")
.unwrap_or(false);
if self.agent_id.is_some() || self.parent_tool_use_id.is_some() || delegate {
Trigger::Parent
} else {
Trigger::Human
}
}
pub fn workspace(&self) -> (WorkspaceKind, Option<String>) {
if let Some(cwd) = &self.cwd {
return (
WorkspaceKind::Repo,
Some(cwd.to_string_lossy().into_owned()),
);
}
if let Some(surface) = self.surface.as_ref().filter(|s| s.is_channel()) {
let label = match (&surface.platform, &surface.chat_id) {
(Some(p), Some(c)) => format!("{p}:{c}"),
(Some(p), None) => p.clone(),
_ => String::new(),
};
return (WorkspaceKind::Channel, Some(label));
}
(WorkspaceKind::None, None)
}
pub fn workspace_ref(&self) -> WorkspaceRef {
let (kind, value) = self.workspace();
WorkspaceRef { kind, value }
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub meta: SessionMeta,
pub messages: Vec<ChatMessage>,
pub subagents: Vec<Session>,
pub raw: Vec<String>,
pub raw_trailing_newline: bool,
pub imported_message_count: Option<usize>,
pub raw_is_verbatim: bool,
pub parse_error_lines: usize,
pub load_residue: Vec<String>,
}
impl Session {
pub fn load_fidelity(&self) -> Fidelity {
let own = if !self.load_residue.is_empty() {
Fidelity::Semantic
} else if self.raw_is_verbatim {
Fidelity::ByteLossless
} else {
Fidelity::ValueLossless
};
if own != Fidelity::Semantic
&& self
.subagents
.iter()
.any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
{
return Fidelity::Semantic;
}
own
}
pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
Session {
meta: SessionMeta::new(SessionSource::Native),
messages,
subagents: Vec::new(),
raw: Vec::new(),
raw_trailing_newline: true,
imported_message_count: None,
raw_is_verbatim: false,
parse_error_lines: 0,
load_residue: Vec::new(),
}
}
pub fn load(path: impl AsRef<Path>) -> Result<Session> {
Self::load_with_fidelity(path, Fidelity::ByteLossless)
}
pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
Self::load_with_fidelity_and_subagents(path, fidelity, true)
}
#[doc(hidden)]
pub fn load_parent_with_fidelity(
path: impl AsRef<Path>,
fidelity: Fidelity,
) -> Result<Session> {
Self::load_with_fidelity_and_subagents(path, fidelity, false)
}
#[doc(hidden)]
pub fn load_display_view(
path: impl AsRef<Path>,
fidelity: Fidelity,
message_limit: usize,
) -> Result<Session> {
let path = path.as_ref();
if path.is_dir() || looks_like_sqlite(path) {
let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
truncate_session_messages(&mut session, message_limit);
return Ok(session);
}
let mut read_limit = message_limit.max(1);
let mut previous_window_len = 0usize;
let (mut session, omitted_prefix) = loop {
let (source, text, omitted_prefix) = read_display_jsonl(path, read_limit)?;
let mut candidate = match source {
Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
Some(SessionSource::Gemini) => {
let mut session = Self::from_gemini_str(&text)?;
session.raw_is_verbatim = false;
session.load_residue.push(
"display history is a bounded native-record projection, not a complete Gemini artifact"
.to_string(),
);
session
}
Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
Some(SessionSource::Grok) => {
let mut session = Self::from_grok_str(&text)?;
session.capture_grok_path_metadata(path);
session
}
Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
_ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
};
let observed_messages = candidate
.imported_message_count
.unwrap_or(candidate.messages.len())
.max(candidate.messages.len());
let human_turns = candidate
.messages
.iter()
.filter(|message| message.role == Role::User)
.count();
let window_len = text.len();
let sufficient =
!omitted_prefix || (observed_messages > message_limit.max(1) && human_turns >= 2);
let byte_window_exhausted = window_len <= previous_window_len && read_limit >= 4096;
if sufficient || byte_window_exhausted {
if omitted_prefix {
candidate.imported_message_count =
Some(observed_messages.max(message_limit.max(1).saturating_add(1)));
}
break (candidate, omitted_prefix);
}
previous_window_len = window_len;
read_limit = read_limit.saturating_mul(2);
};
if omitted_prefix {
session.load_residue.push(
"older native records remain outside this bounded display window".to_string(),
);
}
truncate_session_messages(&mut session, message_limit);
Ok(session)
}
fn load_with_fidelity_and_subagents(
path: impl AsRef<Path>,
fidelity: Fidelity,
include_subagents: bool,
) -> Result<Session> {
let path = path.as_ref();
if path.is_dir() {
return match detect_opencode_storage_surface(path) {
Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
Self::from_opencode_sqlite(&db_path, None)
}
Some((
OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
_,
)) => Err(crate::Error::Other(format!(
"{} is an OpenCode data root using a legacy JSON storage tree, which \
supercode does not load directly — point `inspect`/`convert`/`resume` \
at the store's `opencode*.db` SQLite file if this install has one, or \
use `audit --format opencode {}` instead",
path.display(),
path.display()
))),
None => Err(crate::Error::Other(format!(
"{} is a directory, but no session file or OpenCode store was found in it \
(expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
tree)",
path.display()
))),
};
}
if looks_like_sqlite(path) {
if let Ok(conn) = Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
| rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
if hermes_sqlite_fingerprint(&conn) {
drop(conn);
return Self::from_hermes_sqlite(path, None);
}
}
return Self::from_opencode_sqlite(path, None);
}
let text = read_utf8_or_diagnose(path)?;
match detect_source(&text) {
Some(SessionSource::Codex) => Self::from_codex_str(&text),
Some(SessionSource::Pi) => Self::from_pi_str(&text),
Some(SessionSource::OpenClaw) => {
let mut session = Self::from_openclaw_str(&text)?;
if session.meta.profile.is_none() {
session.meta.profile = openclaw_agent_id_from_path(path);
}
Ok(session)
}
Some(SessionSource::Grok) => {
let mut session = Self::from_grok_str(&text)?;
session.capture_grok_path_metadata(path);
Ok(session)
}
Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
Some(SessionSource::Goose) => Self::from_goose_str(&text),
Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
_ => {
let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
if include_subagents {
session.attach_claude_subagents(path, &text, fidelity)?;
}
Ok(session)
}
}
}
pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
use std::collections::HashMap;
let mut idx: HashMap<String, usize> = HashMap::new();
for (i, s) in sessions.iter().enumerate() {
if let Some(id) = &s.meta.session_id {
idx.insert(id.clone(), i);
}
}
let parent_of: Vec<Option<usize>> = sessions
.iter()
.map(|s| {
s.meta
.lineage
.get("parent_thread_id")
.and_then(|p| idx.get(p).copied())
})
.collect();
let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
let mut order: Vec<usize> = (0..slots.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
for i in order {
if let Some(p) = parent_of[i] {
if p != i {
if let Some(child) = slots[i].take() {
if let Some(parent) = slots[p].as_mut() {
parent.subagents.push(child);
} else {
slots[i] = Some(child); }
}
}
}
}
slots.into_iter().flatten().collect()
}
pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
match format {
SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
SessionFormat::Codex => Self::from_codex_str(jsonl),
SessionFormat::Pi => Self::from_pi_str(jsonl),
SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
SessionFormat::Grok => Self::from_grok_str(jsonl),
SessionFormat::Gemini => Self::from_gemini_str(jsonl),
SessionFormat::Goose => Self::from_goose_str(jsonl),
}
}
pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
match format {
SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
SessionFormat::Codex => Ok(self.to_codex_jsonl()),
SessionFormat::Pi => Ok(self.to_pi_jsonl()),
SessionFormat::OpenCode => self.to_opencode_jsonl(),
SessionFormat::Grok => Ok(self.to_grok_jsonl()),
SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
SessionFormat::Goose => Ok(self.to_goose_json()),
}
}
pub fn to_jsonl_spliced(
&self,
format: SessionFormat,
session_id: Option<&str>,
) -> Result<String> {
if self.parse_error_lines > 0
|| self
.subagents
.iter()
.any(|subagent| subagent.parse_error_lines > 0)
{
return Err(Error::InvalidSession(
"refusing spliced export because the loaded session contains parse loss"
.to_string(),
));
}
if self.raw.is_empty() || format.source() != self.meta.source {
if let Some(session_id) = session_id {
let mut rewritten = self.clone();
rewritten.meta.session_id = Some(session_id.to_string());
return rewritten.to_jsonl(format);
}
return self.to_jsonl(format);
}
match format {
SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
}
}
pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
Ok(())
}
pub fn raw_verbatim(&self) -> String {
join_lines_verbatim(&self.raw, self.raw_trailing_newline)
}
pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
}
pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
self.messages = tree.linear_projection()?;
Ok(())
}
}
pub(super) fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
let bytes = read_session_bytes(path)?;
String::from_utf8(bytes).map_err(|_| {
crate::Error::Other(format!(
"{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
(no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
path.display()
))
})
}
pub fn is_zstd_session_path(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()) == Some("zst")
}
pub fn open_session_reader(path: &Path) -> std::io::Result<Box<dyn Read>> {
let file = std::fs::File::open(path)?;
if !is_zstd_session_path(path) {
return Ok(Box::new(file));
}
let decoder = ruzstd::decoding::StreamingDecoder::new(file).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{} is not a readable zstd stream: {error}", path.display()),
)
})?;
Ok(Box::new(decoder))
}
pub fn read_session_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
if !is_zstd_session_path(path) {
return std::fs::read(path);
}
let mut bytes = Vec::new();
open_session_reader(path)?.read_to_end(&mut bytes)?;
Ok(bytes)
}
pub(super) fn read_display_jsonl(
path: &Path,
message_limit: usize,
) -> Result<(Option<SessionSource>, String, bool)> {
const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
const BYTES_PER_MESSAGE: u64 = 16 * 1024;
if is_zstd_session_path(path) {
let text = read_utf8_or_diagnose(path)?;
return Ok((detect_source(&text), text, false));
}
let mut first = String::new();
BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
let source = detect_source(&first);
if !matches!(
source,
Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
) {
let text = read_utf8_or_diagnose(path)?;
return Ok((detect_source(&text), text, false));
}
let mut file = std::fs::File::open(path)?;
let file_len = file.metadata()?.len();
let requested = (message_limit.max(1) as u64)
.saturating_mul(BYTES_PER_MESSAGE)
.clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
if file_len <= requested {
let text = read_utf8_or_diagnose(path)?;
return Ok((source, text, false));
}
let start = file_len - requested;
file.seek(SeekFrom::Start(start))?;
let mut bytes = Vec::with_capacity(requested as usize);
file.read_to_end(&mut bytes)?;
if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
bytes.drain(..=newline);
}
let mut tail = String::from_utf8(bytes).map_err(|_| {
crate::Error::Other(format!(
"{} contains non-UTF-8 data in its display window",
path.display()
))
})?;
if start > 0 {
let max_search_bytes = start.min(MAX_TAIL_BYTES);
let mut search_bytes = requested.min(max_search_bytes);
let anchors = loop {
let search_start = start - search_bytes;
file.seek(SeekFrom::Start(search_start))?;
let mut search = Vec::with_capacity(search_bytes as usize);
(&mut file).take(search_bytes).read_to_end(&mut search)?;
if search_start > 0 {
if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
search.drain(..=newline);
} else {
search.clear();
}
}
if let Some(newline) = search.iter().rposition(|byte| *byte == b'\n') {
search.truncate(newline + 1);
} else {
search.clear();
}
let anchors = std::str::from_utf8(&search)
.ok()
.map(|search| {
let mut found = search
.lines()
.rev()
.filter(|line| native_display_human_line(line, source))
.take(2)
.map(str::to_string)
.collect::<Vec<_>>();
found.reverse();
found
})
.unwrap_or_default();
if anchors.len() >= 2 || search_start == 0 || search_bytes == max_search_bytes {
break anchors;
}
search_bytes = search_bytes.saturating_mul(2).min(max_search_bytes);
};
if !anchors.is_empty() {
tail = format!("{}\n{tail}", anchors.join("\n"));
}
}
let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
format!("{first}{tail}")
} else {
tail
};
Ok((source, text, true))
}