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
7 changes: 5 additions & 2 deletions src/authorship/internal_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,12 @@ impl PromptDbRecord {
}
}

// Fallback: if no user message, try first assistant message
// Fallback: if no user message, try first AI message
for message in &self.messages.messages {
if let Message::Assistant { text, .. } = message {
if let Message::Assistant { text, .. }
| Message::Thinking { text, .. }
| Message::Plan { text, .. } = message
{
if text.len() <= max_length {
return text.clone();
} else {
Expand Down
34 changes: 29 additions & 5 deletions src/authorship/prompt_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ pub fn update_prompt_from_tool(
current_model: &str,
) -> PromptUpdateResult {
match tool {
"cursor" => update_cursor_prompt(external_thread_id),
"cursor" => update_cursor_prompt(external_thread_id, agent_metadata, current_model),
"claude" => update_claude_prompt(agent_metadata, current_model),
"gemini" => update_gemini_prompt(agent_metadata, current_model),
"github-copilot" => update_github_copilot_prompt(agent_metadata, current_model),
Expand All @@ -179,11 +179,35 @@ pub fn update_prompt_from_tool(
}

/// Update Cursor prompt by fetching from Cursor's database
fn update_cursor_prompt(conversation_id: &str) -> PromptUpdateResult {
let res = CursorPreset::fetch_latest_cursor_conversation(conversation_id);
fn update_cursor_prompt(
conversation_id: &str,
metadata: Option<&HashMap<String, String>>,
current_model: &str,
) -> PromptUpdateResult {
// For Cursor, we check the env var first (it represents the current database state),
// then fall back to metadata (stored during checkpoint for git hook subprocesses
// which don't inherit env vars).
let res = if let Ok(env_db_path) = std::env::var("GIT_AI_CURSOR_GLOBAL_DB_PATH") {
// Environment variable takes precedence (allows resync to use updated database)
CursorPreset::fetch_cursor_conversation_from_db(
std::path::Path::new(&env_db_path),
conversation_id,
)
} else if let Some(db_path) = metadata.and_then(|m| m.get("__test_cursor_db_path")) {
// Fall back to metadata path (for git hook subprocesses in tests)
CursorPreset::fetch_cursor_conversation_from_db(
std::path::Path::new(db_path),
conversation_id,
)
} else {
// Use default Cursor database location
CursorPreset::fetch_latest_cursor_conversation(conversation_id)
};
match res {
Ok(Some((latest_transcript, latest_model))) => {
PromptUpdateResult::Updated(latest_transcript, latest_model)
Ok(Some((latest_transcript, _db_model))) => {
// For Cursor, preserve the model from the checkpoint (which came from hook input)
// rather than using the database model
PromptUpdateResult::Updated(latest_transcript, current_model.to_string())
}
Ok(None) => PromptUpdateResult::Unchanged,
Err(e) => {
Expand Down
5 changes: 4 additions & 1 deletion src/authorship/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,10 @@ pub fn redact_secrets_from_prompts(prompts: &mut BTreeMap<String, PromptRecord>)
for record in prompts.values_mut() {
for message in &mut record.messages {
match message {
Message::User { text, .. } | Message::Assistant { text, .. } => {
Message::User { text, .. }
| Message::Assistant { text, .. }
| Message::Thinking { text, .. }
| Message::Plan { text, .. } => {
let (redacted, count) = redact_secrets_in_text(text);
*text = redacted;
total_redactions += count;
Expand Down
8 changes: 8 additions & 0 deletions src/authorship/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,14 @@ fn calculate_waiting_time(transcript: &crate::authorship::transcript::AiTranscri
Message::Assistant {
timestamp: Some(ai_ts),
..
}
| Message::Thinking {
timestamp: Some(ai_ts),
..
}
| Message::Plan {
timestamp: Some(ai_ts),
..
},
) = (&messages[i], &messages[i + 1])
{
Expand Down
29 changes: 27 additions & 2 deletions src/authorship/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ pub enum Message {
#[serde(skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
Thinking {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
Plan {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
ToolUse {
name: String,
input: serde_json::Value,
Expand All @@ -34,6 +44,16 @@ impl Message {
Message::Assistant { text, timestamp }
}

/// Create a thinking message
pub fn thinking(text: String, timestamp: Option<String>) -> Self {
Message::Thinking { text, timestamp }
}

/// Create a plan message
pub fn plan(text: String, timestamp: Option<String>) -> Self {
Message::Plan { text, timestamp }
}

/// Create a tool use message
pub fn tool_use(name: String, input: serde_json::Value) -> Self {
Message::ToolUse {
Expand All @@ -43,11 +63,14 @@ impl Message {
}
}

/// Get the text content if this is a user or assistant message
/// Get the text content if this is a user or AI text message
#[allow(dead_code)]
pub fn text(&self) -> Option<&String> {
match self {
Message::User { text, .. } | Message::Assistant { text, .. } => Some(text),
Message::User { text, .. }
| Message::Assistant { text, .. }
| Message::Thinking { text, .. }
| Message::Plan { text, .. } => Some(text),
Message::ToolUse { .. } => None,
}
}
Expand All @@ -63,6 +86,8 @@ impl Message {
match self {
Message::User { timestamp, .. }
| Message::Assistant { timestamp, .. }
| Message::Thinking { timestamp, .. }
| Message::Plan { timestamp, .. }
| Message::ToolUse { timestamp, .. } => timestamp.as_ref(),
}
}
Expand Down
28 changes: 24 additions & 4 deletions src/commands/checkpoint_agent/agent_presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -819,9 +819,21 @@ impl AgentCheckpointPreset for CursorPreset {
model,
};

// Store cursor database path in metadata for refetching during post-commit.
// This is only needed when GIT_AI_CURSOR_GLOBAL_DB_PATH env var is set (i.e., in tests),
// because the env var isn't passed to git hook subprocesses.
let agent_metadata = if std::env::var("GIT_AI_CURSOR_GLOBAL_DB_PATH").is_ok() {
Some(HashMap::from([(
"__test_cursor_db_path".to_string(),
global_db.to_string_lossy().to_string(),
)]))
} else {
None
};

Ok(AgentRunResult {
agent_id,
agent_metadata: None,
agent_metadata,
checkpoint_kind: CheckpointKind::AiAgent,
transcript: Some(transcript),
repo_working_dir: Some(repo_working_dir),
Expand Down Expand Up @@ -867,17 +879,25 @@ impl CursorPreset {
conversation_id: &str,
) -> Result<Option<(AiTranscript, String)>, GitAiError> {
let global_db = Self::cursor_global_database_path()?;
if !global_db.exists() {
Self::fetch_cursor_conversation_from_db(&global_db, conversation_id)
}

/// Fetch a Cursor conversation from a specific database path
pub fn fetch_cursor_conversation_from_db(
db_path: &std::path::Path,
conversation_id: &str,
) -> Result<Option<(AiTranscript, String)>, GitAiError> {
if !db_path.exists() {
return Ok(None);
}

// Fetch composer payload
let composer_payload = Self::fetch_composer_payload(&global_db, conversation_id)?;
let composer_payload = Self::fetch_composer_payload(db_path, conversation_id)?;

// Extract transcript and model
let transcript_data = Self::transcript_data_from_composer_payload(
&composer_payload,
&global_db,
db_path,
conversation_id,
)?;

Expand Down
13 changes: 12 additions & 1 deletion src/commands/prompt_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,18 @@ fn format_messages_for_display(
Style::default().fg(Color::Green),
)));
}
Message::Thinking { text, .. } => {
all_lines.push(Line::from(Span::styled(
format!("Thinking: {}", text),
Style::default().fg(Color::Magenta),
)));
}
Message::Plan { text, .. } => {
all_lines.push(Line::from(Span::styled(
format!("Plan: {}", text),
Style::default().fg(Color::Blue),
)));
}
Message::ToolUse { name, .. } => {
all_lines.push(Line::from(Span::styled(
format!("Tool: {}", name),
Expand Down Expand Up @@ -562,4 +574,3 @@ fn render_preview_page(f: &mut Frame, state: &PromptPickerState) {
.alignment(Alignment::Center);
f.render_widget(footer, chunks[2]);
}

8 changes: 8 additions & 0 deletions src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,14 @@ fn calculate_waiting_time(transcript: &crate::authorship::transcript::AiTranscri
Message::Assistant {
timestamp: Some(ai_ts),
..
}
| Message::Thinking {
timestamp: Some(ai_ts),
..
}
| Message::Plan {
timestamp: Some(ai_ts),
..
},
) = (&messages[i], &messages[i + 1])
{
Expand Down
92 changes: 79 additions & 13 deletions src/git/repo_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,23 +312,58 @@ impl PersistedWorkingLog {

/* append checkpoint */
pub fn append_checkpoint(&self, checkpoint: &Checkpoint) -> Result<(), GitAiError> {
let checkpoints_file = self.dir.join("checkpoints.jsonl");

// Serialize checkpoint to JSON and append to JSONL file
let json_line = serde_json::to_string(checkpoint)?;
// Read existing checkpoints
let mut checkpoints = self.read_all_checkpoints().unwrap_or_default();

// Create a copy, potentially without transcript to reduce storage size.
// Transcripts are refetched in update_prompts_to_latest() before post-commit
// using tool-specific sources (transcript_path for Claude, cursor_db_path for Cursor, etc.)
//
// Tools that DON'T support refetch (transcript must be kept):
// - "opencode" - uses agent-v1 format, transcript provided inline
// - "mock_ai" - test preset, transcript not stored externally
// - Any other agent-v1 custom tools (detected by lack of tool-specific metadata)
let mut storage_checkpoint = checkpoint.clone();
let tool = checkpoint
.agent_id
.as_ref()
.map(|a| a.tool.as_str())
.unwrap_or("");
let metadata = &checkpoint.agent_metadata;

// Blacklist: tools that cannot refetch transcripts
let cannot_refetch = match tool {
"opencode" | "mock_ai" => true,
// human checkpoints have no transcript anyway
"human" => false,
// For other tools, check if they have the necessary metadata for refetching
// cursor can always refetch from its database
"cursor" => false,
// claude, gemini, continue-cli need transcript_path
"claude" | "gemini" | "continue-cli" => {
metadata.as_ref().and_then(|m| m.get("transcript_path")).is_none()
}
// github-copilot needs chat_session_path
"github-copilot" => {
metadata.as_ref().and_then(|m| m.get("chat_session_path")).is_none()
}
// Unknown tools (like custom agent-v1 tools) can't refetch
_ => true,
};

// Open file in append mode and write the JSON line
use std::fs::OpenOptions;
use std::io::Write;
if !cannot_refetch {
storage_checkpoint.transcript = None;
}

let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&checkpoints_file)?;
// Add the new checkpoint
checkpoints.push(storage_checkpoint);

writeln!(file, "{}", json_line)?;
// Prune char-level attributions from older checkpoints for the same files
// Only the most recent checkpoint per file needs char-level precision
self.prune_old_char_attributions(&mut checkpoints);

Ok(())
// Write all checkpoints back
self.write_all_checkpoints(&checkpoints)
}

pub fn read_all_checkpoints(&self) -> Result<Vec<Checkpoint>, GitAiError> {
Expand Down Expand Up @@ -409,7 +444,38 @@ impl PersistedWorkingLog {
Ok(migrated_checkpoints)
}

/// Remove char-level attributions from all but the most recent checkpoint per file.
/// This reduces storage size while preserving precision for the entries that matter.
/// Only the most recent checkpoint entry for each file is used when computing new entries.
fn prune_old_char_attributions(&self, checkpoints: &mut [Checkpoint]) {
// Track which checkpoint index has the most recent entry for each file
// Iterate from newest to oldest
let mut newest_for_file: HashMap<String, usize> = HashMap::new();

for (checkpoint_idx, checkpoint) in checkpoints.iter().enumerate().rev() {
for entry in &checkpoint.entries {
newest_for_file
.entry(entry.file.clone())
.or_insert(checkpoint_idx);
}
}

// Clear attributions from entries that aren't the most recent for their file
for (checkpoint_idx, checkpoint) in checkpoints.iter_mut().enumerate() {
for entry in &mut checkpoint.entries {
if let Some(&newest_idx) = newest_for_file.get(&entry.file) {
if checkpoint_idx != newest_idx {
entry.attributions.clear();
}
}
}
}
}

/// Write all checkpoints to the JSONL file, replacing any existing content
/// Note: Unlike append_checkpoint(), this preserves transcripts because it's used
/// by post-commit after transcripts have been refetched and need to be preserved
/// for from_just_working_log() to read them.
pub fn write_all_checkpoints(&self, checkpoints: &[Checkpoint]) -> Result<(), GitAiError> {
let checkpoints_file = self.dir.join("checkpoints.jsonl");

Expand Down
16 changes: 16 additions & 0 deletions tests/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ fn test_parse_example_claude_code_jsonl_with_model() {
Message::ToolUse { name, input, .. } => {
println!("{}: ToolUse: {} with input: {:?}", i, name, input)
}
Message::Thinking { text, .. } => println!("{}: Thinking: {}", i, text),
Message::Plan { text, .. } => println!("{}: Plan: {}", i, text),
}
}
}
Expand Down Expand Up @@ -207,6 +209,20 @@ fn test_parse_claude_code_jsonl_with_thinking() {
Message::ToolUse { name, input, .. } => {
println!("{}: ToolUse: {} with input: {:?}", i, name, input)
}
Message::Thinking { text, .. } => {
println!(
"{}: Thinking: {}",
i,
text.chars().take(100).collect::<String>()
)
}
Message::Plan { text, .. } => {
println!(
"{}: Plan: {}",
i,
text.chars().take(100).collect::<String>()
)
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions tests/continue_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ fn test_parse_example_continue_cli_json() {
Message::ToolUse { name, input, .. } => {
println!("{}: ToolUse: {} with input: {:?}", i, name, input)
}
Message::Thinking { text, .. } => println!("{}: Thinking: {}", i, text),
Message::Plan { text, .. } => println!("{}: Plan: {}", i, text),
}
}
}
Expand Down
Loading
Loading