use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use ene_rds_chat::{ChatLogSurface, ChatMessage, ChatSession, MessageBlock, ToolCall}; use ene_rds_core::RdsClient; use rusqlite::{Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::time::Duration; use tracing::{info, warn}; #[derive(Parser, Debug)] #[command(name = "ene-sync")] #[command(about = "Sync OpenCode sessions to ENE RDS")] struct Cli { #[command(subcommand)] command: Commands, #[arg(long, global = true)] db: Option, #[arg(long, global = true)] dsn: Option, #[arg(long, global = true)] embed: bool, } #[derive(Subcommand, Debug)] enum Commands { Sync { #[arg(long)] since: Option, }, Watch { #[arg(long, default_value = "60")] interval: u64, }, InitSchema, } fn default_db_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from("/home/allaun")) .join(".local/share/opencode/opencode.db") } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let cli = Cli::parse(); let db_path = cli.db.unwrap_or_else(default_db_path); let dsn = cli.dsn.unwrap_or_else(RdsClient::dsn_from_env); match cli.command { Commands::Sync { since } => cmd_sync(&db_path, &dsn, cli.embed, since).await, Commands::Watch { interval } => cmd_watch(&db_path, &dsn, cli.embed, interval).await, Commands::InitSchema => cmd_init_schema(&dsn).await, } } async fn cmd_sync( db_path: &PathBuf, dsn: &str, enable_embed: bool, since: Option, ) -> Result<()> { info!("opening opencode.db at {:?}", db_path); let sqlite = Connection::open(db_path)?; sqlite.busy_timeout(Duration::from_secs(5))?; info!("connecting to RDS"); let client = RdsClient::connect(dsn).await?; client.init_schema().await?; let chat = ChatLogSurface::new(client); chat.init_tables().await?; let embedder = if enable_embed { Some(Embedder::new()) } else { None }; let sessions = if let Some(ts) = since { sessions_since(&sqlite, ts)? } else { load_sessions(&sqlite)? }; let total = sessions.len(); let mut synced = 0; for (i, sess) in sessions.iter().enumerate() { info!("[{}/{}] syncing {}", i + 1, total, sess.id); let raw_msgs = messages_for_session(&sqlite, &sess.id)?; let mut chat_msgs = Vec::with_capacity(raw_msgs.len()); for (idx, raw) in raw_msgs.iter().enumerate() { let parts = parts_for_message(&sqlite, &raw.id)?; let cm = normalize_message(raw, &parts, idx as i32)?; chat_msgs.push(cm); } let mut chat_session = normalize_session(sess, &chat_msgs, None); if let Some(ref emb) = embedder { let text = format!( "{} {} {}", sess.title, sess.agent.as_deref().unwrap_or(""), sess.model.as_deref().unwrap_or("") ); if let Ok(v) = emb.embed(&text).await { chat_session.embedding = Some(v); } for cm in &mut chat_msgs { if !cm.text_content.is_empty() { if let Ok(v) = emb.embed(&cm.text_content).await { cm.embedding = Some(v); } } } } chat.delete_messages_for_session(&sess.id).await?; chat.upsert_session(&chat_session).await?; chat.upsert_messages(&sess.id, &chat_msgs).await?; synced += 1; } info!("sync complete: {} sessions", synced); Ok(()) } async fn cmd_watch(db_path: &PathBuf, dsn: &str, enable_embed: bool, interval: u64) -> Result<()> { let state_path = dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) .join("ene-sync/state.json"); if let Some(p) = state_path.parent() { let _ = std::fs::create_dir_all(p); } let mut last: i64 = std::fs::read_to_string(&state_path) .ok() .and_then(|s| s.trim().parse().ok()) .unwrap_or(0); info!("watch mode, last_synced={}", last); loop { if let Err(e) = cmd_sync(db_path, dsn, enable_embed, Some(last)).await { warn!("sync failed: {}", e); } let sqlite = Connection::open(db_path)?; if let Ok(Some(max)) = max_session_updated(&sqlite) { last = max; let _ = std::fs::write(&state_path, last.to_string()); } tokio::time::sleep(Duration::from_secs(interval)).await; } } async fn cmd_init_schema(dsn: &str) -> Result<()> { let client = RdsClient::connect(dsn).await?; client.init_schema().await?; let chat = ChatLogSurface::new(client); chat.init_tables().await?; println!("RDS chat schema initialized"); Ok(()) } // ─── SQLite source helpers ────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] struct OpenCodeSession { id: String, project_id: String, parent_id: Option, slug: String, directory: String, title: String, agent: Option, model: Option, time_created: i64, time_updated: i64, tokens_input: i64, tokens_output: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct OpenCodeMessage { id: String, session_id: String, time_created: i64, #[serde(rename = "role")] data_role: String, #[serde(default)] data: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] struct OpenCodePart { #[serde(rename = "type")] part_type: String, #[serde(default)] text: Option, #[serde(default)] tool: Option, #[serde(default, rename = "callID")] call_id: Option, #[serde(default)] input: Option, #[serde(default)] output: Option, #[serde(default)] is_error: Option, } fn load_sessions(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, project_id, parent_id, slug, directory, title, \ agent, model, time_created, time_updated, tokens_input, tokens_output \ FROM session ORDER BY time_created", )?; let rows = stmt.query_map([], |row| { Ok(OpenCodeSession { id: row.get(0)?, project_id: row.get(1)?, parent_id: row.get(2)?, slug: row.get(3)?, directory: row.get(4)?, title: row.get(5)?, agent: row.get(6)?, model: row.get(7)?, time_created: row.get(8)?, time_updated: row.get(9)?, tokens_input: row.get(10)?, tokens_output: row.get(11)?, }) })?; rows.collect::, _>>().map_err(|e| e.into()) } fn sessions_since(conn: &Connection, since_ms: i64) -> Result> { let mut stmt = conn.prepare( "SELECT id, project_id, parent_id, slug, directory, title, \ agent, model, time_created, time_updated, tokens_input, tokens_output \ FROM session WHERE time_updated > ?1 ORDER BY time_created", )?; let rows = stmt.query_map([since_ms], |row| { Ok(OpenCodeSession { id: row.get(0)?, project_id: row.get(1)?, parent_id: row.get(2)?, slug: row.get(3)?, directory: row.get(4)?, title: row.get(5)?, agent: row.get(6)?, model: row.get(7)?, time_created: row.get(8)?, time_updated: row.get(9)?, tokens_input: row.get(10)?, tokens_output: row.get(11)?, }) })?; rows.collect::, _>>().map_err(|e| e.into()) } fn max_session_updated(conn: &Connection) -> Result> { conn.query_row("SELECT MAX(time_updated) FROM session", [], |row| { row.get(0) }) .optional() .map_err(|e| e.into()) } fn messages_for_session(conn: &Connection, session_id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT id, session_id, time_created, data FROM message \ WHERE session_id = ?1 ORDER BY time_created, id", )?; let rows = stmt.query_map([session_id], |row| { let data_str: String = row.get(3)?; let data: serde_json::Value = serde_json::from_str(&data_str).unwrap_or(serde_json::Value::Null); let role = data .get("role") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); Ok(OpenCodeMessage { id: row.get(0)?, session_id: row.get(1)?, time_created: row.get(2)?, data_role: role, data, }) })?; rows.collect::, _>>().map_err(|e| e.into()) } fn parts_for_message(conn: &Connection, message_id: &str) -> Result> { let mut stmt = conn.prepare("SELECT data FROM part WHERE message_id = ?1 ORDER BY time_created, id")?; let rows = stmt.query_map([message_id], |row| { let data_str: String = row.get(0)?; let part: OpenCodePart = serde_json::from_str(&data_str).unwrap_or(OpenCodePart { part_type: "unknown".into(), text: None, tool: None, call_id: None, input: None, output: None, is_error: None, }); Ok(part) })?; rows.collect::, _>>().map_err(|e| e.into()) } fn normalize_session( sess: &OpenCodeSession, msgs: &[ChatMessage], _compaction: Option, ) -> ChatSession { ChatSession { session_id: sess.id.clone(), workspace_fingerprint: Some(workspace_fingerprint(&sess.directory)), workspace_root: Some(sess.directory.clone()), fork_parent_session_id: sess.parent_id.clone(), compaction_count: 0, compaction_summary: None, message_count: msgs.len() as i32, token_input_total: sess.tokens_input, token_output_total: sess.tokens_output, created_at_ms: sess.time_created, updated_at_ms: sess.time_updated, first_message_at_ms: msgs.first().map(|m| m.created_at_ms), last_message_at_ms: msgs.last().map(|m| m.created_at_ms), meta: serde_json::json!({ "slug": &sess.slug, "agent": &sess.agent, "model": &sess.model, "project_id": &sess.project_id, }), embedding: None, receipt: None, } } fn normalize_message( msg: &OpenCodeMessage, parts: &[OpenCodePart], index: i32, ) -> Result { let mut blocks = Vec::new(); let mut text_parts = Vec::new(); let mut tool_calls = Vec::new(); for part in parts { match part.part_type.as_str() { "text" => { if let Some(ref t) = part.text { text_parts.push(t.clone()); blocks.push(MessageBlock { block_type: "text".into(), text: Some(t.clone()), tool_name: None, tool_input: None, tool_output: None, is_error: None, }); } } "reasoning" => { if let Some(ref t) = part.text { text_parts.push(format!("[reasoning] {}", t)); blocks.push(MessageBlock { block_type: "reasoning".into(), text: Some(t.clone()), tool_name: None, tool_input: None, tool_output: None, is_error: None, }); } } "tool" => { let call_id = part.call_id.clone().unwrap_or_default(); let tool_name = part.tool.clone().unwrap_or_default(); blocks.push(MessageBlock { block_type: "tool_use".into(), text: None, tool_name: Some(tool_name.clone()), tool_input: part.input.clone(), tool_output: part.output.clone(), is_error: part.is_error, }); tool_calls.push(ToolCall { call_id: call_id.clone(), tool_name, input: part.input.clone().unwrap_or(serde_json::json!({})), }); } "tool-result" => { blocks.push(MessageBlock { block_type: "tool_result".into(), text: part.text.clone(), tool_name: part.tool.clone(), tool_input: None, tool_output: part.output.clone(), is_error: part.is_error, }); } _ => {} } } Ok(ChatMessage { session_id: msg.session_id.clone(), message_index: index, role: msg.data_role.clone(), blocks, text_content: text_parts.join("\n"), token_input: 0, token_output: 0, token_cache_creation: 0, token_cache_read: 0, tool_calls, embedding: None, receipt_hash: None, created_at_ms: msg.time_created, }) } fn workspace_fingerprint(path: &str) -> String { const FNV_OFFSET: u64 = 0xcbf29ce484222325; const FNV_PRIME: u64 = 0x100000001b3; let mut hash = FNV_OFFSET; for b in path.bytes() { hash ^= u64::from(b); hash = hash.wrapping_mul(FNV_PRIME); } format!("{:016x}", hash) } // ─── Embedding helper ─────────────────────────────────────────────────── struct Embedder { client: reqwest::Client, url: String, model: String, } impl Embedder { fn new() -> Self { let base = std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".into()); let model = std::env::var("OLLAMA_EMBED_MODEL").unwrap_or_else(|_| "nomic-embed-text".into()); Self { client: reqwest::Client::new(), url: format!("{}/api/embeddings", base.trim_end_matches('/')), model, } } async fn embed(&self, text: &str) -> Result> { let resp = self .client .post(&self.url) .json(&serde_json::json!({"model": self.model, "prompt": text})) .send() .await .context("embed POST")?; if !resp.status().is_success() { anyhow::bail!("embed HTTP {}", resp.status()); } let json: serde_json::Value = resp.json().await.context("embed JSON")?; let arr = json .get("embedding") .and_then(|v| v.as_array()) .context("missing embedding")?; Ok(arr .iter() .map(|v| v.as_f64().unwrap_or(0.0) as f32) .collect()) } }