goosedump 0.12.43

Browse, search, compact, and learn from coding-agent sessions
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

use crate::engine::context::ContextReader;
use crate::engine::context::ir::{build_tool_call, extract_text};
use crate::engine::message::{
    Context, ContextListing, ConversationMessage, Entry, Part, ProviderId, Reasoning,
    ToolResultData,
};
use chrono::{DateTime, Utc};
use rusqlite::Connection;
use serde_json::Value;
use std::path::{Path, PathBuf};

fn list_via_session(conn: &Connection, db_path: &Path) -> anyhow::Result<Vec<ContextListing>> {
    let sql = "SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, s.parent_id, \
               (SELECT COUNT(*) FROM message WHERE session_id = s.id) \
               FROM session s ORDER BY s.time_created DESC";
    let mut stmt = conn.prepare(sql)?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, String>(0)?,
            row.get::<_, Option<String>>(1)?,
            row.get::<_, Option<String>>(2)?,
            row.get::<_, Option<i64>>(3)?,
            row.get::<_, Option<i64>>(4)?,
            row.get::<_, Option<String>>(5)?,
            row.get::<_, i64>(6)?,
        ))
    })?;

    let mut contexts = Vec::new();
    for row in rows {
        let (id, directory, title, time_created, time_updated, parent_id, count) = row?;
        contexts.push(ContextListing {
            id,
            provider_id: ProviderId {
                label: title.filter(|t| !t.is_empty()),
                cwd: directory.map_or_else(PathBuf::new, PathBuf::from),
                count: count.try_into().unwrap_or(0),
                from: time_created.and_then(DateTime::<Utc>::from_timestamp_millis),
                until: time_updated.and_then(DateTime::<Utc>::from_timestamp_millis),
            },
            path: db_path.to_path_buf(),
            parent_id,
        });
    }
    Ok(contexts)
}

pub struct OpenCodeReader {
    db_path: PathBuf,
}

impl OpenCodeReader {
    pub fn new(db_path: PathBuf) -> Self {
        Self { db_path }
    }
}

impl ContextReader for OpenCodeReader {
    fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
        let conn = crate::engine::context::open_sqlite(&self.db_path)?;

