use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use super::super::images::extract_images_from_jsonl;
use super::super::transcript::{
build_agent_type_map, generate_clean_transcript, generate_clean_transcript_with_agents,
resolve_agent_display_name, resolve_assistant_name, resolve_user_name,
};
use super::super::{AgentInfo, MANIFEST_WRITE_VERSION, Manifest, SourceBreakdown};
use super::ArchiveResult;
use super::get_codex_dir;
use super::include::IncludeSet;
use super::paths::determine_archive_dir;
use super::sources::{
TimestampWindow, derive_session_window, find_agent_sessions, find_history_slice, find_mcp_logs,
find_tool_outputs,
};
fn current_uid() -> Option<u32> {
#[cfg(unix)]
{
unsafe extern "C" {
fn getuid() -> u32;
}
Some(unsafe { getuid() })
}
#[cfg(not(unix))]
{
None
}
}
fn home_user_slug() -> Option<String> {
let home = dirs::home_dir()?;
let s = home.to_string_lossy();
Some(s.replace('/', "-"))
}
fn total_agents_bytes(archive_dir: &Path) -> u64 {
let agents_dir = archive_dir.join("agents");
if !agents_dir.exists() {
return 0;
}
let mut total = 0u64;
if let Ok(entries) = fs::read_dir(&agents_dir) {
for e in entries.flatten() {
if let Ok(meta) = e.metadata() {
total += meta.len();
}
}
}
total
}
fn build_source_breakdown(
session_jsonl_bytes: u64,
agents_bytes: u64,
images_bytes: u64,
sidecars: &SidecarCounts,
) -> Option<SourceBreakdown> {
let any_new_sidecar = sidecars.mcp_log_count.is_some()
|| sidecars.tool_output_count.is_some()
|| sidecars.history_lines.is_some();
if !any_new_sidecar {
return None;
}
Some(SourceBreakdown {
session_jsonl_bytes,
agents_bytes,
images_bytes,
mcp_bytes: sidecars.mcp_bytes,
tool_output_bytes: sidecars.tool_output_bytes,
history_bytes: sidecars.history_bytes,
})
}
#[derive(Debug, Default)]
struct SidecarCounts {
tool_output_count: Option<usize>,
mcp_log_count: Option<usize>,
history_lines: Option<usize>,
mcp_bytes: u64,
tool_output_bytes: u64,
history_bytes: u64,
}
fn capture_optional_sidecars(
archive_dir: &Path,
cwd_encoded: Option<&str>,
session_uuid: &str,
window: TimestampWindow,
include: &IncludeSet,
) -> SidecarCounts {
let mut counts = SidecarCounts::default();
if include.mcp
&& let Some(cwd) = cwd_encoded
{
match find_mcp_logs(cwd, window) {
Ok(paths) => {
let mcp_dir = archive_dir.join("mcp");
if let Err(e) = fs::create_dir_all(&mcp_dir) {
eprintln!("warning: failed to create mcp/ sidecar dir: {e}");
} else {
let mut n = 0usize;
let mut bytes = 0u64;
for src in paths {
let name = match src.file_name() {
Some(n) => n.to_owned(),
None => continue,
};
let dest = mcp_dir.join(&name);
if let Err(e) = fs::copy(&src, &dest) {
eprintln!("warning: failed to copy mcp log {src:?}: {e}");
continue;
}
if let Ok(meta) = fs::metadata(&dest) {
bytes += meta.len();
}
n += 1;
}
counts.mcp_log_count = Some(n);
counts.mcp_bytes = bytes;
}
}
Err(e) => eprintln!("warning: mcp log walk failed: {e}"),
}
}
if include.tool_output
&& let (Some(uid), Some(user_slug)) = (current_uid(), home_user_slug())
{
match find_tool_outputs(uid, &user_slug, session_uuid) {
Ok(paths) => {
let to_dir = archive_dir.join("tool-output");
if let Err(e) = fs::create_dir_all(&to_dir) {
eprintln!("warning: failed to create tool-output/ sidecar dir: {e}");
} else {
let mut n = 0usize;
let mut bytes = 0u64;
for src in paths {
let name = match src.file_name() {
Some(n) => n.to_owned(),
None => continue,
};
let dest = to_dir.join(&name);
if let Err(e) = fs::copy(&src, &dest) {
eprintln!("warning: failed to copy tool output {src:?}: {e}");
continue;
}
if let Ok(meta) = fs::metadata(&dest) {
bytes += meta.len();
}
n += 1;
}
counts.tool_output_count = Some(n);
counts.tool_output_bytes = bytes;
}
}
Err(e) => eprintln!("warning: tool-output walk failed: {e}"),
}
}
if include.history {
match find_history_slice(window) {
Ok(lines) => {
let history_dir = archive_dir.join("history");
if let Err(e) = fs::create_dir_all(&history_dir) {
eprintln!("warning: failed to create history/ sidecar dir: {e}");
} else {
let payload = if lines.is_empty() {
String::new()
} else {
let mut s = lines.join("\n");
s.push('\n');
s
};
let dest = history_dir.join("history.jsonl");
let bytes = payload.len() as u64;
if let Err(e) = fs::write(&dest, &payload) {
eprintln!("warning: failed to write history slice: {e}");
} else {
counts.history_lines = Some(lines.len());
counts.history_bytes = bytes;
}
}
}
Err(e) => eprintln!("warning: history walk failed: {e}"),
}
}
counts
}
pub(crate) fn archive_session(
session_path: &Path,
clean: bool,
include_agents_in_clean_md: bool,
include: &IncludeSet,
) -> Result<PathBuf> {
if !session_path.exists() {
anyhow::bail!("Session file not found: {:?}", session_path);
}
let user_name = resolve_user_name();
let assistant_name = resolve_assistant_name();
let session_id = session_path
.file_stem()
.and_then(|s| s.to_str())
.context("Invalid session filename")?
.to_string();
let metadata = fs::metadata(session_path)?;
let size_bytes = metadata.len();
let cwd_encoded = session_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|s| s.to_string());
let project_path = cwd_encoded.clone();
let content = fs::read_to_string(session_path)?;
let message_count = content.lines().filter(|l| !l.trim().is_empty()).count();
let mut hasher = Sha256::new();
hasher.update(&content);
let checksum = format!("sha256:{:x}", hasher.finalize());
let window = derive_session_window(session_path)?;
let session_start: DateTime<Utc> = window.start;
let session_end: DateTime<Utc> = window.end;
let codex_dir = get_codex_dir()?;
fs::create_dir_all(&codex_dir)?;
let short_uuid = &session_id[0..8.min(session_id.len())];
let timestamp = session_start.format("%Y-%m-%d-%H%M%S");
let base_name = format!("{}-{}", timestamp, short_uuid);
let archive_dir = determine_archive_dir(&codex_dir, &base_name)?;
fs::create_dir_all(&archive_dir)?;
let agents: Vec<AgentInfo> = if include.subagents {
find_agent_sessions(session_path)?
} else {
Vec::new()
};
if clean {
let images_dir = archive_dir.join("images");
fs::create_dir_all(&images_dir)?;
let (_stripped_content, mut all_images) = extract_images_from_jsonl(&content, &images_dir)?;
if !agents.is_empty() {
for agent in &agents {
let source_path = PathBuf::from(&agent.id);
if let Ok(agent_content) = fs::read_to_string(&source_path)
&& let Ok((_modified_agent_content, agent_images)) =
extract_images_from_jsonl(&agent_content, &images_dir)
{
for img in agent_images {
if !all_images.iter().any(|existing| existing.hash == img.hash) {
all_images.push(img);
}
}
}
}
}
let image_count = all_images.len();
let agent_type_map = build_agent_type_map(&content);
let transcript = if include_agents_in_clean_md && !agents.is_empty() {
let mut agent_sessions = Vec::new();
for agent in &agents {
let source_path = PathBuf::from(&agent.id);
if let Ok(agent_content) = fs::read_to_string(&source_path) {
let agent_name = resolve_agent_display_name(&source_path, &agent_type_map);
agent_sessions.push((agent_name, agent_content));
}
}
agent_sessions.sort_by(|a, b| a.0.cmp(&b.0));
generate_clean_transcript_with_agents(
&content,
&agent_sessions,
&user_name,
&assistant_name,
)?
} else {
generate_clean_transcript(&content, &user_name, &assistant_name)?
};
let conversation_md_path = archive_dir.join("conversation.md");
fs::write(&conversation_md_path, &transcript)?;
let md_size = fs::metadata(&conversation_md_path)
.map(|m| m.len())
.unwrap_or(transcript.len() as u64);
let images_size: u64 = all_images.iter().map(|img| img.size_bytes).sum();
let archive_size_bytes = md_size + images_size;
let sidecars = capture_optional_sidecars(
&archive_dir,
cwd_encoded.as_deref(),
&session_id,
window,
include,
);
let breakdown = build_source_breakdown(0, 0, images_size, &sidecars);
let manifest = Manifest {
version: MANIFEST_WRITE_VERSION,
session_id: session_id.clone(),
archived_at: Utc::now(),
session_start,
session_end,
project_path,
message_count,
agent_count: 0,
agents: Vec::new(),
size_bytes: archive_size_bytes,
checksum,
image_count: Some(image_count),
images: Some(all_images),
has_clean_transcript: Some(true),
user_name: Some(user_name.clone()),
assistant_name: Some(assistant_name.clone()),
tool_output_count: sidecars.tool_output_count,
mcp_log_count: sidecars.mcp_log_count,
history_lines: sidecars.history_lines,
source_breakdown: breakdown,
};
let manifest_json = serde_json::to_string_pretty(&manifest)?;
fs::write(archive_dir.join("manifest.json"), manifest_json)?;
println!("Archived session (clean) to: {}", archive_dir.display());
println!(" Messages: {}", message_count);
println!(" Images: {}", image_count);
println!(" Size: {} KB", archive_size_bytes / 1024);
println!(" conversation.md written");
return Ok(archive_dir);
}
let images_dir = archive_dir.join("images");
fs::create_dir_all(&images_dir)?;
let session_content = fs::read_to_string(session_path)?;
let (modified_session_content, mut all_images) =
extract_images_from_jsonl(&session_content, &images_dir)?;
let dest_session = archive_dir.join("session.jsonl");
fs::write(&dest_session, modified_session_content)?;
if !agents.is_empty() {
let agents_dir = archive_dir.join("agents");
fs::create_dir_all(&agents_dir)?;
for agent in &agents {
let source_path = PathBuf::from(&agent.id);
let agent_filename = source_path
.file_name()
.context("Agent path has no filename")?;
let dest_agent = agents_dir.join(agent_filename);
let agent_content = fs::read_to_string(&source_path)?;
let (modified_agent_content, agent_images) =
extract_images_from_jsonl(&agent_content, &images_dir)?;
for img in agent_images {
if !all_images.iter().any(|existing| existing.hash == img.hash) {
all_images.push(img);
}
}
fs::write(&dest_agent, modified_agent_content)?;
}
}
let sidecars = capture_optional_sidecars(
&archive_dir,
cwd_encoded.as_deref(),
&session_id,
window,
include,
);
let session_jsonl_bytes = fs::metadata(&dest_session).map(|m| m.len()).unwrap_or(0);
let agents_bytes = total_agents_bytes(&archive_dir);
let images_bytes: u64 = all_images.iter().map(|img| img.size_bytes).sum();
let breakdown =
build_source_breakdown(session_jsonl_bytes, agents_bytes, images_bytes, &sidecars);
let image_count = all_images.len();
let manifest = Manifest {
version: MANIFEST_WRITE_VERSION,
session_id: session_id.clone(),
archived_at: Utc::now(),
session_start,
session_end,
project_path,
message_count,
agent_count: agents.len(),
agents: agents.clone(),
size_bytes,
checksum,
image_count: Some(image_count),
images: Some(all_images),
has_clean_transcript: None,
user_name: Some(user_name),
assistant_name: Some(assistant_name),
tool_output_count: sidecars.tool_output_count,
mcp_log_count: sidecars.mcp_log_count,
history_lines: sidecars.history_lines,
source_breakdown: breakdown,
};
let manifest_json = serde_json::to_string_pretty(&manifest)?;
fs::write(archive_dir.join("manifest.json"), manifest_json)?;
println!("Archived session to: {}", archive_dir.display());
println!(" Messages: {}", message_count);
println!(" Agents: {}", agents.len());
println!(" Images: {}", image_count);
println!(" Size: {} KB", size_bytes / 1024);
Ok(archive_dir)
}
pub(crate) fn save_all_sessions(
clean: bool,
include_agents_in_clean_md: bool,
include: &IncludeSet,
) -> Result<ArchiveResult> {
let projects_dir = crate::paths::claude_projects_dir();
if !projects_dir.exists() {
eprintln!(
"note: no Claude projects found at {}; nothing to archive",
projects_dir.display()
);
return Ok(ArchiveResult::default());
}
let codex_dir = get_codex_dir()?;
fs::create_dir_all(&codex_dir)?;
let mut archived_ids = std::collections::HashSet::new();
if codex_dir.exists() {
for entry in fs::read_dir(&codex_dir)? {
let entry = entry?;
let manifest_path = entry.path().join("manifest.json");
if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path)?;
let manifest: Manifest = serde_json::from_str(&content)?;
archived_ids.insert(manifest.session_id);
}
}
}
let mut summary = ArchiveResult::default();
for entry in fs::read_dir(&projects_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
for file_entry in fs::read_dir(&path)? {
let file_entry = file_entry?;
let file_path = file_entry.path();
if file_path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("agent-") {
continue;
}
let session_id = name.trim_end_matches(".jsonl");
if archived_ids.contains(session_id) {
summary.skipped_count += 1;
continue;
}
println!("Archiving: {}", session_id);
let dir = archive_session(&file_path, clean, include_agents_in_clean_md, include)?;
summary.archive_paths.push(dir);
summary.archived_count += 1;
}
}
}
println!("Archived {} new session(s)", summary.archived_count);
Ok(summary)
}