use wsb::prelude::*;
use chrono::{DateTime, Utc};
use regex::Regex;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use wsb::persist::global::models::{MessageInsert, SessionInsert, ToolCallInsert};
const FORK_THRESHOLD: usize = 3;
const MAX_FIRST_MESSAGE_LEN: usize = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentType {
Claude,
Codex,
Copilot,
Gemini,
OpenCode,
OpenHands,
Cursor,
Iflow,
Amp,
Zencoder,
VscodeCopilot,
Pi,
OpenClaw,
Kimi,
ClaudeAi,
ChatGpt,
Kiro,
KiroIde,
Cortex,
Hermes,
Warp,
Positron,
}
impl AgentType {
pub fn as_str(&self) -> &'static str {
match self {
AgentType::Claude => "claude",
AgentType::Codex => "codex",
AgentType::Copilot => "copilot",
AgentType::Gemini => "gemini",
AgentType::OpenCode => "opencode",
AgentType::OpenHands => "openhands",
AgentType::Cursor => "cursor",
AgentType::Iflow => "iflow",
AgentType::Amp => "amp",
AgentType::Zencoder => "zencoder",
AgentType::VscodeCopilot => "vscode-copilot",
AgentType::Pi => "pi",
AgentType::OpenClaw => "openclaw",
AgentType::Kimi => "kimi",
AgentType::ClaudeAi => "claude-ai",
AgentType::ChatGpt => "chatgpt",
AgentType::Kiro => "kiro",
AgentType::KiroIde => "kiro-ide",
AgentType::Cortex => "cortex",
AgentType::Hermes => "hermes",
AgentType::Warp => "warp",
AgentType::Positron => "positron",
}
}
}
impl std::fmt::Display for AgentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct ParseResult {
pub session: SessionInsert,
pub messages: Vec<MessageInsert>,
}
#[derive(Debug, Clone)]
pub struct ParsedToolCall {
pub tool_use_id: String,
pub tool_name: String,
pub category: String,
pub input_json: Option<String>,
pub skill_name: Option<String>,
pub subagent_session_id: Option<String>,
}
struct ExtractedContent {
text: String,
has_thinking: bool,
has_tool_use: bool,
tool_calls: Vec<ParsedToolCall>,
has_tool_result: bool,
}
#[derive(Debug, Clone)]
struct DagEntry {
uuid: String,
parent_uuid: String,
entry_type: String,
line: String,
timestamp: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default)]
struct Origin {
cwd: String,
git_branch: String,
}
pub fn parse_claude_session(path: &Path, 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;
let mut origin = Origin::default();
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 origin.cwd.is_empty() {
if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()).filter(|c| !c.is_empty()) {
origin.cwd = cwd.to_string();
origin.git_branch = v
.get("gitBranch")
.and_then(|b| b.as_str())
.unwrap_or_default()
.to_string();
}
}
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,
&origin,
machine,
parent_session_id.as_deref(),
&file_info,
&subagent_map,
global_start,
global_end,
)
} else {
parse_linear(
&entries,
&session_id,
&origin,
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))
}
fn parse_dag(
entries: &[DagEntry],
session_id: &str,
origin: &Origin,
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,
origin,
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,
origin,
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,
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,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: origin.cwd.clone(),
git_branch: origin.git_branch.clone(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
});
}
Ok(results)
}
fn parse_linear(
entries: &[DagEntry],
session_id: &str,
origin: &Origin,
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(),
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,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: origin.cwd.clone(),
git_branch: origin.git_branch.clone(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
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.has_tool_result {
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)
};
let claude_message_id = v
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let claude_request_id = v
.get("requestId")
.or_else(|| v.pointer("/message/requestId"))
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let is_compact_boundary = v
.get("isCompactSummary")
.and_then(|b| b.as_bool())
.unwrap_or(false);
let source_subtype = if is_compact_boundary {
"compact".to_string()
} else {
e.entry_type.clone()
};
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,
has_context_tokens: context_tokens > 0,
has_output_tokens: output_tokens > 0,
claude_message_id,
claude_request_id,
source_type: "claude".to_string(),
source_subtype,
source_uuid: e.uuid.clone(),
source_parent_uuid: e.parent_uuid.clone(),
is_sidechain: false,
is_compact_boundary,
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.has_tool_result {
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)
};
let claude_message_id = v
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let claude_request_id = v
.get("requestId")
.or_else(|| v.pointer("/message/requestId"))
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let is_compact_boundary = v
.get("isCompactSummary")
.and_then(|b| b.as_bool())
.unwrap_or(false);
let source_subtype = if is_compact_boundary {
"compact".to_string()
} else {
e.entry_type.clone()
};
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,
has_context_tokens: context_tokens > 0,
has_output_tokens: output_tokens > 0,
claude_message_id,
claude_request_id,
source_type: "claude".to_string(),
source_subtype,
source_uuid: e.uuid.clone(),
source_parent_uuid: e.parent_uuid.clone(),
is_sidechain: false,
is_compact_boundary,
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(),
has_tool_result: false,
}
}
};
if let Some(s) = content.as_str() {
return ExtractedContent {
text: s.to_string(),
has_thinking: false,
has_tool_use: false,
tool_calls: Vec::new(),
has_tool_result: false,
};
}
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(),
has_tool_result: false,
}
}
};
let mut parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ParsedToolCall> = Vec::new();
let mut has_tool_result = false;
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() {
has_tool_result = true;
}
}
_ => {}
}
}
ExtractedContent {
text: parts.join("\n"),
has_thinking,
has_tool_use,
tool_calls,
has_tool_result,
}
}
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",
_ => {
if name.contains("subagent") {
"Task"
} else if name.ends_with("__web_search") {
"Read"
} 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())
}
use rusqlite::{params, Connection};
pub async fn parse_opencode_session(
db_path: &Path,
session_id: &str,
machine: &str,
) -> Result<Option<(SessionInsert, Vec<MessageInsert>)>> {
if !db_path.exists() {
return Ok(None);
}
let db_path = db_path.to_path_buf();
let session_id = session_id.to_string();
let machine = machine.to_string();
tokio::task::spawn_blocking(move || {
let conn = Connection::open(&db_path)?;
let projects = load_opencode_projects_sync(&conn)?;
let s = load_one_opencode_session_sync(&conn, &session_id)?;
let worktree = projects.get(&s.project_id).cloned().unwrap_or_default();
build_opencode_session_sync(&conn, &s, &worktree, &db_path, &machine)
})
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))?
}
fn load_opencode_projects_sync(conn: &Connection) -> Result<HashMap<String, String>> {
let mut stmt = conn.prepare("SELECT id, worktree FROM project")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut projects = HashMap::new();
for row in rows {
if let Ok((id, worktree)) = row {
projects.insert(id, worktree);
}
}
Ok(projects)
}
struct OpenCodeSessionRow {
id: String,
project_id: String,
parent_id: String,
title: String,
time_created: i64,
time_updated: i64,
}
fn load_one_opencode_session_sync(
conn: &Connection,
session_id: &str,
) -> Result<OpenCodeSessionRow> {
let mut stmt = conn.prepare(
"SELECT s.id, s.project_id, COALESCE(s.parent_id, ''), COALESCE(s.title, ''), s.time_created, s.time_updated FROM session s WHERE s.id = ?"
)?;
let row = stmt.query_row(params![session_id], |row| {
Ok(OpenCodeSessionRow {
id: row.get(0)?,
project_id: row.get(1)?,
parent_id: row.get(2)?,
title: row.get(3)?,
time_created: row.get(4)?,
time_updated: row.get(5)?,
})
})?;
Ok(row)
}
struct OpenCodeMessageRow {
id: String,
data: String,
time_created: i64,
}
#[derive(Clone)]
struct OpenCodePartRow {
message_id: String,
data: String,
time_created: i64,
}
fn load_opencode_messages_sync(
conn: &Connection,
session_id: &str,
) -> Result<Vec<OpenCodeMessageRow>> {
let mut stmt = conn.prepare(
"SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created",
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok(OpenCodeMessageRow {
id: row.get(0)?,
data: row.get(1)?,
time_created: row.get(2)?,
})
})?;
let msgs: Vec<OpenCodeMessageRow> = rows.filter_map(|r| r.ok()).collect();
Ok(msgs)
}
fn load_opencode_parts_sync(
conn: &Connection,
session_id: &str,
) -> Result<HashMap<String, Vec<OpenCodePartRow>>> {
let mut stmt = conn.prepare(
"SELECT p.message_id, COALESCE(p.data, '{}'), p.time_created FROM part p WHERE p.session_id = ? ORDER BY p.time_created"
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok(OpenCodePartRow {
message_id: row.get(0)?,
data: row.get(1)?,
time_created: row.get(2)?,
})
})?;
let mut parts: HashMap<String, Vec<OpenCodePartRow>> = HashMap::new();
for row in rows {
if let Ok(part) = row {
parts.entry(part.message_id.clone()).or_default().push(part);
}
}
Ok(parts)
}
fn build_opencode_session_sync(
conn: &Connection,
s: &OpenCodeSessionRow,
worktree: &str,
db_path: &Path,
machine: &str,
) -> Result<Option<(SessionInsert, Vec<MessageInsert>)>> {
let messages = load_opencode_messages_sync(conn, &s.id)?;
let parts = load_opencode_parts_sync(conn, &s.id)?;
let mut parsed: Vec<MessageInsert> = Vec::new();
let mut first_msg: Option<String> = None;
let mut has_user_or_ast = false;
let mut ordinal: i64 = 0;
let mut total_output: i64 = 0;
let mut peak_context: i64 = 0;
if !s.title.is_empty() && !is_opencode_default_title(&s.title) {
first_msg = Some(truncate_str(&s.title, MAX_FIRST_MESSAGE_LEN));
}
for m in &messages {
let md: OpenCodeMessageData = match serde_json::from_str(&m.data) {
Ok(v) => v,
Err(_) => continue,
};
let role = normalize_opencode_role(&md.role);
if role.is_empty() {
continue;
}
has_user_or_ast = true;
let mut msg_parts = parts.get(&m.id).cloned().unwrap_or_default();
msg_parts.sort_by_key(|p| p.time_created);
let (
content,
has_thinking,
has_tool_use,
tool_calls,
model,
token_usage,
context_tokens,
output_tokens,
) = build_opencode_message(&msg_parts, &m.data);
if content.trim().is_empty() && tool_calls.is_empty() {
continue;
}
if role == "user" && first_msg.is_none() {
first_msg = Some(truncate_str(
&content.replace('\n', " "),
MAX_FIRST_MESSAGE_LEN,
));
}
total_output += output_tokens;
if context_tokens > peak_context {
peak_context = context_tokens;
}
parsed.push(MessageInsert {
session_id: format!("opencode:{}", s.id),
ordinal,
role: role.clone(),
content,
timestamp: Some(millis_to_time(m.time_created).to_rfc3339()),
has_thinking,
has_tool_use,
content_length: 0,
is_system: false,
model: model.clone(),
token_usage: token_usage.clone(),
context_tokens,
output_tokens,
has_context_tokens: context_tokens > 0,
has_output_tokens: output_tokens > 0,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "opencode".to_string(),
source_subtype: role,
source_uuid: m.id.clone(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
if !has_user_or_ast || parsed.is_empty() {
return Ok(None);
}
let parent_id = if !s.parent_id.is_empty() {
Some(format!("opencode:{}", s.parent_id))
} else {
None
};
let started_at = millis_to_time(s.time_created);
let ended_at = millis_to_time(s.time_updated);
let user_count: i64 = parsed
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let session = SessionInsert {
id: format!("opencode:{}", s.id),
machine: machine.to_string(),
agent: "opencode".to_string(),
first_message: first_msg,
started_at: Some(started_at.to_rfc3339()),
ended_at: Some(ended_at.to_rfc3339()),
message_count: parsed.len() as i64,
user_message_count: user_count,
file_path: Some(format!("{}#{}", db_path.display(), s.id)),
file_size: None,
file_mtime: Some(s.time_updated * 1_000_000),
file_hash: None,
parent_session_id: parent_id,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: worktree.to_string(),
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
};
Ok(Some((session, parsed)))
}
#[derive(Debug, Deserialize)]
struct OpenCodeMessageData {
#[serde(rename = "role")]
role: String,
#[serde(rename = "modelID", default)]
model_id: String,
}
fn normalize_opencode_role(role: &str) -> String {
match role {
"user" => "user".to_string(),
"assistant" => "assistant".to_string(),
_ => String::new(),
}
}
fn build_opencode_message(
parts: &[OpenCodePartRow],
data_raw: &str,
) -> (
String,
bool,
bool,
Vec<ToolCallInsert>,
String,
String,
i64,
i64,
) {
let mut texts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ToolCallInsert> = Vec::new();
let mut has_thinking = false;
let mut has_tool_use = false;
let mut model = String::new();
let mut token_usage = String::new();
let mut context_tokens: i64 = 0;
let mut output_tokens: i64 = 0;
for p in parts {
if let Some(part_type) = extract_opencode_part_type(&p.data) {
match part_type.as_str() {
"text" => {
if let Some(text) = extract_opencode_text(&p.data) {
texts.push(text);
}
}
"tool" => {
has_tool_use = true;
if let Some(tc) = extract_opencode_tool_call(&p.data) {
tool_calls.push(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: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
}
}
"reasoning" => {
if let Some(text) = extract_opencode_text(&p.data) {
has_thinking = true;
texts.push(format!("[Thinking]\n{}\n[/Thinking]", text));
}
}
_ => {}
}
}
}
if let Ok(md) = serde_json::from_str::<OpenCodeMessageData>(data_raw) {
if !md.model_id.is_empty() {
model = md.model_id;
}
if let Ok(tokens_val) = serde_json::from_str::<Value>(data_raw) {
if let Some(tokens) = tokens_val.get("tokens") {
let input = tokens.get("input").and_then(|v| v.as_i64()).unwrap_or(0);
let output = tokens.get("output").and_then(|v| v.as_i64()).unwrap_or(0);
let cache_read = tokens
.get("cache")
.and_then(|c| c.get("read"))
.and_then(|v| v.as_i64())
.unwrap_or(0);
let cache_create = tokens
.get("cache")
.and_then(|c| c.get("write"))
.and_then(|v| v.as_i64())
.unwrap_or(0);
if input > 0 || output > 0 || cache_read > 0 || cache_create > 0 {
let normalized = serde_json::json!({
"input_tokens": input,
"output_tokens": output,
"cache_read_input_tokens": cache_read,
"cache_creation_input_tokens": cache_create,
});
token_usage = normalized.to_string();
output_tokens = output;
context_tokens = input + cache_read + cache_create;
}
}
}
}
(
texts.join("\n"),
has_thinking,
has_tool_use,
tool_calls,
model,
token_usage,
context_tokens,
output_tokens,
)
}
fn extract_opencode_part_type(data: &str) -> Option<String> {
let v: Value = serde_json::from_str(data).ok()?;
v.get("type")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
}
fn extract_opencode_text(data: &str) -> Option<String> {
serde_json::from_str::<Value>(data).ok().and_then(|v| {
v.get("content")
.and_then(|c| c.as_str())
.map(|s| s.to_string())
.or_else(|| {
v.get("text")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
})
})
}
struct OpenCodeToolCall {
tool_use_id: String,
tool_name: String,
category: String,
input_json: Option<String>,
}
fn extract_opencode_tool_call(data: &str) -> Option<OpenCodeToolCall> {
let v: Value = serde_json::from_str(data).ok()?;
let tool_name = v.get("tool")?.as_str()?.to_string();
let call_id = v
.get("callID")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let input_json = v
.get("state")
.and_then(|s| s.get("input"))
.map(|i| i.to_string());
Some(OpenCodeToolCall {
tool_use_id: call_id,
tool_name: tool_name.clone(),
category: normalize_tool_category(&tool_name),
input_json,
})
}
fn is_opencode_default_title(title: &str) -> bool {
lazy_static::lazy_static! {
static ref RE: Regex = Regex::new(r"^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$").unwrap();
}
RE.is_match(title)
}
fn millis_to_time(ms: i64) -> DateTime<Utc> {
if ms == 0 {
return DateTime::from_timestamp(0, 0).unwrap_or_default();
}
chrono::TimeZone::timestamp_millis_opt(&Utc, ms)
.single()
.unwrap_or_else(|| DateTime::from_timestamp(0, 0).unwrap_or_default())
}
pub fn parse_codex_session(path: &Path, machine: &str) -> Result<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 content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let mut cwd = String::new();
let mut first_message: Option<String> = None;
let mut started_at: Option<DateTime<Utc>> = None;
let mut ended_at: Option<DateTime<Utc>> = None;
let mut messages: Vec<MessageInsert> = Vec::new();
let mut ordinal: i64 = 0;
let mut current_model = String::new();
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,
};
let entry_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if let Some(ts_str) = v.get("timestamp").and_then(|t| t.as_str()) {
if let Some(ts) = parse_timestamp(ts_str) {
if started_at.is_none() {
started_at = Some(ts);
}
ended_at = Some(ts);
}
}
match entry_type {
"session_meta" => {
if let Some(payload) = v.get("payload") {
if let Some(id) = payload.get("id").and_then(|id| id.as_str()) {
session_id = id.to_string();
}
if let Some(recorded) = payload.get("cwd").and_then(|c| c.as_str()) {
if !recorded.is_empty() {
cwd = recorded.to_string();
}
}
}
}
"turn_context" => {
if let Some(payload) = v.get("payload") {
if let Some(model) = payload.get("model").and_then(|m| m.as_str()) {
current_model = model.to_string();
}
}
}
"response_item" => {
if let Some(payload) = v.get("payload") {
let item_type = payload.get("type").and_then(|t| t.as_str()).unwrap_or("");
match item_type {
"function_call" => {
let name = payload.get("name").and_then(|n| n.as_str()).unwrap_or("");
if name.is_empty() {
continue;
}
let call_id = payload
.get("call_id")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let input_json = extract_codex_function_args(payload);
let tool_calls = vec![ToolCallInsert {
tool_name: name.to_string(),
category: normalize_tool_category(name),
tool_use_id: if call_id.is_empty() {
None
} else {
Some(call_id)
},
input_json: input_json,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
}];
let content = format!("[{}]", name);
let content_len = content.len() as i64;
messages.push(MessageInsert {
session_id: format!("codex:{}", session_id),
ordinal,
role: "assistant".to_string(),
content,
timestamp: ended_at.map(|t| t.to_rfc3339()),
has_thinking: false,
has_tool_use: true,
content_length: content_len,
is_system: false,
model: current_model.clone(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "codex".to_string(),
source_subtype: "function_call".to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
"function_call_output" => {
}
_ => {
let role = payload.get("role").and_then(|r| r.as_str()).unwrap_or("");
if role != "user" && role != "assistant" {
continue;
}
let content = extract_codex_content(payload);
if content.trim().is_empty() {
continue;
}
if role == "user" && first_message.is_none() {
first_message = Some(truncate_str(
&content.replace('\n', " "),
MAX_FIRST_MESSAGE_LEN,
));
}
messages.push(MessageInsert {
session_id: format!("codex:{}", session_id),
ordinal,
role: role.to_string(),
content,
timestamp: ended_at.map(|t| t.to_rfc3339()),
has_thinking: false,
has_tool_use: false,
content_length: 0,
is_system: false,
model: current_model.clone(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "codex".to_string(),
source_subtype: role.to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls: Vec::new(),
});
ordinal += 1;
}
}
}
}
"event_msg" => {
}
_ => {}
}
}
session_id = format!("codex:{}", session_id);
let user_count: i64 = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let (total_output, peak_context) = sum_token_usage(&messages);
Ok(ParseResult {
session: SessionInsert {
id: session_id,
machine: machine.to_string(),
agent: "codex".to_string(),
first_message,
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(path.to_string_lossy().to_string()),
file_size: Some(file_size),
file_mtime: Some(file_mtime),
file_hash: None,
parent_session_id: None,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd,
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
})
}
fn extract_codex_content(payload: &Value) -> String {
let mut texts = Vec::new();
if let Some(content) = payload.get("content") {
if let Some(arr) = content.as_array() {
for block in arr {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"input_text" | "output_text" | "text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
_ => {}
}
}
}
}
texts.join("\n")
}
fn extract_codex_function_args(payload: &Value) -> Option<String> {
for key in ["arguments", "input"] {
if let Some(arg) = payload.get(key) {
match arg {
Value::String(s) => {
let trimmed = s.trim();
if !trimmed.is_empty() && trimmed != "{}" && trimmed != "[]" {
if serde_json::from_str::<Value>(trimmed).is_ok() {
return Some(trimmed.to_string());
}
}
}
Value::Object(_) => {
let raw = arg.to_string();
if raw != "{}" && raw != "[]" {
return Some(raw);
}
}
_ => {}
}
}
}
None
}
pub fn discover_codex_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let sessions_dir = home.join(".codex").join("sessions");
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for year_entry in std::fs::read_dir(&sessions_dir)? {
let year_entry = year_entry?;
let year_path = year_entry.path();
if !year_path.is_dir() {
continue;
}
for month_entry in std::fs::read_dir(&year_path)? {
let month_entry = month_entry?;
let month_path = month_entry.path();
if !month_path.is_dir() {
continue;
}
for day_entry in std::fs::read_dir(&month_path)? {
let day_entry = day_entry?;
let day_path = day_entry.path();
if !day_path.is_dir() {
continue;
}
for file_entry in std::fs::read_dir(&day_path)? {
let file_entry = file_entry?;
let file_path = file_entry.path();
if !file_path.is_file() {
continue;
}
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let session_id = file_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
files.push(DiscoveredFile {
path: file_path,
cwd: None,
session_id,
agent_type: AgentType::Codex,
});
}
}
}
}
Ok(files)
}
pub fn parse_gemini_session(path: &Path, cwd: Option<&str>, machine: &str) -> Result<ParseResult> {
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let data: Value = serde_json::from_str(&content)
.with_context(|| format!("parse JSON from {}", path.display()))?;
let session_id = data
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let start_time = data
.get("startTime")
.and_then(|v| v.as_str())
.and_then(parse_timestamp)
.unwrap_or_else(chrono::Utc::now);
let last_updated = data
.get("lastUpdated")
.and_then(|v| v.as_str())
.and_then(parse_timestamp)
.unwrap_or_else(chrono::Utc::now);
let mut messages: Vec<MessageInsert> = Vec::new();
let mut first_message: Option<String> = None;
let mut ordinal: i64 = 0;
if let Some(msgs_array) = data.get("messages").and_then(|v| v.as_array()) {
for msg in msgs_array {
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("");
if msg_type != "user" && msg_type != "gemini" {
continue;
}
let role = if msg_type == "gemini" {
"assistant"
} else {
"user"
};
let (content, has_thinking, has_tool_use, tool_calls) = extract_gemini_content(msg);
if content.trim().is_empty() && tool_calls.is_empty() {
continue;
}
if role == "user" && first_message.is_none() {
first_message = Some(truncate_str(
&content.replace('\n', " "),
MAX_FIRST_MESSAGE_LEN,
));
}
let ts = msg
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
let model = msg
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let (context_tokens, output_tokens, token_usage) = extract_gemini_tokens(msg);
messages.push(MessageInsert {
session_id: format!("gemini:{}", session_id),
ordinal,
role: role.to_string(),
content,
timestamp: ts.map(|t| t.to_rfc3339()),
has_thinking,
has_tool_use,
content_length: 0,
is_system: false,
model,
token_usage,
context_tokens,
output_tokens,
has_context_tokens: context_tokens > 0,
has_output_tokens: output_tokens > 0,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "gemini".to_string(),
source_subtype: msg_type.to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
}
let user_count: i64 = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let (total_output, peak_context) = sum_token_usage(&messages);
Ok(ParseResult {
session: SessionInsert {
id: format!("gemini:{}", session_id),
machine: machine.to_string(),
agent: "gemini".to_string(),
first_message,
started_at: Some(start_time.to_rfc3339()),
ended_at: Some(last_updated.to_rfc3339()),
message_count: messages.len() as i64,
user_message_count: user_count,
file_path: Some(path.to_string_lossy().to_string()),
file_size: None,
file_mtime: None,
file_hash: None,
parent_session_id: None,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: cwd.unwrap_or_default().to_string(),
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
})
}
fn extract_gemini_content(msg: &Value) -> (String, bool, bool, Vec<ToolCallInsert>) {
let mut texts = Vec::new();
let mut has_thinking = false;
let mut has_tool_use = false;
let mut tool_calls = Vec::new();
if let Some(thoughts) = msg.get("thoughts").and_then(|v| v.as_array()) {
for thought in thoughts {
has_thinking = true;
let desc = thought
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let subj = thought
.get("subject")
.and_then(|v| v.as_str())
.unwrap_or("");
if !desc.is_empty() {
if subj.is_empty() {
texts.push(format!("[Thinking]\n{}\n[/Thinking]", desc));
} else {
texts.push(format!("[Thinking]\n{}\n{}\n[/Thinking]", subj, desc));
}
}
}
}
if let Some(content) = msg.get("content") {
match content {
Value::String(s) => {
if !s.is_empty() {
texts.push(s.to_string());
}
}
Value::Array(arr) => {
for part in arr {
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
}
_ => {}
}
}
if let Some(calls) = msg.get("toolCalls").and_then(|v| v.as_array()) {
for tc in calls {
has_tool_use = true;
let name = tc
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tc_id = tc
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let args = tc.get("args").map(|v| v.to_string());
tool_calls.push(ToolCallInsert {
tool_name: name.clone(),
category: normalize_tool_category(&name),
tool_use_id: if tc_id.is_empty() { None } else { Some(tc_id) },
input_json: args,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
let display = format_gemini_tool_call(tc);
if !display.is_empty() {
texts.push(display);
}
}
}
(texts.join("\n\n"), has_thinking, has_tool_use, tool_calls)
}
fn format_gemini_tool_call(tc: &Value) -> String {
let name = tc.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = tc.get("args");
match name {
"read_file" => {
if let Some(path) = args
.and_then(|a| a.get("file_path"))
.and_then(|v| v.as_str())
{
return format!("[Read: {}]", path);
}
}
"write_file" => {
if let Some(path) = args
.and_then(|a| a.get("file_path"))
.and_then(|v| v.as_str())
{
return format!("[Write: {}]", path);
}
}
"edit_file" | "replace" => {
if let Some(path) = args
.and_then(|a| a.get("file_path"))
.and_then(|v| v.as_str())
{
return format!("[Edit: {}]", path);
}
}
"run_command" | "execute_command" | "run_shell_command" => {
if let Some(cmd) = args.and_then(|a| a.get("command")).and_then(|v| v.as_str()) {
return format!("[Bash]\n$ {}", cmd);
}
}
"list_directory" => {
if let Some(path) = args
.and_then(|a| a.get("dir_path"))
.and_then(|v| v.as_str())
{
return format!("[List: {}]", path);
}
}
"search_files" | "grep" | "grep_search" => {
let query = args
.and_then(|a| a.get("query"))
.and_then(|v| v.as_str())
.or_else(|| args.and_then(|a| a.get("pattern")).and_then(|v| v.as_str()))
.unwrap_or("");
return format!("[Grep: {}]", query);
}
"glob" => {
if let Some(pattern) = args.and_then(|a| a.get("pattern")).and_then(|v| v.as_str()) {
return format!("[Glob: {}]", pattern);
}
}
_ => {}
}
if !name.is_empty() {
format!("[Tool: {}]", name)
} else {
String::new()
}
}
fn extract_gemini_tokens(msg: &Value) -> (i64, i64, String) {
let tokens = match msg.get("tokens") {
Some(v) => v,
None => return (0, 0, String::new()),
};
let input = tokens.get("input").and_then(|v| v.as_i64()).unwrap_or(0);
let output = tokens.get("output").and_then(|v| v.as_i64()).unwrap_or(0);
let cached = tokens.get("cached").and_then(|v| v.as_i64()).unwrap_or(0);
if input == 0 && output == 0 && cached == 0 {
return (0, 0, String::new());
}
let usage = serde_json::json!({
"input_tokens": input,
"output_tokens": output,
"cache_read_input_tokens": cached,
"cache_creation_input_tokens": 0i64,
});
(input + cached, output, usage.to_string())
}
pub fn discover_gemini_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let gemini_dir = home.join(".gemini");
if !gemini_dir.exists() {
return Ok(Vec::new());
}
let tmp_dir = gemini_dir.join("tmp");
if !tmp_dir.is_dir() {
return Ok(Vec::new());
}
let folders = gemini_folders(&gemini_dir);
let mut files = Vec::new();
let hash_dirs = match std::fs::read_dir(&tmp_dir) {
Ok(d) => d,
Err(_) => return Ok(files),
};
for entry in hash_dirs.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let hash = entry.file_name();
let hash_str = hash.to_string_lossy();
let chats_dir = path.join("chats");
if !chats_dir.is_dir() {
continue;
}
let cwd = folders.get(hash_str.as_ref()).cloned();
let chat_entries = match std::fs::read_dir(&chats_dir) {
Ok(d) => d,
Err(_) => continue,
};
for chat_entry in chat_entries.flatten() {
let chat_path = chat_entry.path();
if chat_path.is_dir() {
continue;
}
let name = chat_entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("session-") || !name_str.ends_with(".json") {
continue;
}
let session_id = name_str
.strip_prefix("session-")
.unwrap_or(&name_str)
.strip_suffix(".json")
.unwrap_or(&name_str)
.to_string();
files.push(DiscoveredFile {
path: chat_path,
cwd: cwd.clone(),
session_id: format!("gemini:{}", session_id),
agent_type: AgentType::Gemini,
});
}
}
Ok(files)
}
fn gemini_folders(gemini_dir: &Path) -> HashMap<String, String> {
let mut map = HashMap::new();
let mut add = |abs_path: &str| {
map.entry(gemini_path_hash(abs_path))
.or_insert_with(|| abs_path.to_string());
};
let projects_path = gemini_dir.join("projects.json");
if let Ok(data) = std::fs::read_to_string(&projects_path) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&data) {
if let Some(projects) = parsed.get("projects").and_then(|v| v.as_object()) {
for abs_path in projects.keys() {
add(abs_path);
}
}
}
}
let trusted_path = gemini_dir.join("trustedFolders.json");
if let Ok(data) = std::fs::read_to_string(&trusted_path) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&data) {
if let Some(arr) = parsed.get("trustedFolders").and_then(|v| v.as_array()) {
for abs_path in arr.iter().filter_map(|folder| folder.as_str()) {
add(abs_path);
}
}
}
}
map
}
fn gemini_path_hash(path: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(path.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn parse_cursor_session(path: &Path, machine: &str) -> Result<ParseResult> {
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut messages: Vec<MessageInsert> = if is_cursor_jsonl(&content) {
parse_cursor_jsonl_messages(&content)?
} else {
parse_cursor_text_messages(&content)?
};
for (ordinal, msg) in messages.iter_mut().enumerate() {
msg.ordinal = ordinal as i64;
}
if messages.is_empty() {
anyhow::bail!("no messages found in cursor session");
}
let first_message: Option<String> = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.next()
.map(|m| truncate_str(&m.content.replace('\n', " "), MAX_FIRST_MESSAGE_LEN));
let user_count: i64 = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let (total_output, peak_context) = sum_token_usage(&messages);
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let metadata = std::fs::metadata(path).ok();
let file_size = metadata.as_ref().map(|m| m.len() as i64);
let file_mtime = metadata
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64);
Ok(ParseResult {
session: SessionInsert {
id: format!("cursor:{}", session_id),
machine: machine.to_string(),
agent: "cursor".to_string(),
first_message,
started_at: None,
ended_at: None,
message_count: messages.len() as i64,
user_message_count: user_count,
file_path: Some(path.to_string_lossy().to_string()),
file_size,
file_mtime,
file_hash: None,
parent_session_id: None,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: String::new(),
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
})
}
fn is_cursor_jsonl(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if serde_json::from_str::<Value>(trimmed).is_ok() {
return true;
}
break;
}
false
}
fn parse_cursor_jsonl_messages(content: &str) -> Result<Vec<MessageInsert>> {
let mut messages = Vec::new();
let mut ordinal: i64 = 0;
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,
};
let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("");
if role != "user" && role != "assistant" {
continue;
}
let msg_content = v.get("message").and_then(|m| m.get("content"));
let (text, has_thinking, has_tool_use, tool_calls) = if role == "user" {
(
extract_jsonl_user_content(msg_content),
false,
false,
Vec::new(),
)
} else {
extract_text_content_with_tools(msg_content)
};
if text.trim().is_empty() && tool_calls.is_empty() {
continue;
}
messages.push(MessageInsert {
session_id: String::new(),
ordinal,
role: role.to_string(),
content: text,
timestamp: None,
has_thinking,
has_tool_use,
content_length: 0,
is_system: false,
model: String::new(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "cursor".to_string(),
source_subtype: role.to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
Ok(messages)
}
fn extract_jsonl_user_content(content: Option<&Value>) -> String {
let content = match content {
Some(v) => v,
None => return String::new(),
};
match content {
Value::String(s) => extract_user_query_from_text(s),
Value::Array(arr) => {
let mut texts = Vec::new();
for part in arr {
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
extract_user_query_from_text(&texts.join("\n"))
}
_ => String::new(),
}
}
fn extract_user_query_from_text(text: &str) -> String {
let text = text.trim();
if let Some(start) = text.find("<user_query>") {
if let Some(end) = text.find("</user_query>") {
if end > start {
return text[start + 12..end].trim().to_string();
}
}
}
text.to_string()
}
fn extract_text_content_with_tools(
content: Option<&Value>,
) -> (String, bool, bool, Vec<ToolCallInsert>) {
let content = match content {
Some(v) => v,
None => return (String::new(), false, false, Vec::new()),
};
let mut texts = Vec::new();
let mut has_thinking = false;
let mut has_tool_use = false;
let mut tool_calls = Vec::new();
if let Some(arr) = content.as_array() {
for block in arr {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" | "input_text" | "output_text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
"thinking" => {
has_thinking = true;
if let Some(text) = block.get("thinking").and_then(|t| t.as_str()) {
if !text.is_empty() {
texts.push(format!("[Thinking]\n{}\n[/Thinking]", text));
}
}
}
"tool_use" => {
has_tool_use = true;
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let tool_use_id = block
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let input_json = block.get("input").map(|i| i.to_string());
tool_calls.push(ToolCallInsert {
tool_name: name.clone(),
category: normalize_tool_category(&name),
tool_use_id: if tool_use_id.is_empty() {
None
} else {
Some(tool_use_id)
},
input_json,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
if !name.is_empty() {
texts.push(format!("[Tool call: {}]", name));
}
}
_ => {}
}
}
} else if let Some(s) = content.as_str() {
texts.push(s.to_string());
}
(texts.join("\n"), has_thinking, has_tool_use, tool_calls)
}
fn parse_cursor_text_messages(content: &str) -> Result<Vec<MessageInsert>> {
let lines: Vec<&str> = content.lines().collect();
let mut messages = Vec::new();
let mut ordinal: i64 = 0;
let mut current_role: Option<String> = None;
let mut current_lines: Vec<&str> = Vec::new();
for line in &lines {
let trimmed = line.trim();
if trimmed == "user:" || trimmed == "assistant:" {
if let Some(role) = current_role.take() {
if !current_lines.is_empty() || role == "assistant" {
let (text, has_thinking, tool_calls) = if role == "user" {
(
extract_user_query_from_text(¤t_lines.join("\n")),
false,
Vec::new(),
)
} else {
extract_assistant_content(¤t_lines)
};
if !text.trim().is_empty() || !tool_calls.is_empty() {
messages.push(MessageInsert {
session_id: String::new(),
ordinal,
role,
content: text,
timestamp: None,
has_thinking,
has_tool_use: !tool_calls.is_empty(),
content_length: 0,
is_system: false,
model: String::new(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "cursor".to_string(),
source_subtype: "text".to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
}
current_lines.clear();
}
current_role = Some(
if trimmed == "user:" {
"user"
} else {
"assistant"
}
.to_string(),
);
} else if current_role.is_some() {
current_lines.push(line);
}
}
if let Some(role) = current_role.take() {
if !current_lines.is_empty() || role == "assistant" {
let (text, has_thinking, tool_calls) = if role == "user" {
(
extract_user_query_from_text(¤t_lines.join("\n")),
false,
Vec::new(),
)
} else {
extract_assistant_content(¤t_lines)
};
if !text.trim().is_empty() || !tool_calls.is_empty() {
messages.push(MessageInsert {
session_id: String::new(),
ordinal,
role,
content: text,
timestamp: None,
has_thinking,
has_tool_use: !tool_calls.is_empty(),
content_length: 0,
is_system: false,
model: String::new(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "cursor".to_string(),
source_subtype: "text".to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
}
}
}
for (i, msg) in messages.iter_mut().enumerate() {
msg.ordinal = i as i64;
}
Ok(messages)
}
fn extract_assistant_content(lines: &[&str]) -> (String, bool, Vec<ToolCallInsert>) {
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
let mut has_thinking = false;
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
if trimmed.starts_with("[Thinking]") {
has_thinking = true;
i += 1;
while i < lines.len() && !is_block_end(lines[i]) {
i += 1;
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("[Tool call] ") {
let tool_name = rest.trim().to_string();
if !tool_name.is_empty() {
tool_calls.push(ToolCallInsert {
tool_name: tool_name.clone(),
category: normalize_tool_category(&tool_name),
tool_use_id: None,
input_json: None,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
}
i += 1;
while i < lines.len() && !is_block_end(lines[i]) {
i += 1;
}
continue;
}
if trimmed.starts_with("[Tool result]") {
i += 1;
while i < lines.len() && !is_block_end(lines[i]) {
i += 1;
}
continue;
}
text_parts.push(&lines[i]);
i += 1;
}
let joined_text: String = text_parts
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join("\n");
(joined_text.trim().to_string(), has_thinking, tool_calls)
}
fn is_block_end(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with("[Thinking]")
|| trimmed.starts_with("[Tool call]")
|| trimmed.starts_with("[Tool result]")
{
return true;
}
trimmed
.chars()
.next()
.map_or(false, |c| c != ' ' && c != '\t')
}
pub fn discover_cursor_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let projects_dir = home.join(".cursor").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.is_file() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.is_empty() || name.starts_with('.') {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "txt" && ext != "md" && ext != "transcript" {
continue;
}
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(),
cwd: None,
session_id,
agent_type: AgentType::Cursor,
});
}
Ok(files)
}
pub fn parse_copilot_session(path: &Path, machine: &str) -> Result<ParseResult> {
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let data: Value = serde_json::from_str(&content)
.with_context(|| format!("parse JSON from {}", path.display()))?;
let session_id = data
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let messages = extract_copilot_messages(&data)?;
if messages.is_empty() {
anyhow::bail!("no messages found in copilot session");
}
let first_message: Option<String> = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.next()
.map(|m| truncate_str(&m.content.replace('\n', " "), MAX_FIRST_MESSAGE_LEN));
let user_count: i64 = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let (total_output, peak_context) = sum_token_usage(&messages);
let started_at = data
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
Ok(ParseResult {
session: SessionInsert {
id: format!("copilot:{}", session_id),
machine: machine.to_string(),
agent: "copilot".to_string(),
first_message,
started_at: started_at.map(|t| t.to_rfc3339()),
ended_at: started_at.map(|t| t.to_rfc3339()),
message_count: messages.len() as i64,
user_message_count: user_count,
file_path: Some(path.to_string_lossy().to_string()),
file_size: None,
file_mtime: None,
file_hash: None,
parent_session_id: None,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: String::new(),
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
})
}
fn extract_copilot_messages(data: &Value) -> Result<Vec<MessageInsert>> {
let mut messages = Vec::new();
let mut ordinal: i64 = 0;
let empty_items: &Vec<Value> = &Vec::new();
let items = data
.get("messages")
.and_then(|v| v.as_array())
.or_else(|| data.get("conversationHistory").and_then(|v| v.as_array()))
.unwrap_or(empty_items);
for item in items {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("");
if role != "user" && role != "assistant" && role != "bot" {
continue;
}
let content = extract_copilot_content(item);
if content.trim().is_empty() {
continue;
}
let ts = item
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
let model = item
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let (has_tool_use, tool_calls) = extract_copilot_tools(item);
messages.push(MessageInsert {
session_id: String::new(),
ordinal,
role: if role == "bot" { "assistant" } else { role }.to_string(),
content,
timestamp: ts.map(|t| t.to_rfc3339()),
has_thinking: false,
has_tool_use,
content_length: 0,
is_system: false,
model,
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "copilot".to_string(),
source_subtype: role.to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
Ok(messages)
}
fn extract_copilot_content(item: &Value) -> String {
let mut texts = Vec::new();
if let Some(content) = item.get("content") {
match content {
Value::String(s) => {
if !s.is_empty() {
texts.push(s.to_string());
}
}
Value::Array(arr) => {
for part in arr {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"text" | "output_text" => {
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
_ => {}
}
}
}
_ => {}
}
}
texts.join("\n")
}
fn extract_copilot_tools(item: &Value) -> (bool, Vec<ToolCallInsert>) {
let mut has_tool_use = false;
let mut tool_calls = Vec::new();
if let Some(tools) = item.get("toolCalls").and_then(|v| v.as_array()) {
for tool in tools {
has_tool_use = true;
let name = tool
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let id = tool
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let args = tool.get("arguments").map(|a| a.to_string());
tool_calls.push(ToolCallInsert {
tool_name: name.clone(),
category: normalize_tool_category(&name),
tool_use_id: if id.is_empty() { None } else { Some(id) },
input_json: args,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
}
}
(has_tool_use, tool_calls)
}
pub fn discover_copilot_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let copilot_dir = home.join(".copilot");
if !copilot_dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
let session_state_dir = copilot_dir.join("session-state");
if session_state_dir.exists() {
for entry in walkdir::WalkDir::new(&session_state_dir)
.follow_links(false)
.max_depth(2)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.ends_with(".json") {
continue;
}
let session_id = name.trim_end_matches(".json");
files.push(DiscoveredFile {
path: path.to_path_buf(),
cwd: None,
session_id: session_id.to_string(),
agent_type: AgentType::Copilot,
});
}
}
Ok(files)
}
pub fn parse_pi_session(path: &Path, machine: &str) -> Result<ParseResult> {
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let data: Value = serde_json::from_str(&content)
.with_context(|| format!("parse JSON from {}", path.display()))?;
let session_id = data
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let messages = extract_pi_messages(&data)?;
if messages.is_empty() {
anyhow::bail!("no messages found in pi session");
}
let first_message: Option<String> = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.next()
.map(|m| truncate_str(&m.content.replace('\n', " "), MAX_FIRST_MESSAGE_LEN));
let user_count: i64 = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let (total_output, peak_context) = sum_token_usage(&messages);
let started_at = data
.get("createdAt")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
let ended_at = data
.get("updatedAt")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
Ok(ParseResult {
session: SessionInsert {
id: format!("pi:{}", session_id),
machine: machine.to_string(),
agent: "pi".to_string(),
first_message,
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(path.to_string_lossy().to_string()),
file_size: None,
file_mtime: None,
file_hash: None,
parent_session_id: None,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: String::new(),
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
})
}
fn extract_pi_messages(data: &Value) -> Result<Vec<MessageInsert>> {
let mut messages = Vec::new();
let mut ordinal: i64 = 0;
let empty_items: &Vec<Value> = &Vec::new();
let items = data
.get("messages")
.and_then(|v| v.as_array())
.unwrap_or(empty_items);
for item in items {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("");
if role != "user" && role != "assistant" {
continue;
}
let content = extract_pi_content(item);
if content.trim().is_empty() {
continue;
}
let ts = item
.get("createdAt")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
let (has_tool_use, tool_calls) = extract_pi_tools(item);
messages.push(MessageInsert {
session_id: String::new(),
ordinal,
role: role.to_string(),
content,
timestamp: ts.map(|t| t.to_rfc3339()),
has_thinking: false,
has_tool_use,
content_length: 0,
is_system: false,
model: String::new(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "pi".to_string(),
source_subtype: role.to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
Ok(messages)
}
fn extract_pi_content(item: &Value) -> String {
let mut texts = Vec::new();
if let Some(content) = item.get("content") {
match content {
Value::String(s) => {
if !s.is_empty() {
texts.push(s.to_string());
}
}
Value::Array(arr) => {
for part in arr {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"text" => {
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
"tool_use" => {
let name = part.get("name").and_then(|n| n.as_str()).unwrap_or("");
if !name.is_empty() {
texts.push(format!("[{}]", name));
}
}
_ => {}
}
}
}
_ => {}
}
}
texts.join("\n")
}
fn extract_pi_tools(item: &Value) -> (bool, Vec<ToolCallInsert>) {
let mut has_tool_use = false;
let mut tool_calls = Vec::new();
if let Some(content) = item.get("content").and_then(|v| v.as_array()) {
for part in content {
if part.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
has_tool_use = true;
let name = part
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let id = part
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let input = part.get("input").map(|i| i.to_string());
tool_calls.push(ToolCallInsert {
tool_name: name.clone(),
category: normalize_tool_category(&name),
tool_use_id: if id.is_empty() { None } else { Some(id) },
input_json: input,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
}
}
}
(has_tool_use, tool_calls)
}
pub fn discover_pi_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let pi_dir = home.join(".pi").join("agent").join("sessions");
if !pi_dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(&pi_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "json" && ext != "jsonl" {
continue;
}
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(),
cwd: None,
session_id,
agent_type: AgentType::Pi,
});
}
Ok(files)
}
pub fn parse_openhands_session(path: &Path, machine: &str) -> Result<ParseResult> {
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let data: Value = serde_json::from_str(&content)
.with_context(|| format!("parse JSON from {}", path.display()))?;
let session_id = data
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let messages = extract_openhands_messages(&data)?;
if messages.is_empty() {
anyhow::bail!("no messages found in openhands session");
}
let first_message: Option<String> = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.next()
.map(|m| truncate_str(&m.content.replace('\n', " "), MAX_FIRST_MESSAGE_LEN));
let user_count: i64 = messages
.iter()
.filter(|m| m.role == "user" && !m.content.is_empty())
.count() as i64;
let (total_output, peak_context) = sum_token_usage(&messages);
let started_at = data
.get("created_at")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
let ended_at = data
.get("updated_at")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
Ok(ParseResult {
session: SessionInsert {
id: format!("openhands:{}", session_id),
machine: machine.to_string(),
agent: "openhands".to_string(),
first_message,
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(path.to_string_lossy().to_string()),
file_size: None,
file_mtime: None,
file_hash: None,
parent_session_id: None,
relationship_type: String::new(),
total_output_tokens: total_output,
peak_context_tokens: peak_context,
has_total_output_tokens: total_output > 0,
has_peak_context_tokens: peak_context > 0,
is_automated: false,
cwd: String::new(),
git_branch: String::new(),
source_session_id: String::new(),
source_version: String::new(),
parser_malformed_lines: 0,
is_truncated: false,
},
messages,
})
}
fn extract_openhands_messages(data: &Value) -> Result<Vec<MessageInsert>> {
let mut messages = Vec::new();
let mut ordinal: i64 = 0;
let empty_history: &Vec<Value> = &Vec::new();
let history = data
.get("history")
.and_then(|v| v.as_array())
.or_else(|| data.get("messages").and_then(|v| v.as_array()))
.unwrap_or(empty_history);
for item in history {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("");
if role != "user" && role != "assistant" && role != "agent" {
continue;
}
let content = extract_openhands_content(item);
if content.trim().is_empty() {
continue;
}
let ts = item
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
let (has_tool_use, tool_calls) = extract_openhands_tools(item);
messages.push(MessageInsert {
session_id: String::new(),
ordinal,
role: if role == "agent" { "assistant" } else { role }.to_string(),
content,
timestamp: ts.map(|t| t.to_rfc3339()),
has_thinking: false,
has_tool_use,
content_length: 0,
is_system: false,
model: String::new(),
token_usage: String::new(),
context_tokens: 0,
output_tokens: 0,
has_context_tokens: false,
has_output_tokens: false,
claude_message_id: String::new(),
claude_request_id: String::new(),
source_type: "openhands".to_string(),
source_subtype: role.to_string(),
source_uuid: String::new(),
source_parent_uuid: String::new(),
is_sidechain: false,
is_compact_boundary: false,
tool_calls,
});
ordinal += 1;
}
Ok(messages)
}
fn extract_openhands_content(item: &Value) -> String {
let mut texts = Vec::new();
if let Some(content) = item.get("content") {
match content {
Value::String(s) => {
if !s.is_empty() {
texts.push(s.to_string());
}
}
Value::Array(arr) => {
for part in arr {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"text" => {
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
"tool_call" => {
let name = part.get("name").and_then(|n| n.as_str()).unwrap_or("");
if !name.is_empty() {
texts.push(format!("[{}]", name));
}
}
_ => {}
}
}
}
_ => {}
}
}
texts.join("\n")
}
fn extract_openhands_tools(item: &Value) -> (bool, Vec<ToolCallInsert>) {
let mut has_tool_use = false;
let mut tool_calls = Vec::new();
if let Some(content) = item.get("content").and_then(|v| v.as_array()) {
for part in content {
if part.get("type").and_then(|t| t.as_str()) == Some("tool_call") {
has_tool_use = true;
let name = part
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let id = part
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let args = part.get("parameters").map(|p| p.to_string());
tool_calls.push(ToolCallInsert {
tool_name: name.clone(),
category: normalize_tool_category(&name),
tool_use_id: if id.is_empty() { None } else { Some(id) },
input_json: args,
skill_name: None,
result_content_length: None,
result_content: None,
subagent_session_id: None,
});
}
}
}
(has_tool_use, tool_calls)
}
pub fn discover_openhands_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let conversations_dir = home.join(".openhands").join("conversations");
if !conversations_dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(&conversations_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "json" && ext != "jsonl" {
continue;
}
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(),
cwd: None,
session_id,
agent_type: AgentType::OpenHands,
});
}
Ok(files)
}
pub fn discover_all_sessions() -> Result<Vec<DiscoveredFile>> {
let mut all_files = Vec::new();
if let Ok(claude_files) = discover_claude_sessions() {
all_files.extend(claude_files);
}
if let Ok(codex_files) = discover_codex_sessions() {
all_files.extend(codex_files);
}
if let Ok(gemini_files) = discover_gemini_sessions() {
all_files.extend(gemini_files);
}
if let Ok(cursor_files) = discover_cursor_sessions() {
all_files.extend(cursor_files);
}
if let Ok(copilot_files) = discover_copilot_sessions() {
all_files.extend(copilot_files);
}
if let Ok(pi_files) = discover_pi_sessions() {
all_files.extend(pi_files);
}
if let Ok(openhands_files) = discover_openhands_sessions() {
all_files.extend(openhands_files);
}
if let Ok(opencode_files) = discover_opencode_sessions() {
all_files.extend(opencode_files);
}
all_files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(all_files)
}
pub fn parse_session_by_agent(
path: &Path,
cwd: Option<&str>,
machine: &str,
agent_type: AgentType,
session_id: &str,
) -> Result<ParseResult> {
match agent_type {
AgentType::Claude => {
let results = parse_claude_session(path, machine)?;
results
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("no parse results"))
}
AgentType::Codex => parse_codex_session(path, machine),
AgentType::Gemini => parse_gemini_session(path, cwd, machine),
AgentType::Cursor => parse_cursor_session(path, machine),
AgentType::Copilot => parse_copilot_session(path, machine),
AgentType::Pi => parse_pi_session(path, machine),
AgentType::OpenHands => parse_openhands_session(path, machine),
AgentType::OpenCode => {
let (session, messages) = tokio::runtime::Handle::current()
.block_on(parse_opencode_session(path, session_id, machine))?
.ok_or_else(|| {
anyhow::anyhow!("no parse results for opencode session {}", session_id)
})?;
Ok(ParseResult { session, messages })
}
_ => anyhow::bail!("parser not implemented for agent type: {}", agent_type),
}
}
pub fn discover_opencode_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
let opencode_dir = home.join(".local").join("share").join("opencode");
let db_path = opencode_dir.join("opencode.db");
if !db_path.exists() {
return Ok(Vec::new());
}
let conn = rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)?;
let mut stmt = conn.prepare("SELECT id FROM session ORDER BY time_created")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut files = Vec::new();
for row in rows {
if let Ok(session_id) = row {
files.push(DiscoveredFile {
path: db_path.clone(),
cwd: None,
session_id,
agent_type: AgentType::OpenCode,
});
}
}
Ok(files)
}
pub fn discover_claude_sessions() -> Result<Vec<DiscoveredFile>> {
let home = super::home_dir()?;
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 session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
files.push(DiscoveredFile {
path: path.to_path_buf(),
cwd: None,
session_id,
agent_type: AgentType::Claude,
});
}
Ok(files)
}
#[derive(Debug, Clone)]
pub struct DiscoveredFile {
pub path: std::path::PathBuf,
pub cwd: Option<String>,
pub session_id: String,
pub agent_type: AgentType,
}
#[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]"));
}
}