blob: 1e330e20173ad07fee78bd6b680c0ac2740d7348 [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.
*/
use crate::{Error, jsonl, transcript::Timestamp};
use serde::{Deserialize, Serialize};
use serde_json::{Value, value::RawValue};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Row<'a> {
#[serde(rename = "type")]
pub kind: Kind,
pub uuid: Option<String>,
pub parent_uuid: Option<String>,
pub session_id: Option<String>,
pub timestamp: Option<Timestamp>,
pub cwd: Option<String>,
#[serde(default)]
pub is_sidechain: bool,
#[serde(default)]
pub is_meta: bool,
#[serde(default)]
pub is_compact_summary: bool,
#[serde(default)]
pub is_api_error_message: bool,
pub subtype: Option<Subtype>,
pub custom_title: Option<String>,
pub ai_title: Option<String>,
pub last_prompt: Option<String>,
pub summary: Option<String>,
pub title: Option<String>,
pub prompt: Option<String>,
#[serde(borrow)]
pub message: Option<&'a RawValue>,
}
impl Row<'_> {
pub fn message(&self, line: &jsonl::Line<'_>) -> Result<Message, Error> {
crate::jsonl::decode(
self.message
.ok_or(Error::Invalid("missing Claude message"))?,
line.number,
)
}
pub fn compacted(&self) -> bool {
matches!(self.subtype, Some(Subtype::Compact))
}
}
#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
pub(super) enum Kind {
#[serde(rename = "user")]
User,
#[serde(rename = "assistant")]
Assistant,
#[serde(rename = "ai-title")]
AiTitle,
#[serde(rename = "last-prompt")]
LastPrompt,
#[serde(other)]
Other,
}
#[derive(Deserialize)]
pub(super) enum Subtype {
#[serde(rename = "compact_boundary")]
Compact,
#[serde(other)]
Other,
}
#[derive(Deserialize, Serialize)]
pub(super) struct Message {
pub id: Option<String>,
pub model: Option<String>,
pub content: Content,
pub stop_reason: Option<StopReason>,
}
#[derive(Clone, Copy, Deserialize, Serialize)]
pub(super) enum StopReason {
#[serde(rename = "end_turn")]
EndTurn,
#[serde(rename = "stop_sequence")]
StopSequence,
#[serde(rename = "max_tokens")]
MaxTokens,
#[serde(other)]
Other,
}
#[derive(Deserialize, Serialize)]
#[serde(untagged)]
pub(super) enum Content {
Text(String),
Blocks(Vec<Block>),
}
#[derive(Deserialize, Serialize)]
#[serde(tag = "type")]
pub(super) enum Block {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "thinking")]
Thinking { thinking: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Option<Value>,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: Value,
#[serde(default)]
is_error: bool,
},
#[serde(rename = "image")]
Image,
#[serde(rename = "document")]
Document,
#[serde(other)]
Other,
}
impl Content {
pub fn has_results(&self) -> bool {
matches!(self, Self::Blocks(blocks) if blocks.iter().any(|block| matches!(block, Block::ToolResult { .. })))
}
pub fn text(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Blocks(blocks) => blocks
.iter()
.filter_map(|block| match block {
Block::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn user_text(&self) -> String {
let text = self.text();
if !text.is_empty() {
return text;
}
match self {
Self::Blocks(blocks) if blocks.iter().any(|block| matches!(block, Block::Image)) => {
"[Image]".into()
}
Self::Blocks(blocks) if blocks.iter().any(|block| matches!(block, Block::Document)) => {
"[Document]".into()
}
_ => text,
}
}
}
pub(crate) fn synthetic(text: &str) -> bool {
let text = text.trim_start();
if text.starts_with("[Request interrupted by user") {
return true;
}
let Some(tag) = text
.strip_prefix('<')
.map(|text| text.strip_prefix('/').unwrap_or(text))
else {
return false;
};
[
"command-name",
"command-message",
"command-args",
"command-contents",
"local-command-stdout",
"local-command-stderr",
"bash-input",
"bash-stdout",
"bash-stderr",
]
.iter()
.any(|name| {
tag.strip_prefix(name)
.is_some_and(|tail| tail.starts_with('>') || tail.starts_with(char::is_whitespace))
})
}