use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use regex::Regex;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::Path;
use super::db::{MessageInsert, SessionInsert, ToolCallInsert};
const FORK_THRESHOLD: usize = 3;
const MAX_FIRST_MESSAGE_LEN: usize = 300;
#[derive(Debug, Clone)]
pub struct ParseResult {
pub session: SessionInsert,
pub messages: Vec<MessageInsert>,
}
#[derive(Debug, Clone)]
struct DagEntry {
uuid: String,
parent_uuid: String,
entry_type: String, line: String, timestamp: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
struct ParsedToolCall {
tool_use_id: String,
tool_name: String,
category: String,
input_json: Option<String>,
skill_name: Option<String>,
subagent_session_id: Option<String>,
}
#[derive(Debug, Clone)]
struct ParsedToolResult {
#[allow(dead_code)]
tool_use_id: String,
#[allow(dead_code)]
content_length: i64,
#[allow(dead_code)]
content_raw: Option<String>,
}
struct ExtractedContent {
text: String,
has_thinking: bool,
has_tool_use: bool,
tool_calls: Vec<ParsedToolCall>,
tool_results: Vec<ParsedToolResult>,
}
pub fn parse_claude_session(
path: &Path,
project: &str,
machine: &str,
) -> Result<Vec<ParseResult>> {
let metadata = std::fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?;
let file_size = metadata.len() as i64;
let file_mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let content = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let file_hash = compute_sha256(&content);
let mut entries: Vec<DagEntry> = Vec::new();
let mut has_any_uuid = false;
let mut all_have_uuid = true;
let mut parent_session_id: Option<String> = None;
let mut found_parent_sid = false;
let mut subagent_map: HashMap<String, String> = HashMap::new();
let mut global_start: Option<DateTime<Utc>> = None;
let mut global_end: Option<DateTime<Utc>> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(ts) = extract_timestamp(&v) {
update_bounds(&mut global_start, &mut global_end, ts);
}
let entry_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if entry_type == "queue-operation" {
if v.get("operation").and_then(|o| o.as_str()) == Some("enqueue") {
collect_subagent_from_enqueue(&v, &mut subagent_map);
}
continue;
}
if entry_type == "progress" {
if v.pointer("/data/type").and_then(|t| t.as_str()) == Some("agent_progress") {
let tuid = v
.get("parentToolUseID")
.and_then(|t| t.as_str())
.unwrap_or("");
let agent_id = v
.pointer("/data/agentId")
.and_then(|a| a.as_str())
.unwrap_or("");
if !tuid.is_empty() && !agent_id.is_empty() {
subagent_map
.insert(tuid.to_string(), format!("agent-{}", agent_id));
}
}
continue;
}
if entry_type != "user" && entry_type != "assistant" {
continue;
}
if !found_parent_sid {
if let Some(sid) = v.get("sessionId").and_then(|s| s.as_str()) {
found_parent_sid = true;
if !sid.is_empty() && sid != session_id {
parent_session_id = Some(sid.to_string());
}
}
}
let uuid = v
.get("uuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let parent_uuid = v
.get("parentUuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
if !uuid.is_empty() {
has_any_uuid = true;
} else {
all_have_uuid = false;
}
let ts = extract_timestamp(&v);
entries.push(DagEntry {
uuid,
parent_uuid,
entry_type: entry_type.to_string(),
line: line.to_string(),
timestamp: ts,
});
}
let file_info = (
path.to_string_lossy().to_string(),
file_size,
file_mtime,
file_hash,
);
if has_any_uuid && all_have_uuid {
parse_dag(
&entries,
&session_id,
project,
machine,
parent_session_id.as_deref(),
&file_info,
&subagent_map,
global_start,
global_end,
)
} else {
parse_linear(
&entries,
&session_id,
project,
machine,
parent_session_id.as_deref(),
&file_info,
&subagent_map,
global_start,
global_end,
)
}
}
pub fn parse_claude_session_from(
path: &Path,
offset: i64,
start_ordinal: i64,
session_id: &str,
) -> Result<(Vec<MessageInsert>, Option<String>, i64)> {
let file = std::fs::File::open(path)
.with_context(|| format!("open {}", path.display()))?;
let mut reader = BufReader::new(file);
reader
.seek(SeekFrom::Start(offset as u64))
.with_context(|| format!("seek {} to offset {}", path.display(), offset))?;
let mut entries: Vec<DagEntry> = Vec::new();
let mut latest_ts: Option<DateTime<Utc>> = None;
let mut bytes_consumed: i64 = 0;
let mut line_buf = String::new();
loop {
line_buf.clear();
let bytes_read = reader.read_line(&mut line_buf)?;
if bytes_read == 0 {
break;
}
let line = line_buf.trim();
if line.is_empty() {
bytes_consumed += bytes_read as i64;
continue;
}
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
break;
}
};
bytes_consumed += bytes_read as i64;
if let Some(ts) = extract_timestamp(&v) {
if latest_ts.map_or(true, |l| ts > l) {
latest_ts = Some(ts);
}
}
let entry_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if entry_type != "user" && entry_type != "assistant" {
continue;
}
let uuid = v
.get("uuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let parent_uuid = v
.get("parentUuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let ts = extract_timestamp(&v);
entries.push(DagEntry {
uuid,
parent_uuid,
entry_type: entry_type.to_string(),
line: line.to_string(),
timestamp: ts,
});
}
if entries.is_empty() {
let ended_at = latest_ts.map(|ts| ts.to_rfc3339());
return Ok((Vec::new(), ended_at, bytes_consumed));
}
if has_dag_fork(&entries) {
anyhow::bail!("DAG fork detected in incremental data");
}
let (messages, ended_at) =
extract_messages_from(&entries, start_ordinal, session_id);
let ended_at_str = latest_ts
.or(ended_at)
.map(|ts| ts.to_rfc3339());
Ok((messages, ended_at_str, bytes_consumed))
}
pub fn extract_project_hints(path: &Path) -> (Option<String>, Option<String>) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return (None, None),
};
let mut cwd: Option<String> = None;
let mut git_branch: Option<String> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(_) => continue,
};
if v.get("type").and_then(|t| t.as_str()) != Some("user") {
continue;
}
if cwd.is_none() {
if let Some(c) = v.get("cwd").and_then(|c| c.as_str()) {
if !c.is_empty() {
cwd = Some(c.to_string());
}
}
}
if git_branch.is_none() {
if let Some(b) = v.get("gitBranch").and_then(|b| b.as_str()) {
if !b.is_empty() {
git_branch = Some(b.to_string());
}
}
}
if cwd.is_some() && git_branch.is_some() {
break;
}
}
(cwd, git_branch)
}
fn parse_dag(
entries: &[DagEntry],
session_id: &str,
project: &str,
machine: &str,
parent_session_id: Option<&str>,
file_info: &(String, i64, i64, String),
subagent_map: &HashMap<String, String>,
global_start: Option<DateTime<Utc>>,
global_end: Option<DateTime<Utc>>,
) -> Result<Vec<ParseResult>> {
let mut children: HashMap<String, Vec<usize>> = HashMap::new();
let mut uuid_set: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut roots: Vec<usize> = Vec::new();
for (i, e) in entries.iter().enumerate() {
if !e.uuid.is_empty() {
uuid_set.insert(e.uuid.clone());
}
if e.parent_uuid.is_empty() {
roots.push(i);
} else {
children
.entry(e.parent_uuid.clone())
.or_default()
.push(i);
}
}
if roots.len() != 1 {
return parse_linear(
entries,
session_id,
project,
machine,
parent_session_id,
file_info,
subagent_map,
global_start,
global_end,
);
}
for e in entries {
if !e.parent_uuid.is_empty() && !uuid_set.contains(&e.parent_uuid) {
return parse_linear(
entries,
session_id,
project,
machine,
parent_session_id,
file_info,
subagent_map,
global_start,
global_end,
);
}
}
struct Branch {
indices: Vec<usize>,
parent_id: String,
}
let mut all_branches: Vec<Branch> = Vec::new();
let mut fork_branches: Vec<Branch> = Vec::new();
fn walk_branch(
start_idx: usize,
owner_id: &str,
entries: &[DagEntry],
children: &HashMap<String, Vec<usize>>,
session_id: &str,
fork_branches: &mut Vec<Branch>,
) -> Vec<usize> {
let mut path = Vec::new();
let mut current = Some(start_idx);
while let Some(idx) = current {
path.push(idx);
let uuid = &entries[idx].uuid;
let kids = children.get(uuid.as_str());
match kids {
None => break,
Some(kids) if kids.is_empty() => break,
Some(kids) if kids.len() == 1 => {
current = Some(kids[0]);
}
Some(kids) => {
let first_child_turns =
count_user_turns(entries, children, kids[0]);
if first_child_turns <= FORK_THRESHOLD {
current = Some(*kids.last().unwrap());
} else {
for &kid in &kids[1..] {
let fork_sid =
format!("{}-{}", session_id, entries[kid].uuid);
let fork_path = walk_branch(
kid,
&fork_sid,
entries,
children,
session_id,
fork_branches,
);
fork_branches.push(Branch {
indices: fork_path,
parent_id: owner_id.to_string(),
});
}
current = Some(kids[0]);
}
}
}
}
path
}
let main_path = walk_branch(
roots[0],
session_id,
entries,
&children,
session_id,
&mut fork_branches,
);
all_branches.push(Branch {
indices: main_path,
parent_id: parent_session_id.unwrap_or("").to_string(),
});
all_branches.append(&mut fork_branches);
let mut results = Vec::new();
for (i, branch) in all_branches.iter().enumerate() {
let branch_entries: Vec<DagEntry> = branch
.indices
.iter()
.map(|&idx| entries[idx].clone())
.collect();
let sid = if i == 0 {
session_id.to_string()
} else {
format!("{}-{}", session_id, entries[branch.indices[0]].uuid)
};
let (mut messages, started_at, ended_at) =
extract_messages_with_times(&branch_entries, &sid);
let (started_at, ended_at) = if i == 0 {
(
earlier_time(global_start, started_at),
later_time(global_end, ended_at),
)
} else {
(started_at, ended_at)
};
annotate_subagent_sessions(&mut messages, subagent_map);
let (user_count, first_msg) = count_user_messages(&messages);
let (total_output, peak_context) = sum_token_usage(&messages);
let relationship_type = if i == 0 {
String::new()
} else {
"fork".to_string()
};
results.push(ParseResult {
session: SessionInsert {
id: sid,
project: project.to_string(),
machine: machine.to_string(),
agent: "claude".to_string(),
first_message: first_msg,
started_at: started_at.map(|t| t.to_rfc3339()),
ended_at: ended_at.map(|t| t.to_rfc3339()),
message_count: messages.len() as i64,
user_message_count: user_count,
file_path: Some(file_info.0.clone()),
file_size: Some(file_info.1),
file_mtime: Some(file_info.2),
file_hash: Some(file_info.3.clone()),
parent_session_id: if branch.parent_id.is_empty() {
None
} else {
Some(branch.parent_id.clone())
},
relationship_type,
total_output_tokens: total_output,
peak_context_tokens: peak_context,
},
messages,
});
}
Ok(results)
}
fn parse_linear(
entries: &[DagEntry],
session_id: &str,
project: &str,
machine: &str,
parent_session_id: Option<&str>,
file_info: &(String, i64, i64, String),
subagent_map: &HashMap<String, String>,
global_start: Option<DateTime<Utc>>,
global_end: Option<DateTime<Utc>>,
) -> Result<Vec<ParseResult>> {
let (mut messages, started_at, ended_at) =
extract_messages_with_times(entries, session_id);
let started_at = earlier_time(global_start, started_at);
let ended_at = later_time(global_end, ended_at);
annotate_subagent_sessions(&mut messages, subagent_map);
let (user_count, first_msg) = count_user_messages(&messages);
let (total_output, peak_context) = sum_token_usage(&messages);
Ok(vec![ParseResult {
session: SessionInsert {
id: session_id.to_string(),
project: project.to_string(),
machine: machine.to_string(),
agent: "claude".to_string(),
first_message: first_msg,
started_at: started_at.map(|t| t.to_rfc3339()),
ended_at: ended_at.map(|t| t.to_rfc3339()),
message_count: messages.len() as i64,
user_message_count: user_count,
file_path: Some(file_info.0.clone()),
file_size: Some(file_info.1),
file_mtime: Some(file_info.2),
file_hash: Some(file_info.3.clone()),
parent_session_id: parent_session_id.map(|s| s.to_string()),
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
},
messages,
}])
}
fn extract_messages_with_times(
entries: &[DagEntry],
session_id: &str,
) -> (Vec<MessageInsert>, Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
let mut messages = Vec::new();
let mut started_at: Option<DateTime<Utc>> = None;
let mut ended_at: Option<DateTime<Utc>> = None;
let mut ordinal: i64 = 0;
for e in entries {
if let Some(ts) = e.timestamp {
if started_at.is_none() {
started_at = Some(ts);
}
ended_at = Some(ts);
}
let v: Value = match serde_json::from_str(&e.line) {
Ok(v) => v,
Err(_) => continue,
};
if e.entry_type == "user" {
if v.get("isMeta").and_then(|m| m.as_bool()).unwrap_or(false)
|| v
.get("isCompactSummary")
.and_then(|m| m.as_bool())
.unwrap_or(false)
{
continue;
}
}
let content = v.pointer("/message/content");
let extracted = extract_text_content(content);
let mut text = extracted.text;
if e.entry_type == "user" {
if let Some(cmd_text) = extract_command_text(&text) {
text = cmd_text;
} else if is_command_envelope(&text) {
continue;
}
}
if text.trim().is_empty() && extracted.tool_results.is_empty() {
continue;
}
if e.entry_type == "user" && is_claude_system_message(&text) {
continue;
}
let tool_calls: Vec<ToolCallInsert> = extracted
.tool_calls
.into_iter()
.map(|tc| ToolCallInsert {
tool_name: tc.tool_name,
category: tc.category,
tool_use_id: if tc.tool_use_id.is_empty() {
None
} else {
Some(tc.tool_use_id)
},
input_json: tc.input_json,
skill_name: tc.skill_name,
result_content_length: None,
result_content: None,
subagent_session_id: tc.subagent_session_id,
})
.collect();
let (model, token_usage, context_tokens, output_tokens) = if e.entry_type == "assistant" {
extract_token_fields(&v)
} else {
(String::new(), String::new(), 0, 0)
};
messages.push(MessageInsert {
session_id: session_id.to_string(),
ordinal,
role: e.entry_type.clone(),
content: text.clone(),
timestamp: e.timestamp.map(|t| t.to_rfc3339()),
has_thinking: extracted.has_thinking,
has_tool_use: extracted.has_tool_use,
content_length: text.len() as i64,
is_system: false,
model,
token_usage,
context_tokens,
output_tokens,
tool_calls,
});
ordinal += 1;
}
(messages, started_at, ended_at)
}
fn extract_messages_from(
entries: &[DagEntry],
start_ordinal: i64,
session_id: &str,
) -> (Vec<MessageInsert>, Option<DateTime<Utc>>) {
let mut messages = Vec::new();
let mut ended_at: Option<DateTime<Utc>> = None;
let mut ordinal = start_ordinal;
for e in entries {
if let Some(ts) = e.timestamp {
ended_at = Some(ts);
}
let v: Value = match serde_json::from_str(&e.line) {
Ok(v) => v,
Err(_) => continue,
};
if e.entry_type == "user" {
if v.get("isMeta").and_then(|m| m.as_bool()).unwrap_or(false)
|| v
.get("isCompactSummary")
.and_then(|m| m.as_bool())
.unwrap_or(false)
{
continue;
}
}
let content = v.pointer("/message/content");
let extracted = extract_text_content(content);
let mut text = extracted.text;
if e.entry_type == "user" {
if let Some(cmd_text) = extract_command_text(&text) {
text = cmd_text;
} else if is_command_envelope(&text) {
continue;
}
}
if text.trim().is_empty() && extracted.tool_results.is_empty() {
continue;
}
if e.entry_type == "user" && is_claude_system_message(&text) {
continue;
}
let tool_calls: Vec<ToolCallInsert> = extracted
.tool_calls
.into_iter()
.map(|tc| ToolCallInsert {
tool_name: tc.tool_name,
category: tc.category,
tool_use_id: if tc.tool_use_id.is_empty() {
None
} else {
Some(tc.tool_use_id)
},
input_json: tc.input_json,
skill_name: tc.skill_name,
result_content_length: None,
result_content: None,
subagent_session_id: tc.subagent_session_id,
})
.collect();
let (model, token_usage, context_tokens, output_tokens) = if e.entry_type == "assistant" {
extract_token_fields(&v)
} else {
(String::new(), String::new(), 0, 0)
};
messages.push(MessageInsert {
session_id: session_id.to_string(),
ordinal,
role: e.entry_type.clone(),
content: text.clone(),
timestamp: e.timestamp.map(|t| t.to_rfc3339()),
has_thinking: extracted.has_thinking,
has_tool_use: extracted.has_tool_use,
content_length: text.len() as i64,
is_system: false,
model,
token_usage,
context_tokens,
output_tokens,
tool_calls,
});
ordinal += 1;
}
(messages, ended_at)
}
fn extract_text_content(content: Option<&Value>) -> ExtractedContent {
let content = match content {
Some(c) => c,
None => {
return ExtractedContent {
text: String::new(),
has_thinking: false,
has_tool_use: false,
tool_calls: Vec::new(),
tool_results: Vec::new(),
}
}
};
if let Some(s) = content.as_str() {
return ExtractedContent {
text: s.to_string(),
has_thinking: false,
has_tool_use: false,
tool_calls: Vec::new(),
tool_results: Vec::new(),
};
}
let blocks = match content.as_array() {
Some(a) => a,
None => {
return ExtractedContent {
text: String::new(),
has_thinking: false,
has_tool_use: false,
tool_calls: Vec::new(),
tool_results: Vec::new(),
}
}
};
let mut parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ParsedToolCall> = Vec::new();
let mut tool_results: Vec<ParsedToolResult> = Vec::new();
let mut has_thinking = false;
let mut has_tool_use = false;
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
parts.push(text.to_string());
}
}
}
"thinking" => {
if let Some(thinking) = block.get("thinking").and_then(|t| t.as_str()) {
if !thinking.is_empty() {
has_thinking = true;
parts.push(format!("[Thinking]\n{}\n[/Thinking]", thinking));
}
}
}
"tool_use" => {
has_tool_use = true;
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("");
if !name.is_empty() {
let tool_use_id = block
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let input_json = block.get("input").map(|i| i.to_string());
let skill_name = match name {
"Skill" | "skill" => block
.pointer("/input/skill")
.or_else(|| block.pointer("/input/name"))
.and_then(|s| s.as_str())
.map(|s| s.to_string()),
_ => None,
};
tool_calls.push(ParsedToolCall {
tool_use_id,
tool_name: name.to_string(),
category: normalize_tool_category(name),
input_json,
skill_name,
subagent_session_id: None,
});
}
parts.push(format_tool_use(block));
}
"tool_result" => {
let tuid = block
.get("tool_use_id")
.and_then(|t| t.as_str())
.unwrap_or("");
if !tuid.is_empty() {
let rc = block.get("content");
let cl = tool_result_content_length(rc);
tool_results.push(ParsedToolResult {
tool_use_id: tuid.to_string(),
content_length: cl,
content_raw: rc.map(|c| c.to_string()),
});
}
}
_ => {}
}
}
ExtractedContent {
text: parts.join("\n"),
has_thinking,
has_tool_use,
tool_calls,
tool_results,
}
}
fn tool_result_content_length(content: Option<&Value>) -> i64 {
match content {
None => 0,
Some(Value::String(s)) => s.len() as i64,
Some(Value::Array(arr)) => arr
.iter()
.map(|b| {
b.get("text")
.and_then(|t| t.as_str())
.map(|s| s.len() as i64)
.unwrap_or(0)
})
.sum(),
_ => 0,
}
}
fn format_tool_use(block: &Value) -> String {
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input");
match name {
"Read" | "read" | "read_file" | "look_at" | "view" => {
let path = input
.and_then(|i| {
i.get("file_path")
.or_else(|| i.get("path"))
.or_else(|| i.get("filePath"))
})
.and_then(|p| p.as_str())
.unwrap_or("");
format!("[Read: {}]", path)
}
"Edit" | "edit" | "edit_file" | "str_replace" | "StrReplace" => {
let path = input
.and_then(|i| i.get("file_path").or_else(|| i.get("path")))
.and_then(|p| p.as_str())
.unwrap_or("");
format!("[Edit: {}]", path)
}
"Write" | "write" | "create_file" | "NotebookEdit" => {
let path = input
.and_then(|i| i.get("file_path").or_else(|| i.get("path")))
.and_then(|p| p.as_str())
.unwrap_or("");
format!("[Write: {}]", path)
}
"Bash" | "bash" | "shell_command" | "run_command" | "Shell" | "exec" => {
let cmd = input
.and_then(|i| i.get("command").or_else(|| i.get("cmd")))
.and_then(|c| c.as_str())
.unwrap_or("");
let desc = input
.and_then(|i| i.get("description"))
.and_then(|d| d.as_str())
.unwrap_or("");
if !desc.is_empty() {
format!("[Bash: {}]\n$ {}", desc, cmd)
} else {
format!("[Bash]\n$ {}", cmd)
}
}
"Grep" | "grep" | "grep_search" | "search_files" | "finder" => {
let pattern = input
.and_then(|i| i.get("pattern").or_else(|| i.get("query")))
.and_then(|p| p.as_str())
.unwrap_or("");
format!("[Grep: {}]", pattern)
}
"Glob" | "glob" => {
let pattern = input
.and_then(|i| i.get("pattern").or_else(|| i.get("filePattern")))
.and_then(|p| p.as_str())
.unwrap_or("");
let path = input
.and_then(|i| i.get("path"))
.and_then(|p| p.as_str())
.unwrap_or(".");
format!("[Glob: {} in {}]", pattern, path)
}
"Task" | "Agent" | "task" => {
let desc = input
.and_then(|i| i.get("description").or_else(|| i.get("prompt")))
.and_then(|d| d.as_str())
.unwrap_or("");
let agent_type = input
.and_then(|i| i.get("subagent_type").or_else(|| i.get("agent")))
.and_then(|a| a.as_str())
.unwrap_or("");
if desc.is_empty() && agent_type.is_empty() {
"[Task]".to_string()
} else if agent_type.is_empty() {
format!("[Task: {}]", desc)
} else {
format!("[Task: {} ({})]", desc, agent_type)
}
}
"Skill" | "skill" => {
let skill = input
.and_then(|i| i.get("skill").or_else(|| i.get("name")))
.and_then(|s| s.as_str())
.unwrap_or("");
format!("[Skill: {}]", skill)
}
"TaskCreate" => {
let subject = input
.and_then(|i| i.get("subject"))
.and_then(|s| s.as_str())
.unwrap_or("");
if subject.is_empty() {
"[TaskCreate]".to_string()
} else {
format!("[TaskCreate: {}]", subject)
}
}
"TaskUpdate" => {
let task_id = input
.and_then(|i| i.get("taskId"))
.and_then(|t| t.as_str())
.unwrap_or("");
let status = input
.and_then(|i| i.get("status"))
.and_then(|s| s.as_str())
.unwrap_or("");
if !status.is_empty() {
format!("[TaskUpdate: #{} {}]", task_id, status)
} else {
format!("[TaskUpdate: #{}]", task_id)
}
}
"EnterPlanMode" => "[Entering Plan Mode]".to_string(),
"ExitPlanMode" => "[Exiting Plan Mode]".to_string(),
"TaskGet" => {
let task_id = input
.and_then(|i| i.get("taskId"))
.and_then(|t| t.as_str())
.unwrap_or("");
format!("[TaskGet: #{}]", task_id)
}
"TaskList" => "[TaskList]".to_string(),
_ => {
if name.contains("subagent") {
let desc = input
.and_then(|i| i.get("description").or_else(|| i.get("prompt")))
.and_then(|d| d.as_str())
.unwrap_or("");
format!("[Task: {}]", desc)
} else {
format!("[Tool: {}]", name)
}
}
}
}
pub fn normalize_tool_category(name: &str) -> String {
match name {
"Read" => "Read",
"Edit" => "Edit",
"Write" | "NotebookEdit" => "Write",
"Bash" => "Bash",
"Grep" => "Grep",
"Glob" => "Glob",
"Task" | "Agent" => "Task",
"Skill" => "Tool",
"shell_command" | "exec_command" | "write_stdin" | "shell" => "Bash",
"apply_patch" => "Edit",
"read_file" | "list_directory" => "Read",
"write_file" => "Write",
"edit_file" | "replace" => "Edit",
"run_command" | "execute_command" | "run_shell_command" => "Bash",
"search_files" | "grep" | "grep_search" => "Grep",
"read" => "Read",
"edit" => "Edit",
"write" => "Write",
"bash" => "Bash",
"glob" => "Glob",
"task" => "Task",
"view" => "Read",
"report_intent" => "Tool",
"Shell" => "Bash",
"StrReplace" => "Edit",
"LS" => "Read",
"create_file" => "Write",
"look_at" => "Read",
"undo_edit" => "Edit",
"finder" => "Grep",
"read_web_page" => "Read",
"skill" => "Tool",
"find" => "Read",
"str_replace" => "Edit",
"exec" | "process" => "Bash",
"browser" | "web_search" | "web_fetch" => "Tool",
"image" | "canvas" | "tts" => "Tool",
"message" | "nodes" => "Tool",
"sessions_list" | "sessions_history" | "sessions_send" | "sessions_spawn" => "Task",
"subagents" | "agents_list" | "session_status" => "Task",
"WebFetch" => "Read",
"TodoWrite" => "Tool",
"subagent__ZencoderSubagent" => "Task",
"zencoder-rag-mcp__web_search" => "Read",
_ => {
if name.contains("subagent") {
"Task"
} else {
"Other"
}
}
}
.to_string()
}
fn extract_timestamp(v: &Value) -> Option<DateTime<Utc>> {
let ts_str = v
.get("timestamp")
.and_then(|t| t.as_str())
.or_else(|| v.pointer("/snapshot/timestamp").and_then(|t| t.as_str()));
ts_str.and_then(parse_timestamp)
}
fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ") {
return Some(DateTime::from_naive_utc_and_offset(dt, Utc));
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ") {
return Some(DateTime::from_naive_utc_and_offset(dt, Utc));
}
None
}
fn update_bounds(
start: &mut Option<DateTime<Utc>>,
end: &mut Option<DateTime<Utc>>,
ts: DateTime<Utc>,
) {
if start.map_or(true, |s| ts < s) {
*start = Some(ts);
}
if end.map_or(true, |e| ts > e) {
*end = Some(ts);
}
}
fn earlier_time(
a: Option<DateTime<Utc>>,
b: Option<DateTime<Utc>>,
) -> Option<DateTime<Utc>> {
match (a, b) {
(Some(a), Some(b)) => Some(if a < b { a } else { b }),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
fn later_time(
a: Option<DateTime<Utc>>,
b: Option<DateTime<Utc>>,
) -> Option<DateTime<Utc>> {
match (a, b) {
(Some(a), Some(b)) => Some(if a > b { a } else { b }),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
fn has_dag_fork(entries: &[DagEntry]) -> bool {
let mut last_uuid = String::new();
for e in entries {
if e.uuid.is_empty() {
continue;
}
if !last_uuid.is_empty() && e.parent_uuid != last_uuid {
return true;
}
last_uuid = e.uuid.clone();
}
false
}
fn count_user_turns(
entries: &[DagEntry],
children: &HashMap<String, Vec<usize>>,
start_idx: usize,
) -> usize {
let mut count = 0;
let mut stack = vec![start_idx];
while let Some(current) = stack.pop() {
if entries[current].entry_type == "user" {
count += 1;
}
if let Some(kids) = children.get(&entries[current].uuid) {
stack.extend(kids.iter());
}
}
count
}
fn annotate_subagent_sessions(
messages: &mut [MessageInsert],
subagent_map: &HashMap<String, String>,
) {
if subagent_map.is_empty() {
return;
}
for msg in messages {
for tc in &mut msg.tool_calls {
if let Some(ref tuid) = tc.tool_use_id {
if let Some(sid) = subagent_map.get(tuid.as_str()) {
if tc.category == "Task" || tc.tool_name.contains("subagent") {
tc.subagent_session_id = Some(sid.clone());
}
}
}
}
}
}
fn count_user_messages(messages: &[MessageInsert]) -> (i64, Option<String>) {
let mut count: i64 = 0;
let mut first_msg: Option<String> = None;
for msg in messages {
if msg.role == "user" && !msg.content.is_empty() {
count += 1;
if first_msg.is_none() {
let truncated = truncate_str(
&msg.content.replace('\n', " "),
MAX_FIRST_MESSAGE_LEN,
);
first_msg = Some(truncated);
}
}
}
(count, first_msg)
}
fn sum_token_usage(messages: &[MessageInsert]) -> (i64, i64) {
let mut total_output: i64 = 0;
let mut peak_context: i64 = 0;
for msg in messages {
total_output += msg.output_tokens;
if msg.context_tokens > peak_context {
peak_context = msg.context_tokens;
}
}
(total_output, peak_context)
}
fn extract_token_fields(v: &Value) -> (String, String, i64, i64) {
let model = v
.pointer("/message/model")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let usage = v.pointer("/message/usage");
let (token_usage, context_tokens, output_tokens) = match usage {
Some(u) => {
let raw = u.to_string();
let input = u
.get("input_tokens")
.and_then(|t| t.as_i64())
.unwrap_or(0);
let cache_creation = u
.get("cache_creation_input_tokens")
.and_then(|t| t.as_i64())
.unwrap_or(0);
let cache_read = u
.get("cache_read_input_tokens")
.and_then(|t| t.as_i64())
.unwrap_or(0);
let output = u
.get("output_tokens")
.and_then(|t| t.as_i64())
.unwrap_or(0);
(raw, input + cache_creation + cache_read, output)
}
None => (String::new(), 0, 0),
};
(model, token_usage, context_tokens, output_tokens)
}
fn collect_subagent_from_enqueue(v: &Value, subagent_map: &mut HashMap<String, String>) {
let content_str = match v.get("content").and_then(|c| c.as_str()) {
Some(s) => s,
None => return,
};
let content_val: Value = match serde_json::from_str(content_str) {
Ok(v) => v,
Err(_) => Value::Null,
};
let mut tuid = content_val
.get("tool_use_id")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
let mut task_id = content_val
.get("task_id")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
if tuid.is_empty() || task_id.is_empty() {
lazy_static_regex!(XML_TASK_ID_RE, r"<task-id>([^<]+)</task-id>");
lazy_static_regex!(XML_TOOL_USE_RE, r"<tool-use-id>([^<]+)</tool-use-id>");
if task_id.is_empty() {
if let Some(caps) = XML_TASK_ID_RE.captures(content_str) {
task_id = caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
}
}
if tuid.is_empty() {
if let Some(caps) = XML_TOOL_USE_RE.captures(content_str) {
tuid = caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
}
}
}
if !tuid.is_empty() && !task_id.is_empty() {
subagent_map.insert(tuid, format!("agent-{}", task_id));
}
}
macro_rules! lazy_static_regex {
($name:ident, $pattern:expr) => {
lazy_static::lazy_static! {
static ref $name: Regex = Regex::new($pattern).unwrap();
}
};
}
use lazy_static_regex;
fn extract_command_text(content: &str) -> Option<String> {
let trimmed = content.trim_start_matches('\u{FEFF}').trim_start();
if !trimmed.starts_with("<command-message>") && !trimmed.starts_with("<command-name>") {
return None;
}
lazy_static_regex!(CMD_STRIP_RE, r"<command-(?:name|message|args)>[^<]*</command-(?:name|message|args)>");
let stripped = CMD_STRIP_RE.replace_all(trimmed, "");
if !stripped.trim().is_empty() {
return None;
}
lazy_static_regex!(CMD_NAME_RE, r"<command-name>([^<]+)</command-name>");
lazy_static_regex!(CMD_MSG_RE, r"<command-message>([^<]+)</command-message>");
lazy_static_regex!(CMD_ARGS_RE, r"<command-args>([^<]*)</command-args>");
if let Some(caps) = CMD_NAME_RE.captures(content) {
let mut name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let name_owned;
if !name.starts_with('/') {
name_owned = format!("/{}", name);
name = &name_owned;
}
let args = CMD_ARGS_RE
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim())
.unwrap_or("");
if args.is_empty() {
Some(name.to_string())
} else {
Some(format!("{} {}", name, args))
}
} else if let Some(caps) = CMD_MSG_RE.captures(content) {
let msg = caps.get(1).map(|m| m.as_str()).unwrap_or("");
Some(format!("/{}", msg))
} else {
None
}
}
fn is_command_envelope(content: &str) -> bool {
let trimmed = content.trim_start_matches('\u{FEFF}').trim_start();
if !trimmed.starts_with("<command-message>") && !trimmed.starts_with("<command-name>") {
return false;
}
lazy_static_regex!(CMD_STRIP_RE2, r"<command-(?:name|message|args)>[^<]*</command-(?:name|message|args)>");
let stripped = CMD_STRIP_RE2.replace_all(trimmed, "");
stripped.trim().is_empty()
}
fn is_claude_system_message(content: &str) -> bool {
let trimmed = content.trim_start_matches('\u{FEFF}').trim_start();
let prefixes = [
"This session is being continued",
"[Request interrupted",
"<task-notification>",
"<local-command-",
"Stop hook feedback:",
];
prefixes.iter().any(|p| trimmed.starts_with(p))
}
fn truncate_str(s: &str, max_len: usize) -> String {
let s = s.trim();
if s.len() <= max_len {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_len {
return s.to_string();
}
let truncated: String = chars[..max_len].iter().collect();
format!("{}...", truncated)
}
fn compute_sha256(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn discover_claude_sessions() -> Result<Vec<DiscoveredFile>> {
let home = dirs::home_dir().context("cannot determine home directory")?;
let projects_dir = home.join(".claude").join("projects");
if !projects_dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(&projects_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let rel = match path.strip_prefix(&projects_dir) {
Ok(r) => r,
Err(_) => continue,
};
let components: Vec<&str> = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
if components.len() < 2 {
continue;
}
let is_subagent = components.len() >= 4 && components[2] == "subagents";
let project = components[0].to_string();
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
files.push(DiscoveredFile {
path: path.to_path_buf(),
project,
session_id,
is_subagent,
});
}
Ok(files)
}
#[derive(Debug, Clone)]
pub struct DiscoveredFile {
pub path: std::path::PathBuf,
pub project: String,
pub session_id: String,
pub is_subagent: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_tool_category() {
assert_eq!(normalize_tool_category("Read"), "Read");
assert_eq!(normalize_tool_category("Edit"), "Edit");
assert_eq!(normalize_tool_category("Bash"), "Bash");
assert_eq!(normalize_tool_category("Task"), "Task");
assert_eq!(normalize_tool_category("Agent"), "Task");
assert_eq!(normalize_tool_category("shell_command"), "Bash");
assert_eq!(normalize_tool_category("unknown_tool"), "Other");
assert_eq!(normalize_tool_category("some_subagent_tool"), "Task");
}
#[test]
fn test_is_claude_system_message() {
assert!(is_claude_system_message(
"This session is being continued from a previous conversation"
));
assert!(is_claude_system_message("[Request interrupted by user]"));
assert!(is_claude_system_message("<task-notification>something</task-notification>"));
assert!(is_claude_system_message("<local-command-caveat>test</local-command-caveat>"));
assert!(is_claude_system_message("Stop hook feedback: something"));
assert!(!is_claude_system_message("Hello, how can I help?"));
assert!(!is_claude_system_message("normal user message"));
}
#[test]
fn test_extract_command_text() {
let xml = "<command-name>commit</command-name><command-args>-m fix</command-args>";
assert_eq!(extract_command_text(xml), Some("/commit -m fix".to_string()));
let xml2 = "<command-name>help</command-name>";
assert_eq!(extract_command_text(xml2), Some("/help".to_string()));
assert_eq!(extract_command_text("regular text"), None);
}
#[test]
fn test_is_command_envelope() {
assert!(is_command_envelope(
"<command-name>test</command-name><command-args></command-args>"
));
assert!(!is_command_envelope("not a command"));
assert!(!is_command_envelope(
"<command-name>test</command-name> extra text"
));
}
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 5), "hello...");
}
#[test]
fn test_has_dag_fork() {
let entries = vec![
DagEntry {
uuid: "a".to_string(),
parent_uuid: "".to_string(),
entry_type: "user".to_string(),
line: String::new(),
timestamp: None,
},
DagEntry {
uuid: "b".to_string(),
parent_uuid: "a".to_string(),
entry_type: "assistant".to_string(),
line: String::new(),
timestamp: None,
},
];
assert!(!has_dag_fork(&entries));
let forked = vec![
DagEntry {
uuid: "a".to_string(),
parent_uuid: "".to_string(),
entry_type: "user".to_string(),
line: String::new(),
timestamp: None,
},
DagEntry {
uuid: "c".to_string(),
parent_uuid: "x".to_string(), entry_type: "assistant".to_string(),
line: String::new(),
timestamp: None,
},
];
assert!(has_dag_fork(&forked));
}
#[test]
fn test_parse_timestamp() {
let ts = parse_timestamp("2025-01-15T10:30:00Z");
assert!(ts.is_some());
let ts2 = parse_timestamp("2025-01-15T10:30:00.123Z");
assert!(ts2.is_some());
let ts3 = parse_timestamp("not a timestamp");
assert!(ts3.is_none());
}
#[test]
fn test_extract_text_content_string() {
let v: Value = serde_json::json!("Hello, world!");
let result = extract_text_content(Some(&v));
assert_eq!(result.text, "Hello, world!");
assert!(!result.has_thinking);
assert!(!result.has_tool_use);
}
#[test]
fn test_extract_text_content_array_with_thinking() {
let v: Value = serde_json::json!([
{"type": "thinking", "thinking": "Let me think..."},
{"type": "text", "text": "Here is my answer."}
]);
let result = extract_text_content(Some(&v));
assert!(result.has_thinking);
assert!(result.text.contains("[Thinking]"));
assert!(result.text.contains("Here is my answer."));
}
#[test]
fn test_extract_text_content_tool_use() {
let v: Value = serde_json::json!([
{"type": "tool_use", "id": "tu_123", "name": "Read", "input": {"file_path": "/tmp/test.rs"}}
]);
let result = extract_text_content(Some(&v));
assert!(result.has_tool_use);
assert_eq!(result.tool_calls.len(), 1);
assert_eq!(result.tool_calls[0].tool_name, "Read");
assert_eq!(result.tool_calls[0].category, "Read");
assert!(result.text.contains("[Read: /tmp/test.rs]"));
}
}