goosedump 0.8.2

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

//! Reader for Claude Code session transcripts.
//!
//! Claude Code stores one session per JSONL file under
//! `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`.
//!
//! Each line is a record; conversation turns carry a `message` field holding
//! Anthropic Messages API content blocks (`text`, `thinking`, `tool_use`,
//! and—on user turns—`tool_result`). Records are linked into a tree by `uuid`
//! and `parentUuid`, which feeds the lineage filtering shared across readers.

use crate::context::ir::{build_tool_call, build_tool_result, extract_text};
use crate::context::{ContextReader, for_each_jsonl_record};
use crate::message::{
    Context, ContextListing, ConversationMessage, Entry, Image, Part, ProviderId, Reasoning,
};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

pub struct ClaudeReader {
    file_path: PathBuf,
}

impl ClaudeReader {
    pub fn new(file_path: PathBuf) -> Self {
        Self { file_path }
    }
}

/// The session id is the file's path relative to the Claude projects root with
/// the `.jsonl` extension stripped — e.g. `-home-user-proj/<uuid>`, or for a
/// subagent sidechain `-home-user-proj/<uuid>/subagents/agent-<hex>`. The bare
/// file stem is only unique within a single project directory (subagents under
/// different sessions can repeat, and a session file can be named anything), so
/// qualifying it with the project path makes the `<provider>:<id>` tag unique
/// across the whole store. The rendered native `sessionId` uses just the final
/// component (see `render_claude`); resolution accepts either the full id or the
/// bare stem. Falls back to the bare stem if the file is not under the root.
fn session_id(file_path: &Path) -> String {
    let base = crate::resolver::claude_projects_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()
}

impl ContextReader for ClaudeReader {
    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 reader = BufReader::new(file);

        let mut sid = String::new();
        let mut agent_sid = String::new();
        let mut cwd = String::new();
        let mut timestamp = String::new();

        // Records do not begin with a fixed header line, so scan until the
        // first turn that carries the session metadata fields. The native
        // `sessionId` (the bare file stem for a main session, the parent
        // session's id for a subagent sidechain) and—when present—the
        // sidechain's own `agentId` identify the context.
        for line in reader.lines() {
            let line = line.with_context(|| format!("read {}", file_path.display()))?;
            if line.trim().is_empty() {
                continue;
            }
            let Ok(entry) = serde_json::from_str::<Value>(&line) else {
                continue;
            };
            if sid.is_empty()
                && let Some(value) = entry["sessionId"].as_str()
            {
                sid = value.to_string();
            }
            if agent_sid.is_empty()
                && let Some(value) = entry["agentId"].as_str()
            {
                agent_sid = value.to_string();
            }
            if cwd.is_empty()
                && let Some(value) = entry["cwd"].as_str()
            {
                cwd = value.to_string();
            }
            if timestamp.is_empty()
                && let Some(value) = entry["timestamp"].as_str()
            {
                timestamp = value.to_string();
            }
            if !sid.is_empty() && !cwd.is_empty() && !timestamp.is_empty() {
                break;
            }
        }

        let provider_id = ProviderId {
            label: None,
            cwd: PathBuf::from(&cwd),
            count: 0,
            from: if timestamp.is_empty() {
                None
            } else {
                DateTime::parse_from_rfc3339(&timestamp)
                    .ok()
                    .map(|dt| dt.with_timezone(&Utc))
            },
            until: None,
        };

        // A subagent sidechain is keyed `<parent-sessionId>/agent-<agentId>`
        // (both read from its records); a main session by its bare `sessionId`.
        // Fall back to the path-qualified stem only when records omit the id.
        let (id, parent_id) = if !sid.is_empty() && !agent_sid.is_empty() {
            (format!("{sid}/agent-{agent_sid}"), Some(sid))
        } else if !sid.is_empty() {
            (sid, None)
        } else {
            (session_id(file_path), None)
        };

