blob: 2aadf909b1f5ca0386462964d002c8bdf75ffc45 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Claude JSONL lineage and response-fragment reconstruction. Every pass reads
//! the same pinned prefix and verifies its digest before returning any history.
mod lineage;
mod wire;
pub(crate) use wire::synthetic;
use crate::{
Error, Fingerprint, Transcript, jsonl,
transcript::{Records, identity, source_cwd, title},
};
use lineage::Index;
use maka_agent_facts::import::Content;
use maka_agent_facts::import::Record;
use maka_agent_facts::import::Source;
use maka_capabilities::filesystem::PinnedFile;
use std::{collections::BTreeMap, sync::Arc};
use wire::{Block, Kind, Message, Row, StopReason};
pub async fn read(file: &PinnedFile, expected_session_id: &str) -> Result<Transcript, Error> {
identity(expected_session_id)?;
let expected = expected_session_id.to_owned();
let length = file.info().length;
let id = expected.clone();
let (index, metadata, fingerprint) = file
.with_reader(move |reader| {
Ok((|| {
let mut index = Index::default();
let mut metadata = Metadata::default();
let fingerprint = jsonl::scan(reader, length, |line| {
let row: Row<'_> = line.decode()?;
metadata.accept(&row, &id)?;
index.accept(&row, &line)
})?;
if !metadata.matched {
return Err(Error::Invalid("Claude session identity is missing"));
}
Ok((Arc::new(index.resolve()), metadata, fingerprint))
})())
})
.await??;
let selection = index.clone();
let expected_fingerprint = fingerprint.clone();
let responses = file
.with_reader(move |reader| {
Ok((|| {
let mut responses = Responses::default();
let observed = jsonl::scan(reader, length, |line| {
let row: Row<'_> = line.decode()?;
if selection.keep(&row, line.number) && row.kind == Kind::Assistant {
responses.push(line.number, row.message(&line)?)?;
}
Ok(())
})?;
unchanged(&expected_fingerprint, &observed)?;
Ok::<_, Error>(responses)
})())
})
.await??;
let expected_fingerprint = fingerprint.clone();
let records = file
.with_reader(move |reader| {
Ok((|| {
let mut converter = Converter {
responses,
records: Records::default(),
turn: None,
calls: BTreeMap::new(),
};
let observed = jsonl::scan(reader, length, |line| {
let row: Row<'_> = line.decode()?;
if index.keep(&row, line.number) {
converter.accept(&row, &line)?;
}
Ok(())
})?;
unchanged(&expected_fingerprint, &observed)?;
converter.records.finish()
})())
})
.await??;
let title = metadata
.title
.or_else(|| {
records.iter().find_map(|record| match &record.content {
Content::User { text } if !text.trim().is_empty() => Some(title(text)),
_ => None,
})
})
.unwrap_or_else(|| expected.clone());
Ok(Transcript {
source: Source {
adapter: "claude-code".into(),
session_id: expected,
},
cwd: metadata.cwd,
title,
records,
fingerprint,
})
}
fn unchanged(expected: &Fingerprint, observed: &Fingerprint) -> Result<(), Error> {
if expected == observed {
Ok(())
} else {
Err(Error::Invalid("Claude source changed between read passes"))
}
}
#[derive(Default)]
struct Metadata {
matched: bool,
cwd: Option<String>,
title: Option<String>,
priority: u8,
}
impl Metadata {
fn accept(&mut self, row: &Row<'_>, expected: &str) -> Result<(), Error> {
if row.is_sidechain {
return Err(Error::Invalid(
"Claude subagent sidechains are not root sessions",
));
}
if let Some(id) = &row.session_id {
if id != expected {
return Err(Error::Invalid(
"Claude session identity does not match the selected session",
));
}
self.matched = true;
}
if self.cwd.is_none() {
self.cwd = source_cwd(row.cwd.clone());
}
for (priority, text) in [
(4, row.custom_title.as_ref()),
(
3,
row.ai_title.as_ref().or_else(|| {
(row.kind == Kind::AiTitle)
.then_some(row.title.as_ref())
.flatten()
}),
),
(
2,
row.last_prompt.as_ref().or_else(|| {
(row.kind == Kind::LastPrompt)
.then_some(row.prompt.as_ref())
.flatten()
}),
),
(1, row.summary.as_ref()),
] {
if priority >= self.priority
&& let Some(text) = text.filter(|text| !text.trim().is_empty())
{
self.title = Some(title(text));
self.priority = priority;
}
}
Ok(())
}
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum ResponseKey {
Id(String),
Line(u64),
}
impl ResponseKey {
fn new(message: &Message, line: u64) -> Result<Self, Error> {
match &message.id {
Some(id) => {
identity(id)?;
Ok(Self::Id(id.clone()))
}
None => Ok(Self::Line(line)),
}
}
}
#[derive(Default)]
struct Responses {
entries: BTreeMap<ResponseKey, Vec<(u64, Message)>>,
}
impl Responses {
fn push(&mut self, line: u64, message: Message) -> Result<(), Error> {
self.entries
.entry(ResponseKey::new(&message, line)?)
.or_default()
.push((line, message));
Ok(())
}
}
struct Call {
key: String,
turn: String,
completed: bool,
}
struct Converter {
responses: Responses,
records: Records,
turn: Option<String>,
calls: BTreeMap<String, Call>,
}
impl Converter {
fn accept(&mut self, row: &Row<'_>, line: &jsonl::Line<'_>) -> Result<(), Error> {
let timestamp = row.timestamp.as_ref().and_then(|time| time.millis());
let source = row
.uuid
.clone()
.unwrap_or_else(|| format!("line:{}", line.number));
if row.compacted() {
self.append(
&source,
line.number,
timestamp,
Content::Note {
text: "Source context compacted".into(),
},
)?;
}
match row.kind {
Kind::User => {
let message = row.message(line)?;
if message.content.has_results() {
let wire::Content::Blocks(blocks) = message.content else {
unreachable!()
};
for (part, block) in blocks.into_iter().enumerate() {
if let Block::ToolResult {
tool_use_id,
content,
is_error,
} = block
{
identity(&tool_use_id)?;
let (key, turn) = match self.calls.get_mut(&tool_use_id) {
Some(call) if !call.completed => {
call.completed = true;
(call.key.clone(), call.turn.clone())
}
Some(_) => {
return Err(Error::Invalid("duplicate Claude tool result"));
}
None => (
format!("unmatched:{}:{part}", line.number),
self.turn(line.number),
),
};
self.records.push(Record {
source_message_id: source.clone(),
source_turn_id: turn,
timestamp,
content: Content::ToolResult {
call_id: key,
output: content,
is_error,
},
})?;
}
}
} else {
let text = message.content.user_text();
if !text.is_empty() {
let content =
if row.is_meta || row.is_compact_summary || wire::synthetic(&text) {
Content::Note { text }
} else {
self.turn = Some(source.clone());
Content::User { text }
};
self.append(&source, line.number, timestamp, content)?;
}
}
}
Kind::Assistant => {
let message = row.message(line)?;
let stop = message.stop_reason;
let key = ResponseKey::new(&message, line.number)?;
if let Some(fragments) = self.responses.entries.remove(&key) {
self.assistant(&source, line.number, timestamp, fragments)?;
}
// Terminal observations stay at their actual source position.
// They are never synthesized into local execution end facts.
if row.is_api_error_message {
self.append(
&source,
line.number,
timestamp,
Content::Note {
text: "Source reported an API error".into(),
},
)?;
} else if let Some(stop) = stop {
let text = match stop {
StopReason::EndTurn | StopReason::StopSequence => {
Some("Source turn completed")
}
StopReason::MaxTokens => Some("Source response reached its output limit"),
StopReason::Other => None,
};
if let Some(text) = text {
self.append(
&source,
line.number,
timestamp,
Content::Note { text: text.into() },
)?;
}
}
}
_ => {}
}
Ok(())
}
fn assistant(
&mut self,
source: &str,
line: u64,
timestamp: Option<u64>,
fragments: Vec<(u64, Message)>,
) -> Result<(), Error> {
let mut text = Vec::new();
let mut thinking = Vec::new();
let mut calls = Vec::new();
let mut model = None;
for (fragment_line, fragment) in fragments {
if let Some(value) = fragment.model {
if model.as_ref().is_some_and(|model| model != &value) {
return Err(Error::Invalid(
"Claude response fragments disagree about their model",
));
}
model = Some(value);
}
match fragment.content {
wire::Content::Text(value) => {
if !value.is_empty() {
text.push(value);
}
}
wire::Content::Blocks(blocks) => {
for (part, block) in blocks.into_iter().enumerate() {
match block {
Block::Text { text: value } => {
if !value.is_empty() {
text.push(value);
}
}
Block::Thinking { thinking: value } => {
if !value.is_empty() {
thinking.push(value);
}
}
Block::ToolUse { id, name, input } => {
calls.push((fragment_line, part, id, name, input))
}
_ => {}
}
}
}
}
}
if !text.is_empty() || !thinking.is_empty() {
self.append(
source,
line,
timestamp,
Content::Assistant {
text: text.join("\n\n"),
model,
thinking: (!thinking.is_empty()).then(|| thinking.join("\n\n")),
},
)?;
}
for (fragment_line, part, id, name, input) in calls {
identity(&id)?;
identity(&name)?;
if self.calls.get(&id).is_some_and(|call| !call.completed) {
return Err(Error::Invalid("ambiguous overlapping Claude tool calls"));
}
let key = format!("call:{fragment_line}:{part}");
let turn = self.turn(line);
self.calls.insert(
id,
Call {
key: key.clone(),
turn,
completed: false,
},
);
self.append(
source,
line,
timestamp,
Content::ToolCall {
call_id: key,
name,
input,
},
)?;
}
Ok(())
}
fn turn(&mut self, line: u64) -> String {
self.turn
.get_or_insert_with(|| format!("turn:{line}"))
.clone()
}
fn append(
&mut self,
source: &str,
line: u64,
timestamp: Option<u64>,
content: Content,
) -> Result<(), Error> {
let source_turn_id = self.turn(line);
self.records.push(Record {
source_message_id: source.into(),
source_turn_id,
timestamp,
content,
})
}
}