Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
43 changes: 39 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,16 @@ 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 normalized: PathBuf = Path::new(trimmed).components().collect();
Some(normalized.to_string_lossy().into_owned())
}

fn calculate_cost_from_tokens(
usage: &CodexCliTokenUsage,
model_name: &str,
Expand Down
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
41 changes: 41 additions & 0 deletions src/analyzers/opencode_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ fn to_conversation_message(
application,
date,
project_hash,
project_path: project.map(|project| project.worktree.clone()),
conversation_hash,
local_hash,
global_hash,
Expand Down Expand Up @@ -957,4 +958,44 @@ mod tests {
assert_eq!(stats.tool_calls, 0);
assert_eq!(stats.files_read, 0);
}

#[test]
fn test_conversation_message_preserves_project_worktree() {
let mut project_json = br#"{
"id": "project-1",
"worktree": "/work/splitrail",
"time": { "created": 0 }
}"#
.to_vec();
let project: Project = simd_json::from_slice(&mut project_json).unwrap();
let mut session_json = br#"{
"id": "session-1",
"projectID": "project-1",
"directory": "/work/splitrail",
"time": { "created": 0, "updated": 0 }
}"#
.to_vec();
let session: Session = simd_json::from_slice(&mut session_json).unwrap();
let mut message_json = br#"{
"id": "message-1",
"sessionID": "session-1",
"role": "user",
"time": { "created": 0 }
}"#
.to_vec();
let message: Message = simd_json::from_slice(&mut message_json).unwrap();
let projects = HashMap::from([(project.id.clone(), project)]);
let sessions = HashMap::from([(session.id.clone(), session)]);

let converted = to_conversation_message(
message,
&sessions,
&projects,
Path::new("/nonexistent"),
Application::OpenCode,
"opencode",
);

assert_eq!(converted.project_path.as_deref(), Some("/work/splitrail"));
}
}
Loading
Loading