use crate::context::ir::{build_tool_call, extract_text};
use crate::context::{ContextReader, for_each_jsonl_record};
use crate::message::{
BashOutput, Context, ContextListing, ConversationMessage, Entry, Image, Part, ProviderId,
Reasoning, ToolResultData,
};
use anyhow::Context as _;
use chrono::{DateTime, NaiveDateTime, Utc};
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
fn session_id(file_path: &Path) -> String {
let base = crate::resolver::pi_sessions_base();
let Ok(rel) = file_path.strip_prefix(&base) else {
return file_path.file_stem().map_or_else(
|| "unknown".to_string(),
|stem| stem.to_string_lossy().into_owned(),
);
};
let rel = rel.with_extension("");
if rel.as_os_str().is_empty() {
return "unknown".to_string();
}
rel.to_string_lossy().into_owned()
}
fn pi_start_from_filename(file_path: &Path) -> Option<DateTime<Utc>> {
let stem = file_path.file_stem()?.to_str()?;
let ts_part = stem.split('_').next()?;
NaiveDateTime::parse_from_str(ts_part, "%Y-%m-%dT%H-%M-%S-%3fZ")
.ok()
.map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc))
}
pub struct JsonlReader {
file_path: PathBuf,
}
impl JsonlReader {
pub fn new(file_path: PathBuf) -> Self {
Self { file_path }
}
}
impl ContextReader for JsonlReader {
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
let file_path = &self.file_path;
let file =
fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
let mut reader = BufReader::new(file);
let mut first_line = String::new();
reader
.read_line(&mut first_line)
.with_context(|| format!("read {}", file_path.display()))?;
let first_line = first_line.trim_end().to_string();
if first_line.is_empty() {
anyhow::bail!("empty file");
}
let session: Value = serde_json::from_str(&first_line)?;
if session["type"].as_str() != Some("session") {
anyhow::bail!("first line is not a session header");
}
let id = session["id"]
.as_str()
.map_or_else(|| session_id(file_path), str::to_string);
let cwd = session["cwd"].as_str().unwrap_or("").to_string();
Ok(vec![ContextListing {
id,
provider_id: ProviderId {
label: None,
cwd: PathBuf::from(&cwd),
count: 0,
from: pi_start_from_filename(file_path),
until: None,
},
path: file_path.clone(),
parent_id: None,
}])
}
fn delete_context(&self, _context_id: &str) -> anyhow::Result<()> {
fs::remove_file(&self.file_path)
.with_context(|| format!("remove {}", self.file_path.display()))
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let mut entries = Vec::new();
let mut messages = Vec::new();
let mut cwd = String::new();
for_each_jsonl_record(&self.file_path, |_line_num, entry_json| {
let entry_type = entry_json["type"].as_str().unwrap_or("").to_string();
if entry_type == "session" {
if cwd.is_empty()
&& let Some(value) = entry_json["cwd"].as_str()
{
cwd = value.to_string();
}
return;
}
let id = entry_json["id"].as_str().unwrap_or("").to_string();
let parent_id = entry_json["parentId"].as_str().unwrap_or("").to_string();
if let Some(raw_msg) = entry_json.get("message") {
let (role, parts) = jsonl_build_message(&entry_type, raw_msg);
messages.push(ConversationMessage::new(id.clone(), role, parts));
}
entries.push(Entry { id, parent_id });
})?;
Ok(Context {
entries,
messages,
cwd: (!cwd.is_empty()).then_some(cwd),
})
}
}
fn jsonl_build_message(msg_type: &str, msg: &Value) -> (String, Vec<Part>) {
let role = msg["role"].as_str().unwrap_or(msg_type).to_string();
let content = msg.get("content");
match role.as_str() {
"assistant" => {
let mut parts = Vec::new();
if let Some(Value::Array(blocks)) = content {
for block in blocks {
match block["type"].as_str() {
Some("text") => {
parts
.push(Part::Text(block["text"].as_str().unwrap_or("").to_string()));
}
Some("thinking" | "redacted_thinking") => {
if let Some(text) = block["text"].as_str() {
parts.push(Part::Reasoning(Reasoning::new(text)));
}
}
Some("image") => {
parts.push(Part::Image(jsonl_image(block)));
}
Some("toolCall" | "tool_use") => {
let name = block["name"].as_str().unwrap_or("");
let id = block["id"]
.as_str()
.or_else(|| block["toolCallId"].as_str());
let arguments = block.get("arguments").or_else(|| block.get("input"));
parts.push(Part::ToolCall(build_tool_call(
id.unwrap_or(""),
name,
arguments,
)));
}
_ => {}
}
}
}
("assistant".to_string(), parts)
}
"toolResult" | "bashExecution" => {
let content_str = extract_text(content);
if role == "bashExecution" {
let command = msg["command"].as_str().unwrap_or("").to_string();
(
"user".to_string(),
vec![Part::Bash(BashOutput {
command,
output: content_str,
})],
)
} else {
let tool_name = msg["toolName"].as_str().unwrap_or("").to_string();
let is_error = msg["isError"].as_bool().unwrap_or(false);
let call_id = msg["toolUseId"].as_str().unwrap_or("").to_string();
(
"user".to_string(),
vec![Part::ToolResult(ToolResultData {
call_id,
tool_name,
content: content_str,
is_error,
})],
)
}
}
_ => {
let mut parts = Vec::new();
if let Some(Value::Array(blocks)) = content {
for block in blocks {
if block["type"].as_str() == Some("image") {
parts.push(Part::Image(jsonl_image(block)));
}
}
}
let text = extract_text(content);
if !text.is_empty() || parts.is_empty() {
parts.insert(0, Part::Text(text));
}
(role, parts)
}
}
}
fn jsonl_image(block: &Value) -> Image {
Image {
mime_type: block["mimeType"].as_str().unwrap_or("").to_string(),
data: block["data"].as_str().unwrap_or("").to_string(),
}
}