use super::*;
impl Session {
pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
}
pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
let conn = opencode_sqlite_open(db_path)?;
let id = match session_id {
Some(id) => id.to_string(),
None => opencode_sqlite_primary_session_id(&conn)?,
};
let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
let mut text = lines.join("\n");
text.push('\n');
let mut session = Self::from_opencode_str(&text)?;
session.raw_is_verbatim = false;
Ok(session)
}
pub fn from_opencode_str(text: &str) -> Result<Session> {
let trimmed = text.trim();
if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
{
return Self::from_opencode_export_doc(&doc);
}
}
let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
let mut session_info: Option<Value> = None;
let mut side_records: Vec<Value> = Vec::new();
let mut msgs: Vec<OcMsg> = Vec::new();
let mut msg_index: HashMap<String, usize> = HashMap::new();
let mut parse_error_lines = 0usize;
for line in non_empty_lines(text) {
let Ok(env) = serde_json::from_str::<Value>(line) else {
parse_error_lines += 1;
continue; };
let Some(key) = env.get("key").and_then(Value::as_array) else {
continue; };
let value = env.get("value").cloned().unwrap_or(Value::Null);
match key.first().and_then(Value::as_str) {
Some("session") => session_info = Some(value),
Some("message") => {
let Some(id) = value.get("id").and_then(Value::as_str) else {
continue;
};
let time_created = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(Value::as_i64)
.unwrap_or(0);
msg_index.insert(id.to_string(), msgs.len());
msgs.push(OcMsg {
id: id.to_string(),
time_created,
value,
parts: Vec::new(),
});
}
Some("part") => {
if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
if let Some(&idx) = msg_index.get(msg_id) {
msgs[idx].parts.push(value);
}
}
}
Some("session_diff") | Some("todo") => {
side_records.push(serde_json::json!({"key": key, "value": value}));
}
_ => {} }
}
opencode_guard_against_silent_empty(
!trimmed.is_empty(),
&session_info,
&msgs,
&side_records,
)?;
opencode_session_from_records(
session_info,
side_records,
msgs,
raw,
raw_trailing_newline,
true,
parse_error_lines,
)
}
fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
let messages_arr = doc
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let session_id = session_info
.as_ref()
.and_then(|si| si.get("id"))
.and_then(Value::as_str)
.unwrap_or("ses_unknown")
.to_string();
let project_id = session_info
.as_ref()
.and_then(|si| si.get("projectID"))
.and_then(Value::as_str)
.unwrap_or("global")
.to_string();
let mut raw: Vec<String> = Vec::new();
if let Some(si) = &session_info {
raw.push(
serde_json::json!({"key": ["session", project_id, session_id], "value": si})
.to_string(),
);
}
let mut msgs: Vec<OcMsg> = Vec::new();
for entry in &messages_arr {
let Some(info) = entry.get("info") else {
continue; };
let Some(id) = info.get("id").and_then(Value::as_str) else {
continue;
};
let time_created = info
.get("time")
.and_then(|t| t.get("created"))
.and_then(Value::as_i64)
.unwrap_or(0);
let parts: Vec<Value> = entry
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
raw.push(
serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
);
for p in &parts {
let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
}
msgs.push(OcMsg {
id: id.to_string(),
time_created,
value: info.clone(),
parts,
});
}
opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
opencode_session_from_records(
session_info,
Vec::new(),
msgs,
raw,
true,
false,
0,
)
}
}
struct OcMsg {
id: String,
time_created: i64,
value: Value,
parts: Vec<Value>,
}
const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
fn opencode_guard_against_silent_empty(
non_empty_input: bool,
session_info: &Option<Value>,
msgs: &[OcMsg],
side_records: &[Value],
) -> Result<()> {
let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
|| !msgs.is_empty()
|| !side_records.is_empty();
if non_empty_input && !has_any_record {
return Err(crate::Error::Other(
"opencode input was recognized as an OpenCode source (envelope or \
export-document form) but no session/message/part record could be parsed from \
it — refusing to silently return an empty session"
.to_string(),
));
}
Ok(())
}
fn opencode_session_from_records(
session_info: Option<Value>,
side_records: Vec<Value>,
mut msgs: Vec<OcMsg>,
raw: Vec<String>,
raw_trailing_newline: bool,
raw_is_verbatim: bool,
parse_error_lines: usize,
) -> Result<Session> {
let mut meta = SessionMeta::new(SessionSource::OpenCode);
let msg_index: HashMap<String, usize> = msgs
.iter()
.enumerate()
.map(|(i, m)| (m.id.clone(), i))
.collect();
msgs.sort_by(|a, b| {
a.time_created
.cmp(&b.time_created)
.then_with(|| a.id.cmp(&b.id))
});
for m in &mut msgs {
m.parts.sort_by(|a, b| {
let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
ai.cmp(bi)
});
}
meta.opencode_headers
.push(session_info.clone().unwrap_or(Value::Null));
meta.opencode_headers.extend(side_records);
if let Some(si) = &session_info {
capture_opencode_session_info(si, &mut meta)?;
}
let mut tail_start_pos: Option<usize> = None;
for m in &msgs {
for p in &m.parts {
if p.get("type").and_then(Value::as_str) == Some("compaction") {
if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
if let Some(&tp) = msg_index.get(t) {
tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
}
}
}
}
}
let mut messages = Vec::new();
let mut first_system_seen = false;
for (pos, m) in msgs.iter().enumerate() {
let before = messages.len();
match m.value.get("role").and_then(Value::as_str) {
Some("user") => match opencode_claude_system_subtype(&m.parts) {
Some(subtype) => {
push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
}
None => push_opencode_user(
&m.value,
&m.parts,
&mut messages,
&mut meta,
&mut first_system_seen,
),
},
Some("assistant") => {
push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
}
_ => {}
}
if let Some(original_position) = m
.value
.get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
.and_then(Value::as_u64)
{
if let Some(message) = messages[before..]
.iter_mut()
.find(|message| message.role != Role::Tool)
{
message.metadata.insert(
OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
original_position.to_string(),
);
}
}
for msg in &mut messages[before..] {
let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
if !is_summary {
if let Some(tsp) = tail_start_pos {
if pos < tsp {
msg.metadata
.insert("compacted_out".to_string(), "true".to_string());
}
}
}
}
}
let marked_slots = messages
.iter()
.enumerate()
.filter_map(|(index, message)| {
message
.metadata
.contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
.then_some(index)
})
.collect::<Vec<_>>();
if !marked_slots.is_empty() {
let mut marked_messages = marked_slots
.iter()
.map(|index| messages[*index].clone())
.collect::<Vec<_>>();
marked_messages.sort_by_key(|message| {
message
.metadata
.get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
.and_then(|position| position.parse::<usize>().ok())
.unwrap_or(usize::MAX)
});
for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
messages[slot] = message;
}
for message in &mut messages {
message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
}
}
ensure_tool_results_paired(&mut messages);
let imported_message_count = Some(messages.len());
Ok(Session {
meta,
messages,
subagents: Vec::new(),
raw,
raw_trailing_newline,
imported_message_count,
raw_is_verbatim,
parse_error_lines,
load_residue: Vec::new(),
})
}
pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
for i in 0..sessions.len() {
let child_id = sessions[i].meta.session_id.clone();
let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
continue;
};
let Some(parent_idx) = ids
.iter()
.position(|id| id.as_deref() == Some(parent_id.as_str()))
else {
continue;
};
for m in &sessions[parent_idx].messages {
for (k, v) in &m.metadata {
if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
if v == &child_id {
sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
}
}
}
}
}
}
fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
}
fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
if !db_path.is_file() {
return Err(crate::Error::Other(format!(
"OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
(see `docs/interop/opencode-pi-spec.md` §1.2)",
db_path.display()
)));
}
let conn = Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
crate::Error::Other(format!(
"{} does not look like a valid OpenCode SQLite database: {e}",
db_path.display()
))
})?;
let has_session_table: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
[],
|r| r.get(0),
)
.map_err(|e| {
crate::Error::Other(format!(
"failed to read the OpenCode SQLite schema at {}: {e}",
db_path.display()
))
})?;
if has_session_table == 0 {
return Err(crate::Error::Other(format!(
"{} is a SQLite database but has no `session` table — not a recognized \
OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
db_path.display()
)));
}
Ok(conn)
}
fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
match s.as_deref() {
None => Value::Null,
Some(t) => match serde_json::from_str::<Value>(t) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
column = col,
context,
error = %e,
"opencode SQLite column failed to parse as JSON — treating as absent (D7)"
);
Value::Null
}
},
}
}
fn opencode_session_columns(
conn: &Connection,
) -> rusqlite::Result<std::collections::HashSet<String>> {
let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
let names = stmt.query_map([], |r| r.get::<_, String>(1))?; names.collect()
}
fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
let cols = opencode_session_columns(conn)
.map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
let has = |name: &str| cols.contains(name);
conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
let id: String = r.get("id")?;
let project_id: String = r.get("project_id")?;
let workspace_id: Option<String> = if has("workspace_id") {
r.get("workspace_id")?
} else {
None
};
let parent_id: Option<String> = r.get("parent_id")?;
let slug: String = r.get("slug")?;
let directory: String = r.get("directory")?;
let path: Option<String> = if has("path") { r.get("path")? } else { None };
let title: String = r.get("title")?;
let version: String = r.get("version")?;
let share_url: Option<String> = r.get("share_url")?;
let summary_additions: Option<i64> = r.get("summary_additions")?;
let summary_deletions: Option<i64> = r.get("summary_deletions")?;
let summary_files: Option<i64> = r.get("summary_files")?;
let summary_diffs: Option<String> = r.get("summary_diffs")?;
let metadata: Option<String> = if has("metadata") {
r.get("metadata")?
} else {
None
};
let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
let tokens_input: i64 = if has("tokens_input") {
r.get("tokens_input")?
} else {
0
};
let tokens_output: i64 = if has("tokens_output") {
r.get("tokens_output")?
} else {
0
};
let tokens_reasoning: i64 = if has("tokens_reasoning") {
r.get("tokens_reasoning")?
} else {
0
};
let tokens_cache_read: i64 = if has("tokens_cache_read") {
r.get("tokens_cache_read")?
} else {
0
};
let tokens_cache_write: i64 = if has("tokens_cache_write") {
r.get("tokens_cache_write")?
} else {
0
};
let revert: Option<String> = r.get("revert")?;
let permission: Option<String> = if has("permission") {
r.get("permission")?
} else {
None
};
let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
let model: Option<String> = if has("model") { r.get("model")? } else { None };
let time_created: i64 = r.get("time_created")?;
let time_updated: i64 = r.get("time_updated")?;
let time_compacting: Option<i64> = if has("time_compacting") {
r.get("time_compacting")?
} else {
None
};
let time_archived: Option<i64> = if has("time_archived") {
r.get("time_archived")?
} else {
None
};
let summary =
(summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
.then(|| {
serde_json::json!({
"additions": summary_additions.unwrap_or(0),
"deletions": summary_deletions.unwrap_or(0),
"files": summary_files.unwrap_or(0),
"diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
})
});
let share = share_url.map(|u| serde_json::json!({"url": u}));
Ok(serde_json::json!({
"id": id,
"slug": slug,
"projectID": project_id,
"workspaceID": workspace_id,
"directory": directory,
"path": path,
"parentID": parent_id,
"summary": summary,
"cost": cost,
"tokens": {
"input": tokens_input,
"output": tokens_output,
"reasoning": tokens_reasoning,
"cache": {"read": tokens_cache_read, "write": tokens_cache_write},
},
"share": share,
"title": title,
"agent": agent,
"model": opencode_json_col(model, "model", session_id),
"version": version,
"metadata": opencode_json_col(metadata, "metadata", session_id),
"time": {
"created": time_created,
"updated": time_updated,
"compacting": time_compacting,
"archived": time_archived,
},
"permission": opencode_json_col(permission, "permission", session_id),
"revert": opencode_json_col(revert, "revert", session_id),
}))
})
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
"OpenCode session `{session_id}` not found in this SQLite store"
)),
e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
})
}
fn opencode_row_message_value(
id: &str,
session_id: &str,
data_json: &str,
time_created: i64,
time_updated: i64,
) -> Value {
let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
if let Value::Object(map) = &mut v {
map.insert("id".to_string(), Value::String(id.to_string()));
map.insert(
"sessionID".to_string(),
Value::String(session_id.to_string()),
);
map.insert("time_created".to_string(), Value::from(time_created));
map.insert("time_updated".to_string(), Value::from(time_updated));
}
v
}
fn opencode_row_part_value(
id: &str,
session_id: &str,
message_id: &str,
data_json: &str,
time_created: i64,
time_updated: i64,
) -> Value {
let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
if let Value::Object(map) = &mut v {
map.insert("id".to_string(), Value::String(id.to_string()));
map.insert(
"sessionID".to_string(),
Value::String(session_id.to_string()),
);
map.insert(
"messageID".to_string(),
Value::String(message_id.to_string()),
);
map.insert("time_created".to_string(), Value::from(time_created));
map.insert("time_updated".to_string(), Value::from(time_updated));
}
v
}
fn opencode_sqlite_session_envelope_lines(
conn: &Connection,
db_path: &Path,
session_id: &str,
) -> Result<Vec<String>> {
let mut lines = Vec::new();
let session_info = opencode_row_session_info(conn, session_id)?;
let project_id = session_info
.get("projectID")
.and_then(Value::as_str)
.unwrap_or("global")
.to_string();
lines.push(
serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
.to_string(),
);
let mut msg_stmt = conn
.prepare(
"SELECT id, data, time_created, time_updated FROM message \
WHERE session_id = ?1 ORDER BY time_created, id",
)
.map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
let msg_rows = msg_stmt
.query_map([session_id], |r| {
let id: String = r.get("id")?;
let data: String = r.get("data")?;
let time_created: i64 = r.get("time_created")?;
let time_updated: i64 = r.get("time_updated")?;
Ok((id, data, time_created, time_updated))
})
.map_err(|e| opencode_sql_err(e, "querying messages"))?;
let mut part_stmt = conn
.prepare(
"SELECT id, data, time_created, time_updated FROM part \
WHERE message_id = ?1 ORDER BY id",
)
.map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
for row in msg_rows {
let (msg_id, data, msg_time_created, msg_time_updated) =
row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
let msg_value = opencode_row_message_value(
&msg_id,
session_id,
&data,
msg_time_created,
msg_time_updated,
);
lines.push(
serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
.to_string(),
);
let part_rows = part_stmt
.query_map([&msg_id], |r| {
let id: String = r.get("id")?;
let data: String = r.get("data")?;
let time_created: i64 = r.get("time_created")?;
let time_updated: i64 = r.get("time_updated")?;
Ok((id, data, time_created, time_updated))
})
.map_err(|e| opencode_sql_err(e, "querying parts"))?;
for prow in part_rows {
let (part_id, pdata, part_time_created, part_time_updated) =
prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
let part_value = opencode_row_part_value(
&part_id,
session_id,
&msg_id,
&pdata,
part_time_created,
part_time_updated,
);
lines.push(
serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
.to_string(),
);
}
}
let mut todo_stmt = conn
.prepare(
"SELECT content, status, priority, position, time_created, time_updated \
FROM todo WHERE session_id = ?1 ORDER BY position",
)
.map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
let todo_rows = todo_stmt
.query_map([session_id], |r| {
let content: String = r.get("content")?;
let status: String = r.get("status")?;
let priority: String = r.get("priority")?;
let position: i64 = r.get("position")?;
let time_created: i64 = r.get("time_created")?;
let time_updated: i64 = r.get("time_updated")?;
Ok(serde_json::json!({
"sessionID": session_id,
"content": content,
"status": status,
"priority": priority,
"position": position,
"time": {"created": time_created, "updated": time_updated},
}))
})
.map_err(|e| opencode_sql_err(e, "querying todos"))?;
for trow in todo_rows {
let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
let position = tv.get("position").cloned().unwrap_or(Value::Null);
lines.push(
serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
);
}
if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
lines.push(
serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
.to_string(),
);
}
Ok(lines)
}
fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
let dir = db_path.parent()?;
let sidecar = dir
.join("storage")
.join("session_diff")
.join(format!("{session_id}.json"));
let text = std::fs::read_to_string(&sidecar).ok()?;
match serde_json::from_str::<Value>(&text) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
path = %sidecar.display(),
error = %e,
"opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
);
None
}
}
}
fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
conn.query_row(
"SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
}
e => opencode_sql_err(e, "selecting the primary session"),
})
}
fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
let mut stmt = conn
.prepare("SELECT id FROM session ORDER BY time_created, id")
.map_err(|e| opencode_sql_err(e, "listing sessions"))?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| opencode_sql_err(e, "listing sessions"))?;
let mut ids = Vec::new();
for row in rows {
ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
if limit.is_some_and(|n| ids.len() >= n) {
break;
}
}
Ok(ids)
}
pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
let conn = opencode_sqlite_open(db_path)?;
opencode_sqlite_all_session_ids(&conn, None)
}
pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
let conn = opencode_sqlite_open(db_path)?;
opencode_sqlite_primary_session_id(&conn)
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct OpenCodeSqliteStoreStats {
pub sessions: u64,
pub messages: u64,
pub parts: u64,
pub todos: u64,
}
pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
let conn = opencode_sqlite_open(db_path)?;
let count = |table: &str| -> Result<u64> {
let sql = format!("SELECT count(*) FROM {table}");
conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
.map(|n| n.max(0) as u64)
.map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
};
Ok(OpenCodeSqliteStoreStats {
sessions: count("session")?,
messages: count("message")?,
parts: count("part")?,
todos: count("todo")?,
})
}
pub fn opencode_sqlite_corpus_envelope_text(
db_path: &Path,
limit_sessions: Option<usize>,
) -> Result<String> {
let conn = opencode_sqlite_open(db_path)?;
let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
let mut out = String::new();
for id in ids {
for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
out.push_str(&line);
out.push('\n');
}
}
Ok(out)
}
pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
restore_codex_provenance_from_top_level(si, meta)?;
if let Some(id) = si.get("id").and_then(Value::as_str) {
meta.session_id = Some(id.to_string());
}
if let Some(dir) = si.get("directory").and_then(Value::as_str) {
meta.cwd = Some(PathBuf::from(dir));
}
if let Some(agent) = si.get("agent").and_then(Value::as_str) {
meta.agent_id = Some(agent.to_string());
}
if let Some(model) = si.get("model") {
let provider = model.get("providerID").and_then(Value::as_str);
let id = model.get("id").and_then(Value::as_str);
if let (Some(p), Some(i)) = (provider, id) {
meta.model = Some(format!("{p}/{i}"));
}
}
if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
meta.lineage
.insert("projectID".to_string(), project_id.to_string());
}
if let Some(slug) = si.get("slug").and_then(Value::as_str) {
meta.lineage.insert("slug".to_string(), slug.to_string());
}
if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
meta.lineage
.insert("workspaceID".to_string(), ws.to_string());
}
if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
meta.lineage
.insert("parent_session_id".to_string(), parent.to_string());
meta.lineage
.insert("parent_thread_id".to_string(), parent.to_string());
}
if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
if let Some(v) = si.get("claude_fork_context_ref") {
meta.lineage
.insert("claude_fork_context_ref_raw".to_string(), v.to_string());
}
}
Ok(())
}
#[doc(hidden)]
pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
let mime = part.get("mime").and_then(Value::as_str)?;
let url = part.get("url").and_then(Value::as_str)?;
if !mime.starts_with("image/") || !url.starts_with("data:") {
return None;
}
Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
}
const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
let [part] = parts else { return None };
if part.get("type").and_then(Value::as_str) != Some("text") {
return None;
}
if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
return None;
}
part.get("metadata")
.and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
.and_then(Value::as_str)
.map(str::to_string)
}
fn push_opencode_claude_system(
msg_value: &Value,
parts: &[Value],
subtype: String,
out: &mut Vec<ChatMessage>,
) {
let Some(text) = parts
.first()
.and_then(|p| p.get("text"))
.and_then(Value::as_str)
else {
return;
};
if text.trim().is_empty() {
return;
}
let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
set_opencode_msg_timestamp(&mut msg, msg_value);
out.push(msg);
}
fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
if let Some(ms) = msg_value
.get("time")
.and_then(|t| t.get("created"))
.and_then(Value::as_i64)
{
msg.metadata
.insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
}
}
fn push_opencode_user(
msg_value: &Value,
parts: &[Value],
out: &mut Vec<ChatMessage>,
meta: &mut SessionMeta,
first_system_seen: &mut bool,
) {
let mut text = String::new();
let mut image_parts: Vec<Value> = Vec::new();
let mut has_ignored = false;
for p in parts {
match p.get("type").and_then(Value::as_str) {
Some("text") => {
if p.get("ignored").and_then(Value::as_bool) == Some(true) {
has_ignored = true;
continue; }
if let Some(t) = p.get("text").and_then(Value::as_str) {
push_str_field(&mut text, t);
}
}
Some("file") => {
if let Some(img) = opencode_file_image_part(p) {
image_parts.push(img);
}
}
_ => {}
}
}
let has_images = !image_parts.is_empty();
if text.trim().is_empty() && !has_images {
return;
}
let mut msg = if has_images {
let mut all = Vec::new();
if !text.trim().is_empty() {
all.push(serde_json::json!({"type": "text", "text": text.clone()}));
}
all.extend(image_parts);
ChatMessage {
role: Role::User,
content: None,
content_parts: Some(all),
tool_calls: None,
tool_call_id: None,
name: None,
metadata: Default::default(),
}
} else {
ChatMessage::user(text)
};
if has_ignored {
msg.metadata
.insert("oc_has_ignored_part".to_string(), "true".to_string());
}
if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
msg.metadata
.insert("oc_message_id".to_string(), id.to_string());
}
if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
msg.metadata.insert("agent".to_string(), agent.to_string());
}
if let Some(model) = msg_value.get("model") {
if !model.is_null() {
msg.metadata.insert("model".to_string(), model.to_string());
}
}
if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
if !*first_system_seen {
meta.system_prompt = Some(system.to_string());
*first_system_seen = true;
}
msg.metadata
.insert("system".to_string(), system.to_string());
}
for p in parts {
if p.get("type").and_then(Value::as_str) == Some("compaction") {
msg.metadata
.insert("phase".to_string(), "compaction".to_string());
if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
msg.metadata
.insert("tail_start_id".to_string(), t.to_string());
}
}
}
set_opencode_msg_timestamp(&mut msg, msg_value);
restore_grok_message_extension(msg_value, &mut msg);
out.push(msg);
}
fn push_opencode_assistant(
msg_value: &Value,
parts: &[Value],
out: &mut Vec<ChatMessage>,
meta: &mut SessionMeta,
) {
let mut text = String::new();
let mut calls: Vec<ToolCall> = Vec::new();
let mut thinking = String::new();
let mut reasoning_seen = false;
let mut thinking_sig: Option<String> = None;
let mut tool_results: Vec<(String, String, Value)> = Vec::new();
for p in parts {
match p.get("type").and_then(Value::as_str) {
Some("text") => {
if p.get("ignored").and_then(Value::as_bool) == Some(true) {
continue;
}
if let Some(t) = p.get("text").and_then(Value::as_str) {
push_str_field(&mut text, t);
}
}
Some("reasoning") => {
reasoning_seen = true;
if let Some(t) = p.get("text").and_then(Value::as_str) {
push_str_field(&mut thinking, t);
}
if let Some(sig) = p
.get("metadata")
.and_then(|m| m.get("anthropic"))
.and_then(|a| a.get("signature"))
.and_then(Value::as_str)
{
thinking_sig = Some(sig.to_string());
}
}
Some("tool") => {
let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
let status = p
.get("state")
.and_then(|s| s.get("status"))
.and_then(Value::as_str);
let known_status = matches!(
status,
Some("pending") | Some("running") | Some("completed") | Some("error")
);
if call_id.is_empty() || !known_status {
continue;
}
let input = p
.get("state")
.and_then(|s| s.get("input"))
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
calls.push(function_call(call_id, tool_name, input.to_string()));
if matches!(status, Some("completed") | Some("error")) {
tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
}
}
_ => {}
}
}
let before = out.len();
push_assistant(out, text, calls);
if out.len() == before {
let mut empty = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: None,
tool_call_id: None,
name: None,
metadata: Default::default(),
};
if !reasoning_seen {
empty
.metadata
.insert("empty_assistant_record".to_string(), "true".to_string());
}
out.push(empty);
}
if out.len() > before {
let msg = out.last_mut().expect("just pushed");
if reasoning_seen {
msg.metadata.insert("thinking".to_string(), thinking);
}
if let Some(sig) = thinking_sig {
msg.metadata.insert("thinking_signature".to_string(), sig);
}
if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
msg.metadata
.insert("oc_message_id".to_string(), id.to_string());
}
if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
msg.metadata.insert("agent".to_string(), agent.to_string());
if meta.agent_id.is_none() {
meta.agent_id = Some(agent.to_string());
}
}
let provider = msg_value.get("providerID").and_then(Value::as_str);
let model_id = msg_value.get("modelID").and_then(Value::as_str);
if let (Some(p), Some(i)) = (provider, model_id) {
let full = format!("{p}/{i}");
msg.metadata.insert("model".to_string(), full.clone());
if meta.model.is_none() {
meta.model = Some(full);
}
}
if let Some(cwd) = msg_value
.get("path")
.and_then(|p| p.get("cwd"))
.and_then(Value::as_str)
{
if meta.cwd.is_none() {
meta.cwd = Some(PathBuf::from(cwd));
}
}
if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
msg.metadata
.insert("is_summary".to_string(), "true".to_string());
}
for (key, field) in [
("finish", "finish"),
("variant", "variant"),
("mode", "mode"),
] {
if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
msg.metadata.insert(key.to_string(), s.to_string());
}
}
for (key, field) in [
("cost", "cost"),
("tokens", "tokens"),
("error", "error"),
("structured", "structured"),
] {
if let Some(v) = msg_value.get(field) {
if !v.is_null() {
msg.metadata.insert(key.to_string(), v.to_string());
}
}
}
for p in parts {
if p.get("type").and_then(Value::as_str) == Some("tool")
&& p.get("tool").and_then(Value::as_str) == Some("task")
{
if let (Some(call_id), Some(child)) = (
p.get("callID").and_then(Value::as_str),
p.get("metadata")
.and_then(|m| m.get("sessionId"))
.and_then(Value::as_str),
) {
msg.metadata.insert(
format!("oc_task_child_session_id__{call_id}"),
child.to_string(),
);
}
}
}
set_opencode_msg_timestamp(msg, msg_value);
restore_grok_message_extension(msg_value, msg);
}
for (call_id, tool_name, part) in tool_results {
let status = part
.get("state")
.and_then(|s| s.get("status"))
.and_then(Value::as_str);
let compacted_at = part
.get("state")
.and_then(|s| s.get("time"))
.and_then(|t| t.get("compacted"))
.and_then(Value::as_i64);
let real_output = part
.get("state")
.and_then(|s| s.get("output"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let (content, is_error) = match status {
Some("completed") => {
if compacted_at.is_some() {
(OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
} else {
(real_output.clone(), false)
}
}
Some("error") => {
let err = part
.get("state")
.and_then(|s| s.get("error"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
(err, true)
}
_ => (String::new(), false),
};
let mut tmsg = ChatMessage {
role: Role::Tool,
content: Some(content),
content_parts: None,
tool_calls: None,
tool_call_id: Some(call_id),
name: Some(tool_name),
metadata: Default::default(),
};
if let Some(original_position) = part
.get(OPENCODE_SUPERCODE_RESULT_POSITION)
.and_then(Value::as_u64)
{
tmsg.metadata.insert(
OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
original_position.to_string(),
);
}
if is_error {
crate::mark_tool_error(&mut tmsg);
}
restore_tool_outcome_extension(&part, &mut tmsg);
if let Some(ts) = compacted_at {
tmsg.metadata
.insert("oc_tool_output_compacted".to_string(), real_output);
tmsg.metadata
.insert("oc_tool_time_compacted".to_string(), ts.to_string());
}
if status == Some("completed") {
if let Some(atts) = part
.get("state")
.and_then(|s| s.get("attachments"))
.and_then(Value::as_array)
{
let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
if !images.is_empty() {
let mut parts = Vec::new();
if let Some(t) = &tmsg.content {
if !t.is_empty() {
parts.push(serde_json::json!({"type": "text", "text": t}));
}
}
parts.extend(images);
tmsg.content_parts = Some(parts);
}
}
}
if let Some(id) = part.get("id").and_then(Value::as_str) {
tmsg.metadata
.insert("oc_part_id".to_string(), id.to_string());
}
let tool_ts = part
.get("state")
.and_then(|s| s.get("time"))
.and_then(|t| t.get("end").or_else(|| t.get("start")))
.and_then(Value::as_i64);
if let Some(ms) = tool_ts {
tmsg.metadata
.insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
}
restore_grok_message_extension(&part, &mut tmsg);
out.push(tmsg);
}
}
pub(super) fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
if let Some(real) = msg
.metadata
.get("timestamp")
.and_then(|s| crate::sidecar::rfc3339_to_ms(s))
{
if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
*cursor = cursor.checked_add(1).ok_or_else(|| {
crate::Error::Other(
"cannot synthesize an OpenCode continuation timestamp after i64::MAX"
.to_string(),
)
})?;
return Ok(*cursor);
}
*cursor = (*cursor).max(real);
return Ok(real);
}
let next = cursor.checked_add(1).ok_or_else(|| {
crate::Error::Other(
"cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
)
})?;
*cursor = next.max(SYNTH_TS_MS);
Ok(*cursor)
}
fn opencode_max_timestamp(value: &Value) -> Option<i64> {
fn max_number(value: &Value) -> Option<i64> {
match value {
Value::Number(n) => n.as_i64(),
Value::Array(values) => values.iter().filter_map(max_number).max(),
Value::Object(fields) => fields.values().filter_map(max_number).max(),
_ => None,
}
}
match value {
Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
Value::Object(fields) => fields
.iter()
.filter_map(|(key, value)| {
if key == "time" {
max_number(value)
} else {
opencode_max_timestamp(value)
}
})
.max(),
_ => None,
}
}
impl Session {
fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
let mut session_info: Option<Value> = None;
let mut msg_order: Vec<String> = Vec::new();
let mut msg_values: HashMap<String, Value> = HashMap::new();
let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
for line in &self.raw {
let Ok(env) = serde_json::from_str::<Value>(line) else {
continue;
};
let Some(key) = env.get("key").and_then(Value::as_array) else {
continue;
};
let value = env.get("value").cloned().unwrap_or(Value::Null);
match key.first().and_then(Value::as_str) {
Some("session") => session_info = Some(value),
Some("message") => {
if let Some(id) = value.get("id").and_then(Value::as_str) {
if !msg_values.contains_key(id) {
msg_order.push(id.to_string());
}
msg_values.insert(id.to_string(), value);
}
}
Some("part") => {
if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
msg_parts.entry(mid.to_string()).or_default().push(value);
}
}
_ => {}
}
}
let mut ordered: Vec<(String, i64)> = msg_order
.iter()
.map(|id| {
let tc = msg_values
.get(id)
.and_then(|v| v.get("time"))
.and_then(|t| t.get("created"))
.and_then(Value::as_i64)
.unwrap_or(0);
(id.clone(), tc)
})
.collect();
ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
let mut out = Vec::new();
for (id, _) in ordered {
let mut parts = msg_parts.remove(&id).unwrap_or_default();
parts.sort_by(|a, b| {
let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
ai.cmp(bi)
});
if let Some(v) = msg_values.remove(&id) {
out.push((v, parts));
}
}
(session_info, out)
}
fn synthesized_opencode_info(&self) -> Value {
let id = self
.meta
.session_id
.clone()
.unwrap_or_else(|| "ses_supercode00000000000001".to_string());
let mut info = serde_json::json!({
"id": id,
"projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
"slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
"directory": self.cwd_string(),
"title": "supercode export",
"version": env!("CARGO_PKG_VERSION"),
"time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
});
if let Some(agent) = &self.meta.agent_id {
info["agent"] = Value::String(agent.clone());
}
if let Some(model) = &self.meta.model {
if let Some((provider, mid)) = model.split_once('/') {
info["model"] = serde_json::json!({"providerID": provider, "id": mid});
}
}
if let Some(parent) = self.meta.lineage.get("parent_session_id") {
info["parentID"] = Value::String(parent.clone());
}
if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
info["claude_fork_context_ref"] =
serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
}
if let Some(extension) = native_residue_envelope(&self.meta) {
info[SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY] = native_residue_summary(&extension);
info[SUPERCODE_NATIVE_RESIDUE_KEY] = extension;
}
info
}
fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
if !info.get("time").is_some_and(Value::is_object) {
info["time"] = serde_json::json!({});
}
info["time"]["updated"] = serde_json::json!(timestamp);
}
fn append_synthesized_opencode_messages(
&self,
out: &mut Vec<Value>,
messages: &[ChatMessage],
session_id: &str,
counter: &mut u64,
timestamp_cursor: &mut i64,
) -> Result<()> {
let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
for (message_index, message) in messages.iter().enumerate() {
if message.role == Role::Assistant {
for (tool_index, call) in message.tool_calls().iter().enumerate() {
calls_by_id
.entry(call.id.as_str())
.or_default()
.push((message_index, tool_index));
}
} else if message.role == Role::Tool {
if let Some(id) = &message.tool_call_id {
results_by_id
.entry(id.as_str())
.or_default()
.push((message_index, message));
}
}
}
let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
for (id, calls) in calls_by_id {
let Some(results) = results_by_id.get(id) else {
continue;
};
for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
paired_results.insert(call_position, result);
}
}
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if is_replay_excluded(msg) {
i += 1;
continue;
}
match msg.role {
Role::System => {
let content = msg.content.clone().unwrap_or_default();
if content.trim().is_empty() {
i += 1;
continue;
}
let subtype = msg
.metadata
.get("systemSubtype")
.cloned()
.unwrap_or_else(|| "local_command".to_string());
let msg_id = opencode_fresh_id("msg", counter);
let part_id = opencode_fresh_id("prt", counter);
let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
let mut info = serde_json::json!({
"id": msg_id,
"sessionID": session_id,
"role": "user",
"time": {"created": timestamp},
});
info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
let parts = vec![serde_json::json!({
"id": part_id,
"sessionID": session_id,
"messageID": msg_id,
"type": "text",
"text": content,
"synthetic": true,
"metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
})];
out.push(serde_json::json!({"info": info, "parts": parts}));
i += 1;
}
Role::User => {
let msg_id = opencode_fresh_id("msg", counter);
let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
let mut info = serde_json::json!({
"id": msg_id,
"sessionID": session_id,
"role": "user",
"time": {"created": timestamp},
});
info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
opencode_restore_agent_model_fields(
&mut info, msg, false,
);
set_grok_message_extension(&mut info, self.meta.source, msg);
out.push(serde_json::json!({
"info": info,
"parts": parts,
}));
i += 1;
}
Role::Assistant => {
let msg_id = opencode_fresh_id("msg", counter);
let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
let mut parts = Vec::new();
if let Some(thinking) = msg.metadata.get("thinking") {
let mut part = serde_json::json!({
"id": opencode_fresh_id("prt", counter),
"sessionID": session_id,
"messageID": msg_id,
"type": "reasoning",
"text": thinking,
"time": {"start": timestamp, "end": timestamp},
});
if let Some(signature) = msg.metadata.get("thinking_signature") {
part["metadata"] = serde_json::json!({
"anthropic": {"signature": signature},
});
}
parts.push(part);
}
if let Some(t) = &msg.content {
if !t.is_empty() {
parts.push(serde_json::json!({
"id": opencode_fresh_id("prt", counter),
"sessionID": session_id,
"messageID": msg_id,
"type": "text",
"text": t,
}));
}
}
for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
let input = tc
.function
.parsed_arguments()
.unwrap_or_else(|_| Value::Object(Default::default()));
let paired_result = paired_results.get(&(i, tool_index)).copied();
let state = match paired_result {
Some((_, result)) if crate::is_tool_error(result) => {
let result_timestamp =
opencode_message_timestamp(result, timestamp_cursor)?;
serde_json::json!({
"status": "error",
"input": input,
"error": result.content.clone().unwrap_or_default(),
"time": {"end": result_timestamp},
})
}
Some((_, result)) => {
let result_timestamp =
opencode_message_timestamp(result, timestamp_cursor)?;
let mut s = serde_json::json!({
"status": "completed",
"input": input,
"output": result.content.clone().unwrap_or_default(),
"title": tc.function.name,
"time": {"end": result_timestamp},
});
if let Some(cps) = &result.content_parts {
let atts: Vec<Value> = cps
.iter()
.filter(|p| {
p.get("type").and_then(Value::as_str)
== Some("image_url")
})
.filter_map(|p| {
let url = p
.get("image_url")
.and_then(|u| u.get("url"))
.and_then(Value::as_str)?;
let mime = url
.strip_prefix("data:")
.and_then(|r| r.split_once(','))
.map(|(m, _)| m.trim_end_matches(";base64"))
.unwrap_or("application/octet-stream");
Some(serde_json::json!({
"mime": mime,
"url": url,
}))
})
.collect();
if !atts.is_empty() {
s["attachments"] = Value::Array(atts);
}
}
s
}
None => serde_json::json!({"status": "pending", "input": input}),
};
let mut part = serde_json::json!({
"id": opencode_fresh_id("prt", counter),
"sessionID": session_id,
"messageID": msg_id,
"type": "tool",
"callID": tc.id,
"tool": tc.function.name,
"state": state,
});
if let Some((result_position, _)) = paired_result {
part[OPENCODE_SUPERCODE_RESULT_POSITION] =
serde_json::json!(result_position);
}
if paired_result.is_some_and(|(_, result)| {
crate::tool_outcome(result) == crate::ToolOutcome::Unknown
}) {
part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
}
if let Some((_, result)) = paired_result {
set_grok_message_extension(&mut part, self.meta.source, result);
}
parts.push(part);
}
let mut info = serde_json::json!({
"id": msg_id,
"sessionID": session_id,
"role": "assistant",
"time": {"created": timestamp},
});
info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
opencode_restore_agent_model_fields(
&mut info, msg, true,
);
set_grok_message_extension(&mut info, self.meta.source, msg);
out.push(serde_json::json!({
"info": info,
"parts": parts,
}));
i += 1;
}
Role::Tool => i += 1,
}
}
Ok(())
}
pub(super) fn to_opencode_jsonl(&self) -> Result<String> {
let mut info = self.synthesized_opencode_info();
let ses_id = info
.get("id")
.and_then(Value::as_str)
.unwrap_or("ses_new")
.to_string();
let mut messages_json: Vec<Value> = Vec::new();
let mut counter: u64 = 0;
let mut timestamp_cursor =
opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
self.append_synthesized_opencode_messages(
&mut messages_json,
&self.messages,
&ses_id,
&mut counter,
&mut timestamp_cursor,
)?;
if !messages_json.is_empty() {
Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
}
let doc = serde_json::json!({"info": info, "messages": messages_json});
Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
}
pub(super) fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
if self.raw.is_empty() {
return self.to_opencode_jsonl();
}
let (session_info, records) = self.opencode_records_from_raw();
let (_, message_prefix_len) = self.spliced_prefix_lens();
let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
if let Some(id) = session_id {
info["id"] = Value::String(id.to_string());
}
let ses_id_for_new = info
.get("id")
.and_then(Value::as_str)
.unwrap_or("ses_new")
.to_string();
let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
.chain(records.iter().flat_map(|(msg, parts)| {
std::iter::once(opencode_max_timestamp(msg))
.chain(parts.iter().map(opencode_max_timestamp))
}))
.flatten()
.max()
.unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
let mut messages_json: Vec<Value> = records
.into_iter()
.map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
.collect();
let imported_len = messages_json.len();
let mut counter: u64 = 0;
self.append_synthesized_opencode_messages(
&mut messages_json,
&self.messages[message_prefix_len..],
&ses_id_for_new,
&mut counter,
&mut timestamp_cursor,
)?;
if messages_json.len() > imported_len {
Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
}
let doc = serde_json::json!({"info": info, "messages": messages_json});
Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
}
pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
let (session_info, mut records) = self.opencode_records_from_raw();
let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
let ses_id = info
.get("id")
.and_then(Value::as_str)
.unwrap_or("ses_new")
.to_string();
if info.get("id").is_none() {
info["id"] = Value::String(ses_id.clone());
}
let project_id = info
.get("projectID")
.and_then(Value::as_str)
.unwrap_or("global")
.to_string();
let (_, message_prefix_len) = self.spliced_prefix_lens();
let mut counter: u64 = 0;
let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
.chain(records.iter().flat_map(|(msg, parts)| {
std::iter::once(opencode_max_timestamp(msg))
.chain(parts.iter().map(opencode_max_timestamp))
}))
.flatten()
.max()
.unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
let mut appended_json: Vec<Value> = Vec::new();
self.append_synthesized_opencode_messages(
&mut appended_json,
&self.messages[message_prefix_len..],
&ses_id,
&mut counter,
&mut timestamp_cursor,
)?;
if !appended_json.is_empty() {
Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
}
for entry in appended_json {
let msg = entry.get("info").cloned().unwrap_or(Value::Null);
let parts = entry
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
records.push((msg, parts));
}
let storage = data_root.join("storage");
let session_dir = storage.join("session").join(&project_id);
std::fs::create_dir_all(&session_dir)?;
std::fs::write(
session_dir.join(format!("{ses_id}.json")),
serde_json::to_string_pretty(&info).unwrap_or_default(),
)?;
let message_dir = storage.join("message").join(&ses_id);
let part_dir = storage.join("part");
std::fs::create_dir_all(&message_dir)?;
for (msg, parts) in &records {
let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
continue;
};
std::fs::write(
message_dir.join(format!("{msg_id}.json")),
serde_json::to_string_pretty(msg).unwrap_or_default(),
)?;
let this_part_dir = part_dir.join(msg_id);
std::fs::create_dir_all(&this_part_dir)?;
for part in parts {
let Some(part_id) = part.get("id").and_then(Value::as_str) else {
continue;
};
std::fs::write(
this_part_dir.join(format!("{part_id}.json")),
serde_json::to_string_pretty(part).unwrap_or_default(),
)?;
}
}
for header in &self.meta.opencode_headers {
let Some(key) = header.get("key").and_then(Value::as_array) else {
continue;
};
let Some(kind) = key.first().and_then(Value::as_str) else {
continue;
};
let value = header.get("value").cloned().unwrap_or(Value::Null);
if !matches!(kind, "session_diff" | "todo") {
continue;
}
let dir = storage.join(kind);
std::fs::create_dir_all(&dir)?;
std::fs::write(
dir.join(format!("{ses_id}.json")),
serde_json::to_string_pretty(&value).unwrap_or_default(),
)?;
}
Ok(session_dir)
}
}
fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
*counter += 1;
format!("{prefix}_synth{counter:06}")
}
fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
if let Some(agent) = msg.metadata.get("agent") {
info["agent"] = Value::String(agent.clone());
}
if let Some(model) = msg.metadata.get("model") {
if is_assistant {
if let Some((provider, model_id)) = model.split_once('/') {
info["providerID"] = Value::String(provider.to_string());
info["modelID"] = Value::String(model_id.to_string());
}
} else if let Ok(v) = serde_json::from_str::<Value>(model) {
info["model"] = v;
}
}
if !is_assistant {
return;
}
if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
info["summary"] = Value::Bool(true);
}
if let Some(finish) = msg.metadata.get("finish") {
info["finish"] = Value::String(finish.clone());
}
if let Some(cost) = msg.metadata.get("cost") {
if let Ok(v) = serde_json::from_str::<Value>(cost) {
info["cost"] = v;
}
}
if let Some(tokens) = msg.metadata.get("tokens") {
if let Ok(v) = serde_json::from_str::<Value>(tokens) {
info["tokens"] = v;
}
}
}
fn opencode_user_parts_from_message(
msg: &ChatMessage,
msg_id: &str,
session_id: &str,
counter: &mut u64,
) -> Vec<Value> {
let mut parts = Vec::new();
if let Some(cps) = &msg.content_parts {
for p in cps {
match p.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = p.get("text").and_then(Value::as_str) {
parts.push(serde_json::json!({
"id": opencode_fresh_id("prt", counter),
"sessionID": session_id,
"messageID": msg_id,
"type": "text",
"text": t,
}));
}
}
Some("image_url") => {
if let Some(url) = p
.get("image_url")
.and_then(|u| u.get("url"))
.and_then(Value::as_str)
{
let mime = url
.strip_prefix("data:")
.and_then(|r| r.split_once(','))
.map(|(m, _)| m.trim_end_matches(";base64"))
.unwrap_or("application/octet-stream");
parts.push(serde_json::json!({
"id": opencode_fresh_id("prt", counter),
"sessionID": session_id,
"messageID": msg_id,
"type": "file",
"mime": mime,
"url": url,
}));
}
}
_ => {}
}
}
} else if let Some(t) = &msg.content {
if !t.is_empty() {
parts.push(serde_json::json!({
"id": opencode_fresh_id("prt", counter),
"sessionID": session_id,
"messageID": msg_id,
"type": "text",
"text": t,
}));
}
}
parts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
let msg = ChatMessage::user("continuation");
let mut cursor = i64::MAX - 1;
assert_eq!(
opencode_message_timestamp(&msg, &mut cursor).unwrap(),
i64::MAX
);
let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
assert!(err.to_string().contains("after i64::MAX"));
assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
}
}