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>;
fn backing_file(&self) -> Option<&Path> {
None
}
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()))
}
}
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);
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(())
}
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)
}
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;
}
}
#[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()
}
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,
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)
}