use std::fs;
use std::path::Path;
use anyhow::Context as _;
use chrono::{Duration, Utc};
use rusqlite::OptionalExtension as _;
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::engine::Client;
use crate::engine::message::Context;
use crate::engine::resolver;
pub(crate) struct Importer<'a> {
pub(crate) client: Client,
pub(crate) ctx: &'a Context,
}
impl TryFrom<Importer<'_>> for String {
type Error = anyhow::Error;
fn try_from(imp: Importer<'_>) -> anyhow::Result<String> {
let Importer { client, ctx } = imp;
client.behavior().import_context(ctx)
}
}
fn new_id() -> String {
Uuid::new_v4().to_string()
}
fn rendered(client: Client, ctx: &Context, id: &str) -> String {
client
.behavior()
.render_context(&ctx.entries, &ctx.messages, id, ctx.cwd.as_deref())
}
fn context_cwd(ctx: &Context) -> String {
ctx.cwd.clone().unwrap_or_else(|| {
std::env::current_dir().map_or_else(|_| String::new(), |p| p.display().to_string())
})
}
fn write_file(path: &Path, contents: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension(format!("tmp.{}", Uuid::new_v4()));
fs::write(&tmp, contents).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
}
fn stamp(base: i64, idx: usize) -> i64 {
base.saturating_add(i64::try_from(idx).unwrap_or_default())
}
pub(crate) fn write_claude(ctx: &Context) -> anyhow::Result<String> {
let id = new_id();
let body = rendered(Client::Claude, ctx, &id);
let path = resolver::claude_projects_base()
.join(encode_project_dir(&context_cwd(ctx)))
.join(format!("{id}.jsonl"));
write_file(&path, &body)?;
Ok(id)
}
fn encode_project_dir(cwd: &str) -> String {
cwd.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
pub(crate) fn write_codex(ctx: &Context) -> anyhow::Result<String> {
let id = new_id();
let body = rendered(Client::Codex, ctx, &id);
let now = Utc::now();
let path = resolver::codex_sessions_base()
.join(now.format("%Y").to_string())
.join(now.format("%m").to_string())
.join(now.format("%d").to_string())
.join(format!(
"rollout-{}-{id}.jsonl",
now.format("%Y-%m-%dT%H-%M-%S")
));
write_file(&path, &body)?;
Ok(id)
}
pub(crate) fn write_pi(ctx: &Context) -> anyhow::Result<String> {
let id = new_id();
let body = rendered(Client::Pi, ctx, &id);
let base = resolver::pi_sessions_base();
let dir = if std::env::var("PI_CODING_AGENT_SESSION_DIR").is_ok() {
base
} else {
base.join(encode_pi_project_dir(&context_cwd(ctx)))
};
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S-%3fZ");
let path = dir.join(format!("{timestamp}_{id}.jsonl"));
write_file(&path, &body)?;
Ok(id)
}
fn encode_pi_project_dir(cwd: &str) -> String {
let stripped = cwd.strip_prefix(['/', '\\']).unwrap_or(cwd);
let encoded: String = stripped
.chars()
.map(|c| {
if matches!(c, '/' | '\\' | ':') {
'-'
} else {
c
}
})
.collect();
format!("--{encoded}--")
}
pub(crate) fn write_gemini(ctx: &Context) -> anyhow::Result<String> {
let id = new_id();
let stem = format!("session-{id}");
let hash = gemini_project_hash(&context_cwd(ctx));
let now = Utc::now();
let mut records = rendered(Client::Gemini, ctx, &id)
.lines()
.map(serde_json::from_str::<Value>)
.collect::<Result<Vec<_>, _>>()
.context("re-parse gemini render")?;
let last = now + Duration::milliseconds(i64::try_from(records.len().saturating_sub(1))?);
let timestamp =
|value: chrono::DateTime<Utc>| value.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let header = records.first_mut().context("gemini render has no header")?;
header["projectHash"] = Value::String(hash.clone());
header["startTime"] = Value::String(timestamp(now));
header["lastUpdated"] = Value::String(timestamp(last));
for (index, record) in records.iter_mut().enumerate().skip(1) {
let offset = Duration::milliseconds(i64::try_from(index)?);
record["timestamp"] = Value::String(timestamp(now + offset));
}
let body = records
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()?
.join("\n")
+ "\n";
let path = resolver::gemini_tmp_base()
.join(&hash)
.join("chats")
.join(format!("{stem}.jsonl"));
write_file(&path, &body)?;
Ok(stem)
}
pub fn gemini_project_hash(cwd: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(cwd.as_bytes());
format!("{:x}", hasher.finalize())
}
pub(crate) fn write_goose(ctx: &Context) -> anyhow::Result<String> {
let id = new_id();
let doc: Value =
serde_json::from_str(&rendered(Client::Goose, ctx, &id)).context("goose render")?;
let mut conn = crate::engine::context::open_sqlite(&resolver::resolve_goose_db()?)?;
let tx = conn.transaction()?;
let session = &doc["session"];
let now = Utc::now();
tx.execute(
"INSERT INTO sessions(id, title, working_dir, created_at, updated_at, message_count) \
VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
id,
session["name"].as_str().unwrap_or(""),
session["working_dir"].as_str().unwrap_or(""),
now.timestamp_millis(),
now.timestamp_millis(),
i64::try_from(array(&doc, "messages").len()).unwrap_or(0),
],
)?;
let base = now.timestamp_millis();
for (idx, row) in array(&doc, "messages").iter().enumerate() {
tx.execute(
"INSERT INTO messages(message_id, session_id, role, content_json, created_timestamp) \
VALUES(?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
row["message_id"].as_str().unwrap_or(""),
id,
row["role"].as_str().unwrap_or(""),
serde_json::to_string(&row["content_json"])?,
stamp(base, idx),
],
)?;
}
tx.commit()?;
Ok(id)
}
pub(crate) fn write_opencode(ctx: &Context) -> anyhow::Result<String> {
let id = format!("ses_{}", new_id());
let doc: Value =
serde_json::from_str(&rendered(Client::Opencode, ctx, &id)).context("opencode render")?;
let mut conn = crate::engine::context::open_sqlite(&resolver::resolve_opencode_db()?)?;
let tx = conn.transaction()?;
let now = Utc::now().timestamp_millis();
let directory = doc["session"]["directory"].as_str().unwrap_or("");
let project_id = tx
.query_row(
"SELECT id FROM project WHERE worktree = ?1 ORDER BY time_updated DESC LIMIT 1",
[directory],
|row| row.get::<_, String>(0),
)
.optional()?;
let project_id = if let Some(project_id) = project_id {
project_id
} else {
let project_id = new_id();
tx.execute(
"INSERT INTO project(id, worktree, name, time_created, time_updated, sandboxes) \
VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![project_id, directory, "", now, now, "[]"],
)?;
project_id
};
tx.execute(
"INSERT INTO session(\
id, project_id, slug, directory, title, version, time_created, time_updated\
) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![id, project_id, id, directory, "", "goosedump", now, now],
)?;
for (idx, row) in array(&doc, "messages").iter().enumerate() {
tx.execute(
"INSERT INTO message(id, session_id, time_created, time_updated, data) VALUES(?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
row["id"].as_str().unwrap_or(""),
id,
stamp(now, idx),
stamp(now, idx),
serde_json::to_string(&row["data"])?
],
)?;
}
for (idx, row) in array(&doc, "parts").iter().enumerate() {
tx.execute(
"INSERT INTO part(id, message_id, session_id, time_created, time_updated, data) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
row["id"]
.as_str()
.map_or_else(|| format!("prt_{}", new_id()), str::to_string),
row["message_id"].as_str().unwrap_or(""),
id,
stamp(now, idx),
stamp(now, idx),
serde_json::to_string(&row["data"])?
],
)?;
}
tx.commit()?;
Ok(id)
}
pub(crate) fn write_crush(ctx: &Context) -> anyhow::Result<String> {
let id = new_id();
let doc: Value =
serde_json::from_str(&rendered(Client::Crush, ctx, &id)).context("crush render")?;
let mut conn = crate::engine::context::open_sqlite(&resolver::resolve_crush_db()?)?;
let tx = conn.transaction()?;
let now = Utc::now().timestamp();
let count = i64::try_from(array(&doc, "messages").len())?;
let updated_at = now.saturating_add(count.saturating_sub(1));
tx.execute(
"INSERT INTO sessions(id, title, message_count, created_at, updated_at) VALUES(?1, ?2, ?3, ?4, ?5)",
rusqlite::params![id, "", count, now, updated_at],
)?;
for (index, row) in array(&doc, "messages").iter().enumerate() {
tx.execute(
"INSERT INTO messages(id, session_id, role, parts, created_at, updated_at) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
new_id(),
id,
row["role"].as_str().unwrap_or(""),
serde_json::to_string(&row["parts"])?,
now.saturating_add(i64::try_from(index)?),
now.saturating_add(i64::try_from(index)?),
],
)?;
}
tx.commit()?;
Ok(id)
}
fn array<'a>(doc: &'a Value, key: &str) -> &'a [Value] {
doc[key].as_array().map_or(&[], Vec::as_slice)
}