use super::import_session::ImportOptions;
use super::{
MessageRole, SessionImporter, SessionMessage, SessionProvider, SessionSummary, ShareError,
SharedSession,
};
use serde_json::Value;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const PROVIDER: &str = "codex";
#[derive(Default)]
pub struct CodexProvider {
root_override: Option<PathBuf>,
}
impl CodexProvider {
pub fn with_root(root: PathBuf) -> Self {
Self { root_override: Some(root) }
}
fn root(&self) -> Option<PathBuf> {
self.root_override
.clone()
.or_else(|| std::env::var("ISELF_CODEX_DIR").ok().map(PathBuf::from))
.or_else(|| dirs::home_dir().map(|h| h.join(".codex")))
}
fn jsonl_files(&self) -> Vec<PathBuf> {
let root = match self.root() {
Some(r) if r.exists() => r,
_ => return Vec::new(),
};
let mut out = Vec::new();
for entry in WalkDir::new(&root)
.max_depth(6)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file()
&& entry.path().extension().and_then(|e| e.to_str()) == Some("jsonl")
{
out.push(entry.path().to_path_buf());
}
}
out
}
}
impl SessionProvider for CodexProvider {
fn name(&self) -> &str {
PROVIDER
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError> {
let mut out = Vec::new();
for path in self.jsonl_files() {
let id = derive_id_from_path(&path);
if let Some(s) = summarize(&path, &id) {
out.push(s);
}
}
Ok(out)
}
fn load_session(&self, id: &str) -> Result<SharedSession, ShareError> {
for path in self.jsonl_files() {
if derive_id_from_path(&path) == id {
return parse_jsonl(&path, id);
}
}
Err(ShareError::NotFound(id.to_string()))
}
}
fn derive_id_from_path(p: &Path) -> String {
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if let Some(rest) = stem.strip_prefix("rollout-") {
return rest.to_string();
}
if !stem.is_empty() && stem != "history" {
return stem.to_string();
}
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
p.hash(&mut h);
format!("codex-{:016x}", h.finish())
}
fn summarize(path: &Path, id: &str) -> Option<SessionSummary> {
let content = std::fs::read_to_string(path).ok()?;
let mut count = 0usize;
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
let mut title_hint: Option<String> = None;
let mut project_path: Option<PathBuf> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()) {
project_path.get_or_insert_with(|| PathBuf::from(cwd));
}
if started_at.is_none() {
started_at = parse_timestamp(&v);
}
if let Some(text) = extract_message_text(&v) {
if classify(&v) != Classify::Skip {
count += 1;
if title_hint.is_none() && classify(&v) == Classify::User {
title_hint = Some(text.chars().take(80).collect());
}
}
}
}
Some(SessionSummary {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path,
started_at,
message_count: count,
title_hint,
imported: false,
})
}
fn parse_jsonl(path: &Path, id: &str) -> Result<SharedSession, ShareError> {
let content = std::fs::read_to_string(path)?;
let mut messages = Vec::new();
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
let mut project_path: Option<PathBuf> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()) {
project_path.get_or_insert_with(|| PathBuf::from(cwd));
}
let timestamp = parse_timestamp(&v);
if started_at.is_none() {
started_at = timestamp;
}
let kind = classify(&v);
if kind == Classify::Skip {
continue;
}
let text = extract_message_text(&v).unwrap_or_default();
if text.is_empty() && !matches!(kind, Classify::ToolUse | Classify::ToolResult) {
continue;
}
let mut metadata = std::collections::HashMap::new();
if let Some(model) = v.get("model").and_then(|m| m.as_str()) {
metadata.insert("model".to_string(), model.to_string());
}
let role = match kind {
Classify::User => MessageRole::User,
Classify::Assistant => MessageRole::Assistant,
Classify::ToolUse => {
if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
metadata.insert("tool_name".to_string(), name.to_string());
}
MessageRole::ToolUse
}
Classify::ToolResult => MessageRole::ToolResult,
Classify::Skip => unreachable!(),
};
let content = if matches!(kind, Classify::ToolUse) {
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("?");
let args = v
.get("arguments")
.map(|a| match a {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
})
.unwrap_or_default();
format!("{}({})", name, args)
} else {
text
};
messages.push(SessionMessage {
role,
content,
timestamp,
metadata,
});
}
Ok(SharedSession {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path,
started_at,
messages,
})
}
#[derive(Debug, PartialEq, Eq)]
enum Classify {
User,
Assistant,
ToolUse,
ToolResult,
Skip,
}
fn classify(v: &Value) -> Classify {
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
"user_message" | "user_input" | "input_text" => return Classify::User,
"agent_message" | "assistant_message" | "assistant" | "message_create" => {
return Classify::Assistant
}
"function_call" | "tool_call" | "tool_use" => return Classify::ToolUse,
"function_call_output" | "tool_result" => return Classify::ToolResult,
"session_meta" | "session_start" | "session_end" | "" => {}
_ => {}
}
match v.get("role").and_then(|r| r.as_str()).unwrap_or("") {
"user" => Classify::User,
"assistant" => Classify::Assistant,
"tool" => Classify::ToolResult,
"system" => Classify::Skip, _ => Classify::Skip,
}
}
fn parse_timestamp(v: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
if let Some(s) = v.get("timestamp").and_then(|t| t.as_str()) {
if let Ok(d) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(d.with_timezone(&chrono::Utc));
}
}
if let Some(secs) = v.get("created").and_then(|c| c.as_i64()) {
return chrono::DateTime::from_timestamp(secs, 0);
}
None
}
fn extract_message_text(v: &Value) -> Option<String> {
if let Some(s) = v.get("content").and_then(|c| c.as_str()) {
return Some(s.to_string());
}
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
let mut parts = Vec::new();
for block in arr {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
parts.push(t.to_string());
} else if let Some(t) = block.get("output").and_then(|t| t.as_str()) {
parts.push(t.to_string());
}
}
if !parts.is_empty() {
return Some(parts.join("\n"));
}
}
if let Some(s) = v.get("output").and_then(|o| o.as_str()) {
return Some(s.to_string());
}
if let Some(s) = v.get("message").and_then(|m| m.as_str()) {
return Some(s.to_string());
}
None
}
#[derive(Default)]
pub struct CodexImporter;
impl SessionImporter for CodexImporter {
fn name(&self) -> &str {
PROVIDER
}
fn import(&self, session: &SharedSession, opts: &ImportOptions) -> Result<String, ShareError> {
let root = opts
.dest_root_override
.clone()
.or_else(|| std::env::var("ISELF_CODEX_DIR").ok().map(PathBuf::from))
.or_else(|| dirs::home_dir().map(|h| h.join(".codex")))
.ok_or_else(|| ShareError::Parse("no home directory".into()))?;
let now = chrono::Utc::now();
let dir = root
.join("sessions")
.join(format!("{:04}", now.format("%Y")))
.join(format!("{:02}", now.format("%m")))
.join(format!("{:02}", now.format("%d")));
std::fs::create_dir_all(&dir).map_err(ShareError::Io)?;
let new_id = uuid::Uuid::new_v4().to_string();
let path = dir.join(format!("rollout-{}.jsonl", new_id));
let mut out = String::new();
out.push_str(
&serde_json::json!({
"type": "session_meta",
"timestamp": now.to_rfc3339(),
"cwd": session.project_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()),
"imported_from": {"provider": session.provider, "id": session.id},
})
.to_string(),
);
out.push('\n');
out.push_str(
&serde_json::json!({
"type": "user_message",
"timestamp": now.to_rfc3339(),
"content": format!(
"[i-self import] Continued from {} session {}. {} prior messages follow.",
session.provider, session.id, session.messages.len()
),
})
.to_string(),
);
out.push('\n');
for msg in &session.messages {
let envelope = encode_codex(msg);
out.push_str(&envelope.to_string());
out.push('\n');
}
std::fs::write(&path, out).map_err(ShareError::Io)?;
Ok(format!(
"Wrote {} messages to {}. Run `codex resume` to continue.",
session.messages.len() + 2,
path.display()
))
}
}
fn encode_codex(msg: &SessionMessage) -> Value {
let ts = msg
.timestamp
.unwrap_or_else(chrono::Utc::now)
.to_rfc3339();
match msg.role {
MessageRole::User => serde_json::json!({
"type": "user_message",
"timestamp": ts,
"content": msg.content,
}),
MessageRole::Assistant => serde_json::json!({
"type": "agent_message",
"timestamp": ts,
"content": [{"type": "text", "text": msg.content}],
}),
MessageRole::ToolUse => {
let name = msg
.metadata
.get("tool_name")
.map(|s| s.as_str())
.unwrap_or("imported_tool");
serde_json::json!({
"type": "function_call",
"timestamp": ts,
"name": name,
"arguments": msg.content,
})
}
MessageRole::ToolResult => serde_json::json!({
"type": "function_call_output",
"timestamp": ts,
"output": msg.content,
}),
MessageRole::System => serde_json::json!({
"type": "agent_message",
"timestamp": ts,
"content": [{"type": "text", "text": format!("[system] {}", msg.content)}],
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_rollout(dir: &Path, id: &str, content: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let p = dir.join(format!("rollout-{}.jsonl", id));
std::fs::write(&p, content).unwrap();
p
}
#[test]
fn parses_modern_rollout_events() {
let tmp = tempfile::tempdir().unwrap();
let day = tmp.path().join("sessions").join("2026").join("05").join("07");
write_rollout(
&day,
"abc",
r#"{"type":"session_meta","timestamp":"2026-05-07T10:00:00Z","cwd":"/Users/foo","model":"o4-mini"}
{"type":"user_message","timestamp":"2026-05-07T10:00:01Z","content":"refactor"}
{"type":"agent_message","timestamp":"2026-05-07T10:00:02Z","content":[{"type":"text","text":"on it"}]}
{"type":"function_call","timestamp":"2026-05-07T10:00:03Z","name":"shell","arguments":"ls"}
{"type":"function_call_output","timestamp":"2026-05-07T10:00:04Z","output":"file.rs"}
"#,
);
let p = CodexProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("abc").unwrap();
assert_eq!(s.messages.len(), 4);
assert!(matches!(s.messages[0].role, MessageRole::User));
assert!(matches!(s.messages[1].role, MessageRole::Assistant));
assert!(matches!(s.messages[2].role, MessageRole::ToolUse));
assert!(s.messages[2].content.starts_with("shell("));
assert!(matches!(s.messages[3].role, MessageRole::ToolResult));
assert_eq!(s.messages[3].content, "file.rs");
assert_eq!(s.project_path.as_ref().unwrap().to_string_lossy(), "/Users/foo");
}
#[test]
fn parses_legacy_role_envelopes() {
let tmp = tempfile::tempdir().unwrap();
let day = tmp.path().join("sessions").join("2026").join("01").join("01");
write_rollout(
&day,
"leg",
r#"{"role":"user","content":"hi","timestamp":"2026-01-01T00:00:00Z"}
{"role":"assistant","content":"hello"}
"#,
);
let s = CodexProvider::with_root(tmp.path().to_path_buf())
.load_session("leg")
.unwrap();
assert_eq!(s.messages.len(), 2);
}
#[test]
fn list_sessions_walks_date_dirs() {
let tmp = tempfile::tempdir().unwrap();
write_rollout(
&tmp.path().join("sessions").join("2026").join("05").join("06"),
"x",
r#"{"type":"user_message","content":"a"}"#,
);
write_rollout(
&tmp.path().join("sessions").join("2026").join("05").join("07"),
"y",
r#"{"type":"user_message","content":"b"}"#,
);
let p = CodexProvider::with_root(tmp.path().to_path_buf());
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 2);
}
#[test]
fn round_trip_through_importer() {
let src_dir = tempfile::tempdir().unwrap();
let day = src_dir.path().join("sessions").join("2026").join("05").join("07");
write_rollout(
&day,
"src",
r#"{"type":"user_message","content":"fix it"}
{"type":"agent_message","content":[{"type":"text","text":"yes"}]}
"#,
);
let session = CodexProvider::with_root(src_dir.path().to_path_buf())
.load_session("src")
.unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let opts = ImportOptions {
dest_root_override: Some(dest_dir.path().to_path_buf()),
..ImportOptions::default()
};
CodexImporter.import(&session, &opts).unwrap();
let count = WalkDir::new(dest_dir.path())
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl"))
.count();
assert_eq!(count, 1);
}
}