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

pub mod claude;
pub mod codex;
pub mod crush;
pub mod gemini;
pub mod goose;
pub mod ir;
pub mod jsonl;
pub mod opencode;

use crate::engine::message::{Context, ContextListing, ConversationMessage, Entry, Part};
use anyhow::Context as _;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;

pub trait ContextReader {
    fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>>;
    fn read_context(&self, context_id: &str) -> anyhow::Result<Context>;

    /// The single on-disk transcript file backing this reader, if any.
    /// File-based readers return `Some` and inherit the default
    /// [`delete_context`](ContextReader::delete_context); SQLite-backed
    /// readers return `None` and override `delete_context` themselves.
    fn backing_file(&self) -> Option<&Path> {
        None
    }

    /// Delete the backing store for `context_id`. The default removes the
    /// [`backing_file`](ContextReader::backing_file); SQLite-backed readers
    /// override this to delete their session rows instead.
    fn delete_context(&self, _context_id: &str) -> anyhow::Result<()> {
        let path = self.backing_file().ok_or(ContextError::NotFileBacked)?;
        fs::remove_file(path).with_context(|| format!("remove {}", path.display()))
    }
}

/// Parse each non-empty line of a JSONL file into a [`Value`] and pass it to
/// `f` with its zero-based line number. Read and parse failures carry
/// `path:line:` context.
pub fn for_each_jsonl_record(
    file_path: &Path,
    mut f: impl FnMut(usize, &Value),
) -> anyhow::Result<()> {
    let file =
        fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
    let mut reader = BufReader::new(file);

    // Decode each line lossily so a stray non-UTF-8 byte does not abort the read,
    // and skip a line that fails to parse (a corrupt or still-being-written
    // record) rather than dropping the rest of the session.
    let mut buf = Vec::new();
    let mut line_num = 0;
    loop {
        buf.clear();
        let read = reader
            .read_until(b'\n', &mut buf)
            .with_context(|| format!("{}: read error", file_path.display()))?;
        if read == 0 {
            break;
        }
        let line = String::from_utf8_lossy(&buf);
        let trimmed = line.trim();
        if !trimmed.is_empty()
            && let Ok(value) = serde_json::from_str::<Value>(trimmed)
        {
            f(line_num, &value);
        }
        line_num += 1;
    }

    Ok(())
}

/// Open a provider `SQLite` database with a busy timeout so a store the owning
/// app has locked is waited on briefly rather than failing outright.
pub fn open_sqlite(path: &Path) -> anyhow::Result<rusqlite::Connection> {
    let conn =
        rusqlite::Connection::open(path).with_context(|| format!("open {}", path.display()))?;
    let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
    Ok(conn)
}

/// Preserve a native message that batches multiple tool results by expanding
/// its ordered parts into linked IR messages. Existing renderers model one
/// result message per call, so this keeps every result addressable while the
/// final expanded message retains the native entry id for child links.
pub(crate) fn push_message(
    entries: &mut Vec<Entry>,
    messages: &mut Vec<ConversationMessage>,
    entry_id: String,
    parent_id: String,
    role: String,
    parts: Vec<Part>,
) {
    let result_count = parts
        .iter()
        .filter(|part| matches!(part, Part::ToolResult(_)))
        .count();
    if result_count < 2 {
        messages.push(ConversationMessage::new(entry_id.clone(), role, parts));
        entries.push(Entry {
            id: entry_id,
            parent_id,
            native_data: None,
        });
        return;
    }

    let last = parts.len().saturating_sub(1);
    let mut parent = parent_id;
    for (index, part) in parts.into_iter().enumerate() {
        let id = if index == last {
            entry_id.clone()
        } else {
            format!("{entry_id}-part-{index}")
        };
        messages.push(ConversationMessage::new(
            id.clone(),
            role.clone(),
            vec![part],
        ));
        entries.push(Entry {
            id: id.clone(),
            parent_id: parent,
            native_data: None,
        });
        parent = id;
    }
}

/// A canonical, id- and timestamp-free projection of a context's messages,
/// used by `import` to detect whether a destination provider already holds the
/// same conversation. Volatile correlation ids, entry ids, and timestamps are
/// excluded so the fingerprint survives a render/re-read round-trip (and a
/// cross-provider conversion), making a repeated import a no-op.
#[must_use]
pub fn content_fingerprint(ctx: &Context) -> String {
    let rows: Vec<Value> = ctx
        .messages
        .iter()
        .map(|m| {
            let parts: Vec<Value> = m.parts.iter().map(part_fingerprint).collect();
            json!([m.role, parts])
        })
        .collect();
    serde_json::to_string(&rows).unwrap_or_default()
}

