Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,7 @@ mod tests {
application: Application::ClaudeCode,
date,
project_hash: "proj".into(),
project_path: None,
conversation_hash: "conv".into(),
local_hash: None,
global_hash: "global".into(),
Expand Down
1 change: 1 addition & 0 deletions src/analyzers/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ impl Analyzer for AntigravityCliAnalyzer {
application: Application::AntigravityCli,
date: ts,
project_hash: "".to_string(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: None,
global_hash,
Expand Down
15 changes: 15 additions & 0 deletions src/analyzers/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ pub fn parse_jsonl_file<R: Read>(
let mut fallback_session_name = None;

let mut current_model = None;
let mut current_project_path = None;

// Read entire file at once to avoid per-line allocations
let mut buffer = Vec::new();
Expand All @@ -695,6 +696,9 @@ pub fn parse_jsonl_file<R: Read>(
summaries.insert(summary.leaf_uuid, summary.summary);
}
Ok(ClaudeCodeEntry::Message(entry)) => {
if let Some(cwd) = entry.cwd.as_deref().and_then(normalize_project_path) {
current_project_path = Some(cwd);
}
// Track all UUIDs for summary linking, even if we skip the message
all_uuids.push(entry.uuid.clone());

Expand Down Expand Up @@ -726,6 +730,7 @@ pub fn parse_jsonl_file<R: Read>(
model: model.clone(),
date: timestamp,
project_hash: project_hash.to_string(),
project_path: current_project_path.clone(),
conversation_hash: conversation_hash.to_string(),
stats: Stats::default(), // Will be filled below
role: match role.as_deref() {
Expand Down Expand Up @@ -828,6 +833,16 @@ pub fn parse_jsonl_file<R: Read>(
Ok((messages, summaries, all_uuids, fallback_session_name))
}

fn normalize_project_path(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}

let normalized: PathBuf = Path::new(trimmed).components().collect();
Some(normalized.to_string_lossy().into_owned())
}

// Type alias for token fingerprint
pub type TokenFingerprint = (u64, u64, u64, u64, u64);

Expand Down
1 change: 1 addition & 0 deletions src/analyzers/claude_code_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ mod tests {
application: Application::ClaudeCode,
date: Utc.with_ymd_and_hms(2025, 8, 2, 14, 5, 17).unwrap(),
project_hash: "project".to_string(),
project_path: None,
conversation_hash: conversation.to_string(),
local_hash: Some(local_hash.to_string()),
global_hash: hash.to_string(),
Expand Down
2 changes: 2 additions & 0 deletions src/analyzers/cline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ fn parse_cline_task_directory(task_dir: &Path) -> Result<Vec<ConversationMessage
application: Application::Cline,
date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(local_hash),
global_hash,
Expand Down Expand Up @@ -248,6 +249,7 @@ fn parse_cline_task_directory(task_dir: &Path) -> Result<Vec<ConversationMessage
application: Application::Cline,
date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(local_hash),
global_hash,
Expand Down
61 changes: 57 additions & 4 deletions src/analyzers/codex_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@ pub(crate) fn parse_codex_cli_jsonl_file(
let mut current_tool_call_ids: HashSet<String> = HashSet::with_capacity(20);
let mut session_name: Option<String> = None;
let mut fallback_session_name: Option<String> = None;
let mut project_path: Option<String> = None;
let mut project_hash = String::new();
let mut has_repository_project_id = false;

for line in buffer.split(|&b| b == b'\n') {
// Skip empty lines
Expand All @@ -356,18 +359,37 @@ pub(crate) fn parse_codex_cli_jsonl_file(
"session_meta" => {
// Try to parse the payload as session metadata
let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?;
if let Ok(_session_meta) =
if let Ok(session_meta) =
simd_json::from_slice::<CodexCliSessionMeta>(&mut payload_bytes)
{
session_model =
extract_model_from_value(&wrapper.payload).map(SessionModel::explicit);
project_path = session_meta.cwd.as_deref().and_then(normalize_project_path);
if let Some(repository_url) = session_meta
.git
.as_ref()
.and_then(|git| git.repository_url.as_deref())
.map(str::trim)
.filter(|url| !url.is_empty())
{
project_hash = hash_text(repository_url);
has_repository_project_id = true;
} else if let Some(path) = project_path.as_deref() {
project_hash = hash_text(path);
}
}
}
"turn_context" => {
let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?;
if let Ok(context) =
simd_json::from_slice::<CodexCliTurnContext>(&mut payload_bytes)
{
if let Some(cwd) = context.cwd.as_deref().and_then(normalize_project_path) {
if !has_repository_project_id {
project_hash = hash_text(&cwd);
}
project_path = Some(cwd);
}
if let Some(model_name) = extract_model_from_value(&wrapper.payload) {
session_model = Some(SessionModel::explicit(model_name));
}
Expand Down Expand Up @@ -484,7 +506,8 @@ pub(crate) fn parse_codex_cli_jsonl_file(
local_hash: None,
conversation_hash: hash_text(&session_path_str),
application: Application::CodexCli,
project_hash: "".to_string(),
project_hash: project_hash.clone(),
project_path: project_path.clone(),
model: None,
stats: Stats::default(),
role: MessageRole::User,
Expand Down Expand Up @@ -521,7 +544,8 @@ pub(crate) fn parse_codex_cli_jsonl_file(
local_hash: None,
conversation_hash: hash_text(&session_path_str),
date: wrapper.timestamp,
project_hash: "".to_string(),
project_hash: project_hash.clone(),
project_path: project_path.clone(),
stats: Stats::default(),
role: MessageRole::Assistant,
uuid: None,
Expand Down Expand Up @@ -589,7 +613,8 @@ pub(crate) fn parse_codex_cli_jsonl_file(
local_hash: None,
conversation_hash: hash_text(&session_path_str),
date: wrapper.timestamp,
project_hash: "".to_string(),
project_hash: project_hash.clone(),
project_path: project_path.clone(),
stats,
role: MessageRole::Assistant,
uuid: None,
Expand All @@ -614,6 +639,17 @@ pub(crate) fn parse_codex_cli_jsonl_file(
Ok((entries, detected_model))
}

fn normalize_project_path(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}

let portable = trimmed.replace('\\', "/");
let normalized: PathBuf = Path::new(&portable).components().collect();
Comment thread
mike1858 marked this conversation as resolved.
Some(normalized.to_string_lossy().replace('\\', "/"))
}

fn calculate_cost_from_tokens(
usage: &CodexCliTokenUsage,
model_name: &str,
Expand Down Expand Up @@ -736,3 +772,20 @@ fn normalize_model_name(raw: &str) -> Option<String> {
Some(trimmed.to_string())
}
}

#[cfg(test)]
mod tests {
use super::normalize_project_path;

#[test]
fn project_paths_are_normalized_independently_of_host_separators() {
assert_eq!(
normalize_project_path(r"C:\work\.\splitrail"),
Some("C:/work/splitrail".to_string())
);
assert_eq!(
normalize_project_path("/home/user/./splitrail"),
Some("/home/user/splitrail".to_string())
);
}
}
2 changes: 2 additions & 0 deletions src/analyzers/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ pub(crate) fn parse_copilot_session_file(session_file: &Path) -> Result<Vec<Conv
application: Application::Copilot,
date: user_date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(user_local_hash),
global_hash: user_global_hash,
Expand Down Expand Up @@ -427,6 +428,7 @@ pub(crate) fn parse_copilot_session_file(session_file: &Path) -> Result<Vec<Conv
application: Application::Copilot,
date: assistant_date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(assistant_local_hash),
global_hash: assistant_global_hash,
Expand Down
11 changes: 11 additions & 0 deletions src/analyzers/copilot_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ fn push_copilot_cli_user_message(
user_index: &mut usize,
conversation_hash: &str,
project_hash: &str,
project_path: Option<&str>,
session_name: Option<&String>,
) {
let user_local_hash = format!("{conversation_hash}-cli-user-{}", *user_index);
Expand All @@ -341,6 +342,7 @@ fn push_copilot_cli_user_message(
application: Application::CopilotCli,
date: pending_user.date,
project_hash: project_hash.to_string(),
project_path: project_path.map(str::to_string),
conversation_hash: conversation_hash.to_string(),
local_hash: Some(user_local_hash),
global_hash: user_global_hash,
Expand Down Expand Up @@ -552,6 +554,7 @@ fn flush_copilot_cli_turn(
assistant_index: &mut usize,
conversation_hash: &str,
project_hash: &str,
project_path: Option<&str>,
session_name: Option<&String>,
) {
let Some(turn) = current_turn.take() else {
Expand All @@ -571,6 +574,7 @@ fn flush_copilot_cli_turn(
user_index,
conversation_hash,
project_hash,
project_path,
session_name,
);
pending_user.emitted = true;
Expand Down Expand Up @@ -614,6 +618,7 @@ fn flush_copilot_cli_turn(
application: Application::CopilotCli,
date: assistant_date,
project_hash: project_hash.to_string(),
project_path: project_path.map(str::to_string),
conversation_hash: conversation_hash.to_string(),
local_hash: Some(assistant_local_hash),
global_hash: assistant_global_hash,
Expand Down Expand Up @@ -719,6 +724,7 @@ pub(crate) fn parse_copilot_cli_session_file(
&mut assistant_index,
&conversation_hash,
&project_hash,
workspace_path.as_deref(),
session_name.as_ref(),
);

Expand All @@ -731,6 +737,7 @@ pub(crate) fn parse_copilot_cli_session_file(
&mut user_index,
&conversation_hash,
&project_hash,
workspace_path.as_deref(),
session_name.as_ref(),
);
}
Expand Down Expand Up @@ -769,6 +776,7 @@ pub(crate) fn parse_copilot_cli_session_file(
&mut assistant_index,
&conversation_hash,
&project_hash,
workspace_path.as_deref(),
session_name.as_ref(),
);

Expand All @@ -795,6 +803,7 @@ pub(crate) fn parse_copilot_cli_session_file(
&mut assistant_index,
&conversation_hash,
&project_hash,
workspace_path.as_deref(),
session_name.as_ref(),
);
}
Expand Down Expand Up @@ -1051,6 +1060,7 @@ pub(crate) fn parse_copilot_cli_session_file(
&mut assistant_index,
&conversation_hash,
&project_hash,
workspace_path.as_deref(),
session_name.as_ref(),
);

Expand All @@ -1063,6 +1073,7 @@ pub(crate) fn parse_copilot_cli_session_file(
&mut user_index,
&conversation_hash,
&project_hash,
workspace_path.as_deref(),
session_name.as_ref(),
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/analyzers/deepseek_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ fn parse_session_reader<R: Read>(path: &Path, reader: R) -> Result<Vec<Conversat
application: Application::DeepSeekHarness,
date,
project_hash: hash_text(&project_id),
project_path: None,
conversation_hash: hash_text(canonical_session),
local_hash: Some(message_id.clone()),
global_hash: hash_text(&format!("deepseek-harness:{message_id}")),
Expand Down Expand Up @@ -367,6 +368,7 @@ fn parse_session_reader<R: Read>(path: &Path, reader: R) -> Result<Vec<Conversat
application: Application::DeepSeekHarness,
date,
project_hash: hash_text(&project_id),
project_path: None,
conversation_hash: hash_text(canonical_session),
local_hash: Some(message_id.clone()),
global_hash: hash_text(&format!("deepseek-harness:{message_id}")),
Expand Down
2 changes: 2 additions & 0 deletions src/analyzers/gemini_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ fn messages_from_session(
date: timestamp,
application: Application::GeminiCli,
project_hash: project_hash.clone(),
project_path: None,
local_hash: None,
global_hash: hash_text(&format!(
"{}_{}",
Expand Down Expand Up @@ -380,6 +381,7 @@ fn messages_from_session(
)),
date: timestamp,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
stats,
role: MessageRole::Assistant,
Expand Down
1 change: 1 addition & 0 deletions src/analyzers/grok.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ pub fn parse_chat_history_file(path: &Path) -> Result<Vec<ConversationMessage>>
application: Application::Grok,
date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(format!("{conversation_hash}:{line_index}")),
global_hash: hash_text(&format!("{file_path}:{line_index}")),
Expand Down
2 changes: 2 additions & 0 deletions src/analyzers/kilo_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ fn parse_kilo_code_task_directory(task_dir: &Path) -> Result<Vec<ConversationMes
application: Application::KiloCode,
date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(local_hash),
global_hash,
Expand Down Expand Up @@ -229,6 +230,7 @@ fn parse_kilo_code_task_directory(task_dir: &Path) -> Result<Vec<ConversationMes
application: Application::KiloCode,
date,
project_hash: project_hash.clone(),
project_path: None,
conversation_hash: conversation_hash.clone(),
local_hash: Some(local_hash),
global_hash,
Expand Down
1 change: 1 addition & 0 deletions src/analyzers/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ pub(crate) fn build_conversation_message(
application,
date,
project_hash,
project_path: None,
conversation_hash,
local_hash,
global_hash,
Expand Down
Loading
Loading