        Ok(vec![ContextListing {
            id,
            provider_id,
            path: file_path.clone(),
            parent_id,
        }])
    }

    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();
        // `tool_result` blocks reference their call by `tool_use_id` only, so
        // remember the name advertised by each preceding `tool_use` block.
        let mut tool_names: std::collections::HashMap<String, String> =
            std::collections::HashMap::new();

        for_each_jsonl_record(&self.file_path, |line_num, entry_json| {
            if cwd.is_empty()
                && let Some(value) = entry_json["cwd"].as_str()
            {
                cwd = value.to_string();
            }

            let Some(message) = entry_json.get("message").filter(|m| m.is_object()) else {
                return;
            };

            let entry_id = match entry_json["uuid"].as_str() {
                Some(uuid) => uuid.to_string(),
                None => format!("claude-{line_num}"),
            };
            let parent_id = entry_json["parentUuid"].as_str().unwrap_or("").to_string();

            let (role, parts) = claude_build_message(message, &mut tool_names);
            messages.push(ConversationMessage::new(entry_id.clone(), role, parts));
            entries.push(Entry {
                id: entry_id,
                parent_id,
            });
        })?;

        Ok(Context {
            entries,
            messages,
            cwd: (!cwd.is_empty()).then_some(cwd),
        })
    }
}

fn claude_build_message(
    message: &Value,
    tool_names: &mut std::collections::HashMap<String, String>,
) -> (String, Vec<Part>) {
    let role = message["role"].as_str().unwrap_or("unknown");
    let content = message.get("content");

    if role == "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") => {
                        parts.push(Part::Reasoning(Reasoning {
                            text: block["thinking"].as_str().unwrap_or("").to_string(),
                            signature: block["signature"].as_str().unwrap_or("").to_string(),
                            redacted: false,
                        }));
                    }
                    Some("redacted_thinking") => {
                        parts.push(Part::Reasoning(Reasoning {
                            text: block["data"]
                                .as_str()
                                .or_else(|| block["thinking"].as_str())
                                .unwrap_or("")
                                .to_string(),
                            signature: block["signature"].as_str().unwrap_or("").to_string(),
                            redacted: true,
                        }));
                    }
                    Some("tool_use") => {
                        let name = block["name"].as_str().unwrap_or("");
                        let id = block["id"].as_str().unwrap_or("");
                        if !id.is_empty() {
                            tool_names.insert(id.to_string(), name.to_string());
                        }
                        parts.push(Part::ToolCall(build_tool_call(
                            id,
                            name,
                            block.get("input"),
                        )));
                    }
                    Some("image") => {
                        if let Some(image) = claude_image(block) {
                            parts.push(Part::Image(image));
                        }
                    }
                    _ => {}
                }
            }
        } else if let Some(Value::String(text)) = content {
            parts.push(Part::Text(text.clone()));
        }
        return ("assistant".to_string(), parts);
    }

    // User turns are either plain text or a batch of tool results.
    if let Some(Value::Array(blocks)) = content {
        for block in blocks {
            if block["type"].as_str() == Some("tool_result") {
                let tool_use_id = block["tool_use_id"].as_str().unwrap_or("");
                let tool_name = tool_names
                    .get(tool_use_id)
                    .map_or_else(|| "tool".to_string(), Clone::clone);
                let result_content = extract_text(block.get("content"));
                let is_error = block["is_error"].as_bool().unwrap_or(false);
                return (
                    role.to_string(),
                    vec![Part::ToolResult(build_tool_result(
                        tool_use_id,
                        tool_name,
                        result_content,
                        is_error,
                    ))],
                );
            }
        }
    }

    // Plain user turns: text, optionally alongside pasted images.
    let mut parts = Vec::new();
    if let Some(Value::Array(blocks)) = content {
        for block in blocks {
            if block["type"].as_str() == Some("image")
                && let Some(image) = claude_image(block)
            {
                parts.push(Part::Image(image));
            }
        }
    }
    let text = extract_text(content);
    if !text.is_empty() || parts.is_empty() {
        parts.insert(0, Part::Text(text));
    }
    (role.to_string(), parts)
}

/// An Anthropic `image` content block: a base64 `source` (or a URL reference).
fn claude_image(block: &Value) -> Option<Image> {
    let source = block.get("source")?;
    let data = source["data"].as_str().or_else(|| source["url"].as_str())?;
    Some(Image {
        mime_type: source["media_type"].as_str().unwrap_or("").to_string(),
        data: data.to_string(),
    })
}