/// Canonical, id- and signature-free fingerprint of a single content part.
fn part_fingerprint(part: &Part) -> Value {
    match part {
        Part::Text(text) => json!(["text", text]),
        Part::Reasoning(reasoning) => json!(["reasoning", reasoning.text]),
        Part::ToolCall(call) => json!(["tool_call", call.name, call.arguments]),
        Part::ToolResult(result) => {
            json!([
                "tool_result",
                result.tool_name,
                result.content,
                result.is_error
            ])
        }
        Part::Image(image) => json!(["image", image.mime_type, image.data]),
        Part::Bash(bash) => json!(["bash", bash.command, bash.output]),
        Part::Passthrough(passthrough) => json!(["passthrough", passthrough.kind]),
    }
}

pub fn active_lineage_ids(entries: &[Entry]) -> Vec<String> {
    let Some(active) = entries.last() else {
        return Vec::new();
    };
    let parent_map: HashMap<&str, &str> = entries
        .iter()
        .filter(|entry| !entry.parent_id.is_empty())
        .map(|entry| (entry.id.as_str(), entry.parent_id.as_str()))
        .collect();
    let mut active_ids = Vec::new();
    let mut visited = HashSet::new();
    let mut current = active.id.as_str();
    while visited.insert(current) {
        active_ids.push(current.to_string());
        let Some(parent) = parent_map.get(current).copied() else {
            break;
        };
        current = parent;
    }
    active_ids
}

pub fn filter_entries(entries: Vec<Entry>, ids: &[String]) -> Vec<Entry> {
    if ids.is_empty() {
        return entries;
    }
    let id_set: HashSet<&str> = ids.iter().map(std::string::String::as_str).collect();
    entries
        .into_iter()
        .filter(|entry| id_set.contains(entry.id.as_str()))
        .collect()
}

pub fn filter_messages(
    messages: Vec<ConversationMessage>,
    ids: &[String],
) -> Vec<ConversationMessage> {
    if ids.is_empty() {
        return messages;
    }
    let id_set: HashSet<&str> = ids.iter().map(std::string::String::as_str).collect();
    messages
        .into_iter()
        .filter(|m| id_set.contains(m.entry_id.as_str()))
        .collect()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextError {
    FromNotFound(String),
    BeforeNotFound(String),
    InvalidRange,
    NotFileBacked,
    EmptyFile,
    /// First non-empty line is not a session header for the given kind
    /// (e.g. `"session"`, `"Codex session"`, `"Gemini session"`).
    InvalidSessionHeader(&'static str),
    UnsupportedPiSessionVersion,
    MissingSessionId,
    MissingHeader,
}

impl std::fmt::Display for ContextError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::FromNotFound(id) => write!(f, "goosedump: --from '{id}' not found"),
            Self::BeforeNotFound(id) => write!(f, "goosedump: --before '{id}' not found"),
            Self::InvalidRange => write!(f, "goosedump: --from must precede --before"),
            Self::NotFileBacked => write!(f, "context deletion is not file-backed"),
            Self::EmptyFile => write!(f, "empty file"),
            Self::InvalidSessionHeader(kind) => {
                write!(f, "first line is not a {kind} header")
            }
            Self::UnsupportedPiSessionVersion => write!(f, "unsupported Pi session version"),
            Self::MissingSessionId => write!(f, "session header has no id"),
            Self::MissingHeader => write!(f, "session has no header"),
        }
    }
}

impl std::error::Error for ContextError {}

pub fn filter_context_range(
    mut context: Context,
    from: Option<&str>,
    before: Option<&str>,
) -> Result<Context, ContextError> {
    let start = match from {
        Some(entry_id) => context
            .entries
            .iter()
            .position(|entry| entry.id == entry_id)
            .ok_or_else(|| ContextError::FromNotFound(entry_id.to_string()))?,
        None => 0,
    };
    let end = match before {
        Some(entry_id) => context
            .entries
            .iter()
            .position(|entry| entry.id == entry_id)
            .ok_or_else(|| ContextError::BeforeNotFound(entry_id.to_string()))?,
        None => context.entries.len(),
    };
    if start > end {
        return Err(ContextError::InvalidRange);
    }

    context.entries = context.entries[start..end].to_vec();
    let ids: HashSet<&str> = context
        .entries
        .iter()
        .map(|entry| entry.id.as_str())
        .collect();
    context
        .messages
        .retain(|message| ids.contains(message.entry_id.as_str()));
    Ok(context)
}