        list_via_session(&conn, &self.db_path)
    }

    fn delete_context(&self, context_id: &str) -> anyhow::Result<()> {
        let mut conn = crate::engine::context::open_sqlite(&self.db_path)?;
        let tx = conn.transaction()?;
        tx.execute(
            "DELETE FROM message WHERE session_id = ?1",
            rusqlite::params![context_id],
        )?;
        tx.execute(
            "DELETE FROM part WHERE session_id = ?1",
            rusqlite::params![context_id],
        )?;
        tx.execute(
            "DELETE FROM session WHERE id = ?1",
            rusqlite::params![context_id],
        )?;
        tx.commit()?;
        Ok(())
    }

    fn read_context(&self, context_id: &str) -> anyhow::Result<Context> {
        let conn = crate::engine::context::open_sqlite(&self.db_path)?;

        let sql = "SELECT id, data FROM message WHERE session_id = ?1 ORDER BY time_created";
        let mut stmt = conn.prepare(sql)?;
        let rows = stmt.query_map(rusqlite::params![context_id], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })?;

        let mut messages_data: Vec<(String, Value)> = Vec::new();
        for row in rows {
            let (id, data_str) = row?;
            let data: Value = serde_json::from_str(&data_str)?;
            messages_data.push((id, data));
        }

        let part_sql =
            "SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY time_created";
        let mut part_stmt = conn.prepare(part_sql)?;
        let part_rows = part_stmt.query_map(rusqlite::params![context_id], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })?;

        let mut parts: Vec<(String, Value)> = Vec::new();
        for row in part_rows {
            let (msg_id, data_str) = row?;
            let data: Value = serde_json::from_str(&data_str)?;
            parts.push((msg_id, data));
        }

        let mut entries = Vec::new();
        let mut messages = Vec::new();
        let mut previous_entry_id = String::new();

        for (msg_id, msg_data) in &messages_data {
            let role = msg_data["role"].as_str().unwrap_or("unknown");
            let entry_id = msg_data["id"]
                .as_str()
                .map_or_else(|| msg_id.clone(), std::string::ToString::to_string);

            let msg_parts: Vec<&Value> = parts
                .iter()
                .filter(|(mid, _)| mid == msg_id)
                .map(|(_, data)| data)
                .collect();

            let (role, parts, tool_results) = build_opencode_message(role, &msg_parts);
            let mut message = ConversationMessage::new(entry_id.clone(), role, parts);
            message.model = msg_data["modelID"]
                .as_str()
                .or_else(|| msg_data["model"]["modelID"].as_str())
                .map(str::to_string);
            message.timestamp = msg_data["time"]["created"]
                .as_i64()
                .and_then(DateTime::<Utc>::from_timestamp_millis);
            messages.push(message);

            entries.push(Entry {
                id: entry_id.clone(),
                parent_id: previous_entry_id.clone(),
                native_data: None,
            });
            previous_entry_id.clone_from(&entry_id);

            for (index, result) in tool_results.into_iter().enumerate() {
                let result_id = format!("{entry_id}-result-{index}");
                messages.push(ConversationMessage::new(
                    result_id.clone(),
                    "tool",
                    vec![Part::ToolResult(result)],
                ));
                entries.push(Entry {
                    id: result_id.clone(),
                    parent_id: previous_entry_id,
                    native_data: None,
                });
                previous_entry_id = result_id;
            }
        }

        // Try to extract cwd from the session table if it exists.
        let cwd: Option<String> = conn
            .query_row(
                "SELECT directory FROM session WHERE id = ?1",
                rusqlite::params![context_id],
                |row| row.get::<_, Option<String>>(0),
            )
            .ok()
            .flatten();

        Ok(Context {
            entries,
            messages,
            cwd,
        })
    }
}

fn build_opencode_message(
    role: &str,
    parts: &[&Value],
) -> (String, Vec<Part>, Vec<ToolResultData>) {
    match role {
        "assistant" => {
            let mut out_parts = Vec::new();
            let mut tool_results = Vec::new();
            for part in parts {
                match part["type"].as_str() {
                    Some("text") => {
                        out_parts.push(Part::Text(part["text"].as_str().unwrap_or("").to_string()));
                    }
                    Some("reasoning") => {
                        if let Some(text) = part["text"].as_str() {
                            out_parts.push(Part::Reasoning(Reasoning::new(text)));
                        }
                    }
                    Some("tool") => {
                        let name = part["tool"].as_str().unwrap_or("");
                        let id = part["callID"].as_str().unwrap_or("");
                        let state = &part["state"];
                        let args = state.get("input");
                        out_parts.push(Part::ToolCall(build_tool_call(id, name, args)));
                        match state["status"].as_str() {
                            Some("completed") => tool_results.push(ToolResultData {
                                call_id: id.to_string(),
                                tool_name: name.to_string(),
                                content: state["output"].as_str().unwrap_or("").to_string(),
                                is_error: false,
                            }),
                            Some("error") => tool_results.push(ToolResultData {
                                call_id: id.to_string(),
                                tool_name: name.to_string(),
                                content: state["error"].as_str().unwrap_or("").to_string(),
                                is_error: true,
                            }),
                            _ => {}
                        }
                    }
                    _ => {}
                }
            }
            ("assistant".to_string(), out_parts, tool_results)
        }
        _ => (
            role.to_string(),
            vec![Part::Text(extract_text_from_parts(parts))],
            Vec::new(),
        ),
    }
}

fn extract_text_from_parts(parts: &[&Value]) -> String {
    let value = Value::Array(parts.iter().map(|v| (*v).clone()).collect());
    extract_text(Some(&value))
}