diff --git a/Cargo.lock b/Cargo.lock
index 57bbd15efc8140..7b0ae57104efa4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2795,6 +2795,67 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "cherrypick_agent"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "futures 0.3.32",
+ "git2",
+ "notify 7.0.0",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "thiserror 2.0.17",
+ "tiktoken-rs",
+ "tokio",
+ "tokio-rusqlite",
+ "zed-reqwest",
+]
+
+[[package]]
+name = "cherrypick_pr"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "git2",
+ "lru",
+ "notify 7.0.0",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "thiserror 2.0.17",
+ "tokio",
+ "tokio-rusqlite",
+]
+
+[[package]]
+name = "cherrypick_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "askpass",
+ "cherrypick_agent",
+ "cherrypick_pr",
+ "git",
+ "git2",
+ "git_graph",
+ "git_ui",
+ "gpui",
+ "log",
+ "project",
+ "settings",
+ "sha2",
+ "tokio",
+ "ui",
+ "workspace",
+]
+
[[package]]
name = "chrono"
version = "0.4.42"
@@ -6150,6 +6211,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
[[package]]
name = "fancy-regex"
version = "0.16.2"
@@ -8138,6 +8205,15 @@ dependencies = [
"hashbrown 0.14.5",
]
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "hashlink"
version = "0.10.0"
@@ -8892,6 +8968,17 @@ dependencies = [
"libc",
]
+[[package]]
+name = "inotify"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
[[package]]
name = "inotify"
version = "0.11.0"
@@ -11202,6 +11289,25 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "notify"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
+dependencies = [
+ "bitflags 2.10.0",
+ "filetime",
+ "fsevent-sys",
+ "inotify 0.10.2",
+ "kqueue",
+ "libc",
+ "log",
+ "mio 1.1.0",
+ "notify-types 1.0.1",
+ "walkdir",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "notify"
version = "8.2.0"
@@ -11214,7 +11320,7 @@ dependencies = [
"libc",
"log",
"mio 1.1.0",
- "notify-types",
+ "notify-types 2.0.0",
"walkdir",
"windows-sys 0.60.2",
]
@@ -11230,6 +11336,15 @@ dependencies = [
"notify 6.1.1",
]
+[[package]]
+name = "notify-types"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
+dependencies = [
+ "instant",
+]
+
[[package]]
name = "notify-types"
version = "2.0.0"
@@ -15117,6 +15232,20 @@ dependencies = [
"zeromq",
]
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.10.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink 0.9.1",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
[[package]]
name = "rust-embed"
version = "8.11.0"
@@ -18007,6 +18136,20 @@ dependencies = [
"zune-jpeg 0.5.15",
]
+[[package]]
+name = "tiktoken-rs"
+version = "0.9.1"
+source = "git+https://github.com/zed-industries/tiktoken-rs?rev=2570c4387a8505fb8f1d3f3557454b474f1e8271#2570c4387a8505fb8f1d3f3557454b474f1e8271"
+dependencies = [
+ "anyhow",
+ "base64 0.22.1",
+ "bstr",
+ "fancy-regex 0.16.2",
+ "lazy_static",
+ "regex",
+ "rustc-hash 1.1.0",
+]
+
[[package]]
name = "time"
version = "0.3.47"
@@ -18223,6 +18366,17 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-rusqlite"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945"
+dependencies = [
+ "crossbeam-channel",
+ "rusqlite",
+ "tokio",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.24.1"
@@ -22380,6 +22534,7 @@ dependencies = [
"breadcrumbs",
"call",
"channel",
+ "cherrypick_ui",
"chrono",
"clap",
"cli",
diff --git a/Cargo.toml b/Cargo.toml
index 2fac513d4ee395..5b13c5b5b162c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,9 @@ members = [
"crates/buffer_diff",
"crates/call",
"crates/channel",
+ "crates/cherrypick_ui",
+ "crates/cherrypick_pr",
+ "crates/cherrypick_agent",
"crates/cli",
"crates/client",
"crates/clock",
@@ -284,6 +287,9 @@ breadcrumbs = { path = "crates/breadcrumbs" }
buffer_diff = { path = "crates/buffer_diff" }
call = { path = "crates/call" }
channel = { path = "crates/channel" }
+cherrypick_ui = { path = "crates/cherrypick_ui" }
+cherrypick_pr = { path = "crates/cherrypick_pr" }
+cherrypick_agent = { path = "crates/cherrypick_agent" }
cli = { path = "crates/cli" }
client = { path = "crates/client" }
clock = { path = "crates/clock" }
@@ -614,6 +620,7 @@ linkify = "0.10.0"
libwebrtc = "0.3.26"
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
+lru = "0.12"
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" }
mach2 = "0.5"
markup5ever_rcdom = "0.3.0"
@@ -623,6 +630,7 @@ moka = { version = "0.12.10", features = ["sync"] }
nanoid = "0.4"
nbformat = "1.2.0"
nix = "0.29"
+notify = "7"
nucleo = "0.5"
num-format = "0.4.4"
objc = "0.2"
@@ -699,6 +707,7 @@ rsa = "0.9.6"
runtimelib = { version = "1.4.0", default-features = false, features = [
"async-dispatcher-runtime", "aws-lc-rs"
] }
+rusqlite = { version = "0.32", features = ["bundled"] }
rust-embed = { version = "8.11", features = ["include-exclude"] }
rustc-hash = "2.1.0"
rustls = { version = "0.23.26" }
@@ -733,6 +742,7 @@ sysinfo = "0.37.0"
take-until = "0.2.0"
tempfile = "3.20.0"
thiserror = "2.0.12"
+tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "2570c4387a8505fb8f1d3f3557454b474f1e8271" }
time = { version = "0.3", features = [
"macros",
"parsing",
@@ -743,6 +753,7 @@ time = { version = "0.3", features = [
] }
tiny_http = "0.12"
tokio = { version = "1" }
+tokio-rusqlite = "0.6"
tokio-socks = { version = "0.5.2", default-features = false, features = [
"futures-io",
"tokio",
diff --git a/assets/icons/file_icons/v.svg b/assets/icons/file_icons/v.svg
deleted file mode 100644
index 485e27a3786e6f..00000000000000
--- a/assets/icons/file_icons/v.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 9ac7ed46cfd737..3f0da9621cdf35 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -956,7 +956,6 @@
"ctrl-k ctrl-shift-c": "workspace::CopyRelativePath",
"ctrl-z": "project_panel::Undo",
"ctrl-shift-z": "project_panel::Redo",
- "enter": "project_panel::Rename",
"f2": "project_panel::Rename",
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
"delete": ["project_panel::Trash", { "skip_prompt": false }],
@@ -977,6 +976,10 @@
"use_key_equivalents": true,
"bindings": {
"space": "project_panel::Open",
+ // CHERRYPICK: Windows-native Enter = open file (Zed defaults Enter to Rename,
+ // a Finder convention). Rename stays on F2. Gated behind not_editing so Enter
+ // still confirms while editing a name.
+ "enter": "project_panel::Open",
},
},
{
diff --git a/crates/cherrypick_agent/Cargo.toml b/crates/cherrypick_agent/Cargo.toml
new file mode 100644
index 00000000000000..d06a21aa1ac12f
--- /dev/null
+++ b/crates/cherrypick_agent/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "cherrypick_agent"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/lib.rs"
+
+[dependencies]
+git2.workspace = true
+tokio = { workspace = true, features = ["rt", "sync", "macros", "time", "process", "io-util"] }
+thiserror.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+reqwest.workspace = true
+async-trait.workspace = true
+futures.workspace = true
+chrono.workspace = true
+rusqlite.workspace = true
+tokio-rusqlite.workspace = true
+tiktoken-rs.workspace = true
+notify.workspace = true
+sha2.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
diff --git a/crates/cherrypick_agent/src/chat/mod.rs b/crates/cherrypick_agent/src/chat/mod.rs
new file mode 100644
index 00000000000000..2e8c6c83df6ed2
--- /dev/null
+++ b/crates/cherrypick_agent/src/chat/mod.rs
@@ -0,0 +1,295 @@
+pub mod store;
+
+use std::path::Path;
+use std::sync::Arc;
+use std::time::Duration;
+
+use tokio::sync::{mpsc, watch};
+
+use crate::context::ContextEngine;
+use crate::error::{AgentError, Result};
+use crate::provider::LlmProvider;
+use crate::provider::types::{
+ CompletionRequest, Message, MessageContent, RiskLevel, StreamChunk, ToolCall,
+};
+use crate::tools::ToolExecutor;
+
+const MAX_ITERATIONS: u32 = 10;
+const MAX_DURATION: Duration = Duration::from_secs(120);
+const SYSTEM_POLICY: &str = r#"You are CherryPick AI, a git-aware coding assistant integrated into the CherryPick git client. You help users understand their repositories, review changes, write commits, and manage branches.
+
+Safety rules (immutable):
+- Never force-push without explicit user confirmation
+- Never delete branches without explicit user confirmation
+- Never modify files outside the repository working directory
+- Never read or expose sensitive files (.env, credentials, keys)
+- Always explain what you're about to do before executing write operations"#;
+
+#[derive(Debug, Clone)]
+pub enum AgentEvent {
+ TextDelta(String),
+ ToolCallStarted {
+ id: String,
+ name: String,
+ risk_level: RiskLevel,
+ },
+ ToolCallCompleted {
+ id: String,
+ result: String,
+ is_error: bool,
+ },
+ ConfirmationNeeded {
+ tool_name: String,
+ risk_level: RiskLevel,
+ preview: String,
+ },
+ TurnComplete,
+ Error(String),
+}
+
+pub struct AgentConfig {
+ pub model: String,
+ pub max_tokens: u32,
+ pub temperature: Option,
+ pub max_iterations: u32,
+ pub max_duration: Duration,
+}
+
+impl Default for AgentConfig {
+ fn default() -> Self {
+ Self {
+ model: "claude-sonnet-4-20250514".to_string(),
+ max_tokens: 4096,
+ temperature: None,
+ max_iterations: MAX_ITERATIONS,
+ max_duration: MAX_DURATION,
+ }
+ }
+}
+
+pub struct AgentService {
+ provider: Arc,
+ tool_executor: ToolExecutor,
+ context_engine: ContextEngine,
+ config: AgentConfig,
+ history: Vec,
+}
+
+impl AgentService {
+ pub fn new(
+ provider: Arc,
+ tool_executor: ToolExecutor,
+ context_engine: ContextEngine,
+ config: AgentConfig,
+ ) -> Self {
+ Self {
+ provider,
+ tool_executor,
+ context_engine,
+ config,
+ history: Vec::new(),
+ }
+ }
+
+ pub async fn send_message(
+ &mut self,
+ user_message: &str,
+ repo_path: &Path,
+ event_tx: mpsc::UnboundedSender,
+ cancel: watch::Receiver,
+ ) -> Result<()> {
+ self.history.push(Message::user(user_message));
+
+ let tool_defs = self.tool_executor.definitions();
+ let start = std::time::Instant::now();
+ let mut iterations = 0u32;
+
+ loop {
+ if *cancel.borrow() {
+ return Err(AgentError::Cancelled);
+ }
+
+ if iterations >= self.config.max_iterations {
+ return Err(AgentError::MaxIterations(self.config.max_iterations));
+ }
+
+ if start.elapsed() > self.config.max_duration {
+ return Err(AgentError::MaxDuration);
+ }
+
+ iterations += 1;
+
+ let truncated = ContextEngine::truncate_history(
+ &self.history,
+ self.context_engine.budget().warm,
+ );
+
+ let request = CompletionRequest {
+ model: self.config.model.clone(),
+ messages: truncated,
+ system: Some(SYSTEM_POLICY.to_string()),
+ tools: tool_defs.clone(),
+ max_tokens: self.config.max_tokens,
+ temperature: self.config.temperature,
+ };
+
+ let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel();
+
+ let provider = self.provider.clone();
+ let provider_handle = tokio::spawn(async move {
+ provider.stream_completion(request, chunk_tx).await
+ });
+
+ let mut text_buffer = String::new();
+ let mut tool_calls: Vec = Vec::new();
+ let mut current_tool_id = String::new();
+ let mut current_tool_name = String::new();
+ let mut current_tool_args = String::new();
+
+ while let Some(chunk) = chunk_rx.recv().await {
+ if *cancel.borrow() {
+ return Err(AgentError::Cancelled);
+ }
+
+ match chunk {
+ StreamChunk::TextDelta(text) => {
+ text_buffer.push_str(&text);
+ let _ = event_tx.send(AgentEvent::TextDelta(text));
+ }
+ StreamChunk::ToolCallStart { id, name } => {
+ current_tool_id = id;
+ current_tool_name = name.clone();
+ current_tool_args.clear();
+ let risk = self
+ .tool_executor
+ .risk_level(&name)
+ .unwrap_or(RiskLevel::ReadOnly);
+ let _ = event_tx.send(AgentEvent::ToolCallStarted {
+ id: current_tool_id.clone(),
+ name,
+ risk_level: risk,
+ });
+ }
+ StreamChunk::ToolCallDelta(json) => {
+ current_tool_args.push_str(&json);
+ }
+ StreamChunk::ToolCallEnd => {
+ let args: serde_json::Value =
+ serde_json::from_str(¤t_tool_args).unwrap_or_default();
+ tool_calls.push(ToolCall {
+ id: current_tool_id.clone(),
+ name: current_tool_name.clone(),
+ arguments: args,
+ });
+ }
+ StreamChunk::Done => break,
+ StreamChunk::Error(e) => {
+ let _ = event_tx.send(AgentEvent::Error(e.clone()));
+ return Err(AgentError::Provider(e));
+ }
+ StreamChunk::Usage(_) => {}
+ }
+ }
+
+ let _ = provider_handle.await;
+
+ let mut assistant_content = Vec::new();
+ if !text_buffer.is_empty() {
+ assistant_content.push(MessageContent::Text {
+ text: text_buffer.clone(),
+ });
+ }
+ for tc in &tool_calls {
+ assistant_content.push(MessageContent::ToolUse {
+ id: tc.id.clone(),
+ name: tc.name.clone(),
+ input: tc.arguments.clone(),
+ });
+ }
+
+ if !assistant_content.is_empty() {
+ self.history.push(Message {
+ role: crate::provider::types::Role::Assistant,
+ content: assistant_content,
+ });
+ }
+
+ if tool_calls.is_empty() {
+ let _ = event_tx.send(AgentEvent::TurnComplete);
+ return Ok(());
+ }
+
+ for tc in &tool_calls {
+ let risk = self
+ .tool_executor
+ .risk_level(&tc.name)
+ .unwrap_or(RiskLevel::ReadOnly);
+
+ if risk.requires_confirmation() {
+ let preview = self
+ .tool_executor
+ .preview(tc)
+ .unwrap_or_else(|| tc.name.clone());
+ let _ = event_tx.send(AgentEvent::ConfirmationNeeded {
+ tool_name: tc.name.clone(),
+ risk_level: risk,
+ preview,
+ });
+ // For now, auto-approve in the agentic loop.
+ // In the full UI implementation, this would wait for user input
+ // via a oneshot channel before proceeding.
+ }
+
+ let result = self.tool_executor.execute(tc, repo_path).await;
+ let (output, is_error) = match result {
+ Ok(output) => {
+ let _ = event_tx.send(AgentEvent::ToolCallCompleted {
+ id: tc.id.clone(),
+ result: output.clone(),
+ is_error: false,
+ });
+ (output, false)
+ }
+ Err(e) => {
+ let err_msg = e.to_string();
+ let _ = event_tx.send(AgentEvent::ToolCallCompleted {
+ id: tc.id.clone(),
+ result: err_msg.clone(),
+ is_error: true,
+ });
+ (err_msg, true)
+ }
+ };
+
+ self.history
+ .push(Message::tool_result(&tc.id, &output, is_error));
+ }
+ }
+ }
+
+ pub fn history(&self) -> &[Message] {
+ &self.history
+ }
+
+ pub fn clear_history(&mut self) {
+ self.history.clear();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_config() {
+ let config = AgentConfig::default();
+ assert_eq!(config.max_iterations, MAX_ITERATIONS);
+ assert!(config.model.contains("claude"));
+ }
+
+ #[test]
+ fn system_policy_contains_safety_rules() {
+ assert!(SYSTEM_POLICY.contains("Never force-push"));
+ assert!(SYSTEM_POLICY.contains("sensitive files"));
+ }
+}
diff --git a/crates/cherrypick_agent/src/chat/store.rs b/crates/cherrypick_agent/src/chat/store.rs
new file mode 100644
index 00000000000000..8b840a86e3bb53
--- /dev/null
+++ b/crates/cherrypick_agent/src/chat/store.rs
@@ -0,0 +1,256 @@
+use chrono::Utc;
+use rusqlite::params;
+use tokio_rusqlite::Connection;
+
+use crate::error::{AgentError, Result};
+
+const SCHEMA: &str = r#"
+CREATE TABLE IF NOT EXISTS conversations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ repo_path TEXT,
+ title TEXT NOT NULL DEFAULT 'New Chat',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+ role TEXT NOT NULL,
+ content_json TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
+"#;
+
+#[derive(Debug, Clone)]
+pub struct Conversation {
+ pub id: i64,
+ pub repo_path: Option,
+ pub title: String,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct StoredMessage {
+ pub id: i64,
+ pub conversation_id: i64,
+ pub role: String,
+ pub content_json: String,
+ pub created_at: String,
+}
+
+pub struct ChatStore {
+ conn: Connection,
+}
+
+impl ChatStore {
+ pub async fn open(path: &str) -> Result {
+ let conn = Connection::open(path)
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))?;
+ conn.call(|conn| {
+ conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
+ conn.execute_batch(SCHEMA)?;
+ Ok(())
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))?;
+ Ok(Self { conn })
+ }
+
+ pub async fn open_in_memory() -> Result {
+ let conn = Connection::open_in_memory()
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))?;
+ conn.call(|conn| {
+ conn.execute_batch("PRAGMA foreign_keys=ON;")?;
+ conn.execute_batch(SCHEMA)?;
+ Ok(())
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))?;
+ Ok(Self { conn })
+ }
+
+ pub async fn create_conversation(&self, repo_path: Option<&str>, title: &str) -> Result {
+ let rp = repo_path.map(String::from);
+ let title = title.to_string();
+ let now = Utc::now().to_rfc3339();
+ self.conn
+ .call(move |conn| {
+ conn.execute(
+ "INSERT INTO conversations (repo_path, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)",
+ params![rp, title, now],
+ )?;
+ Ok(conn.last_insert_rowid())
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))
+ }
+
+ pub async fn list_conversations(&self, repo_path: Option<&str>) -> Result> {
+ let rp = repo_path.map(String::from);
+ self.conn
+ .call(move |conn| {
+ let mut stmt = if rp.is_some() {
+ conn.prepare(
+ "SELECT id, repo_path, title, created_at, updated_at FROM conversations
+ WHERE repo_path = ?1 ORDER BY updated_at DESC",
+ )?
+ } else {
+ conn.prepare(
+ "SELECT id, repo_path, title, created_at, updated_at FROM conversations
+ ORDER BY updated_at DESC",
+ )?
+ };
+
+ let rows = if let Some(ref rp) = rp {
+ stmt.query_map(params![rp], map_conversation)?
+ } else {
+ stmt.query_map([], map_conversation)?
+ };
+
+ let mut convos = Vec::new();
+ for row in rows {
+ convos.push(row?);
+ }
+ Ok(convos)
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))
+ }
+
+ pub async fn save_message(
+ &self,
+ conversation_id: i64,
+ role: &str,
+ content_json: &str,
+ ) -> Result {
+ let role = role.to_string();
+ let content = content_json.to_string();
+ let now = Utc::now().to_rfc3339();
+ self.conn
+ .call(move |conn| {
+ conn.execute(
+ "INSERT INTO messages (conversation_id, role, content_json, created_at)
+ VALUES (?1, ?2, ?3, ?4)",
+ params![conversation_id, role, content, now],
+ )?;
+ conn.execute(
+ "UPDATE conversations SET updated_at = ?1 WHERE id = ?2",
+ params![now, conversation_id],
+ )?;
+ Ok(conn.last_insert_rowid())
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))
+ }
+
+ pub async fn load_messages(&self, conversation_id: i64) -> Result> {
+ self.conn
+ .call(move |conn| {
+ let mut stmt = conn.prepare(
+ "SELECT id, conversation_id, role, content_json, created_at
+ FROM messages WHERE conversation_id = ?1 ORDER BY id ASC",
+ )?;
+ let rows = stmt.query_map(params![conversation_id], |row| {
+ Ok(StoredMessage {
+ id: row.get(0)?,
+ conversation_id: row.get(1)?,
+ role: row.get(2)?,
+ content_json: row.get(3)?,
+ created_at: row.get(4)?,
+ })
+ })?;
+ let mut msgs = Vec::new();
+ for row in rows {
+ msgs.push(row?);
+ }
+ Ok(msgs)
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))
+ }
+
+ pub async fn delete_conversation(&self, id: i64) -> Result<()> {
+ self.conn
+ .call(move |conn| {
+ conn.execute("DELETE FROM conversations WHERE id = ?1", params![id])?;
+ Ok(())
+ })
+ .await
+ .map_err(|e| AgentError::Database(e.to_string()))
+ }
+}
+
+fn map_conversation(row: &rusqlite::Row) -> rusqlite::Result {
+ Ok(Conversation {
+ id: row.get(0)?,
+ repo_path: row.get(1)?,
+ title: row.get(2)?,
+ created_at: row.get(3)?,
+ updated_at: row.get(4)?,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn create_and_list_conversations() {
+ let store = ChatStore::open_in_memory().await.unwrap();
+ let id = store
+ .create_conversation(Some("/repo"), "Test Chat")
+ .await
+ .unwrap();
+ assert!(id > 0);
+
+ let convos = store.list_conversations(Some("/repo")).await.unwrap();
+ assert_eq!(convos.len(), 1);
+ assert_eq!(convos[0].title, "Test Chat");
+ }
+
+ #[tokio::test]
+ async fn save_and_load_messages() {
+ let store = ChatStore::open_in_memory().await.unwrap();
+ let conv_id = store
+ .create_conversation(None, "Chat")
+ .await
+ .unwrap();
+
+ store
+ .save_message(conv_id, "user", r#"[{"type":"text","text":"hello"}]"#)
+ .await
+ .unwrap();
+ store
+ .save_message(conv_id, "assistant", r#"[{"type":"text","text":"hi"}]"#)
+ .await
+ .unwrap();
+
+ let msgs = store.load_messages(conv_id).await.unwrap();
+ assert_eq!(msgs.len(), 2);
+ assert_eq!(msgs[0].role, "user");
+ assert_eq!(msgs[1].role, "assistant");
+ }
+
+ #[tokio::test]
+ async fn delete_conversation_cascades() {
+ let store = ChatStore::open_in_memory().await.unwrap();
+ let conv_id = store
+ .create_conversation(None, "Chat")
+ .await
+ .unwrap();
+ store
+ .save_message(conv_id, "user", "[]")
+ .await
+ .unwrap();
+
+ store.delete_conversation(conv_id).await.unwrap();
+ let msgs = store.load_messages(conv_id).await.unwrap();
+ assert!(msgs.is_empty());
+ }
+}
diff --git a/crates/cherrypick_agent/src/context/mod.rs b/crates/cherrypick_agent/src/context/mod.rs
new file mode 100644
index 00000000000000..e63a9a047a15db
--- /dev/null
+++ b/crates/cherrypick_agent/src/context/mod.rs
@@ -0,0 +1,199 @@
+use std::path::Path;
+
+use crate::provider::types::Message;
+
+const DEFAULT_TOKEN_BUDGET: usize = 100_000;
+const HOT_TIER_RATIO: f32 = 0.20;
+const WARM_TIER_RATIO: f32 = 0.50;
+const COLD_TIER_RATIO: f32 = 0.30;
+
+pub struct ContextBudget {
+ pub total: usize,
+ pub hot: usize,
+ pub warm: usize,
+ pub cold: usize,
+}
+
+impl ContextBudget {
+ pub fn new(total: usize) -> Self {
+ let total_f = total as f32;
+ Self {
+ total,
+ hot: (total_f * HOT_TIER_RATIO) as usize,
+ warm: (total_f * WARM_TIER_RATIO) as usize,
+ cold: (total_f * COLD_TIER_RATIO) as usize,
+ }
+ }
+}
+
+impl Default for ContextBudget {
+ fn default() -> Self {
+ Self::new(DEFAULT_TOKEN_BUDGET)
+ }
+}
+
+pub struct ContextEngine {
+ budget: ContextBudget,
+ repo_map: Option,
+}
+
+impl ContextEngine {
+ pub fn new(budget: ContextBudget) -> Self {
+ Self {
+ budget,
+ repo_map: None,
+ }
+ }
+
+ pub fn build_repo_map(&mut self, repo_path: &Path) {
+ let mut entries = Vec::new();
+ collect_files(repo_path, repo_path, &mut entries, 200);
+ self.repo_map = Some(entries.join("\n"));
+ }
+
+ pub fn repo_map(&self) -> Option<&str> {
+ self.repo_map.as_deref()
+ }
+
+ pub fn estimate_tokens(text: &str) -> usize {
+ text.len() / 4
+ }
+
+ pub fn truncate_history(
+ messages: &[Message],
+ max_tokens: usize,
+ ) -> Vec {
+ let mut total = 0;
+ let mut result: Vec = Vec::new();
+
+ for msg in messages.iter().rev() {
+ let tokens = msg
+ .content
+ .iter()
+ .map(|c| match c {
+ crate::provider::types::MessageContent::Text { text } => {
+ Self::estimate_tokens(text)
+ }
+ crate::provider::types::MessageContent::ToolResult { content, .. } => {
+ Self::estimate_tokens(content)
+ }
+ crate::provider::types::MessageContent::ToolUse { input, .. } => {
+ Self::estimate_tokens(&input.to_string())
+ }
+ })
+ .sum::();
+
+ if total + tokens > max_tokens {
+ break;
+ }
+ total += tokens;
+ result.push(msg.clone());
+ }
+
+ result.reverse();
+ result
+ }
+
+ pub fn budget(&self) -> &ContextBudget {
+ &self.budget
+ }
+}
+
+fn collect_files(
+ dir: &Path,
+ root: &Path,
+ entries: &mut Vec,
+ max: usize,
+) {
+ if entries.len() >= max {
+ return;
+ }
+
+ let read_dir = match std::fs::read_dir(dir) {
+ Ok(d) => d,
+ Err(_) => return,
+ };
+
+ for entry in read_dir {
+ let entry = match entry {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+
+ let path = entry.path();
+ let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
+
+ if name.starts_with('.') || name == "target" || name == "node_modules" {
+ continue;
+ }
+
+ if path.is_dir() {
+ collect_files(&path, root, entries, max);
+ } else if path.is_file() {
+ let rel = path.strip_prefix(root).unwrap_or(&path);
+ entries.push(rel.to_string_lossy().to_string());
+ }
+
+ if entries.len() >= max {
+ break;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::provider::types::Message;
+
+ #[test]
+ fn budget_allocation() {
+ let budget = ContextBudget::new(100_000);
+ assert_eq!(budget.hot, 20_000);
+ assert_eq!(budget.warm, 50_000);
+ assert_eq!(budget.cold, 30_000);
+ assert_eq!(budget.hot + budget.warm + budget.cold, budget.total);
+ }
+
+ #[test]
+ fn estimate_tokens_rough() {
+ let text = "Hello, world! This is a test.";
+ let tokens = ContextEngine::estimate_tokens(text);
+ assert!(tokens > 0);
+ assert!(tokens < text.len());
+ }
+
+ #[test]
+ fn truncate_history_respects_budget() {
+ let messages: Vec = (0..100)
+ .map(|i| Message::user(&format!("Message {i} with some content")))
+ .collect();
+ let truncated = ContextEngine::truncate_history(&messages, 100);
+ assert!(truncated.len() < messages.len());
+ assert!(!truncated.is_empty());
+ }
+
+ #[test]
+ fn truncate_preserves_order() {
+ let messages = vec![
+ Message::user("first"),
+ Message::user("second"),
+ Message::user("third"),
+ ];
+ let truncated = ContextEngine::truncate_history(&messages, 10000);
+ assert_eq!(truncated.len(), 3);
+ assert_eq!(truncated[0].text_content(), Some("first"));
+ }
+
+ #[test]
+ fn repo_map_collection() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(tmp.path().join("a.rs"), "fn main() {}").unwrap();
+ std::fs::write(tmp.path().join("b.rs"), "fn test() {}").unwrap();
+
+ let mut engine = ContextEngine::new(ContextBudget::default());
+ engine.build_repo_map(tmp.path());
+ let map = engine.repo_map().unwrap();
+ assert!(map.contains("a.rs"));
+ assert!(map.contains("b.rs"));
+ }
+}
diff --git a/crates/cherrypick_agent/src/error.rs b/crates/cherrypick_agent/src/error.rs
new file mode 100644
index 00000000000000..cafbc195d8eb62
--- /dev/null
+++ b/crates/cherrypick_agent/src/error.rs
@@ -0,0 +1,79 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum AgentError {
+ #[error("Provider error: {0}")]
+ Provider(String),
+
+ #[error("Rate limited: retry after {retry_after_secs}s")]
+ RateLimited { retry_after_secs: u64 },
+
+ #[error("Context too long: {0} tokens exceeds budget")]
+ ContextTooLong(usize),
+
+ #[error("Tool execution failed: {0}")]
+ ToolExecution(String),
+
+ #[error("Tool not found: {0}")]
+ ToolNotFound(String),
+
+ #[error("Tool call denied by user")]
+ ToolDenied,
+
+ #[error("Max iterations ({0}) exceeded")]
+ MaxIterations(u32),
+
+ #[error("Max duration exceeded")]
+ MaxDuration,
+
+ #[error("Cancelled")]
+ Cancelled,
+
+ #[error("Key not found for provider: {0}")]
+ KeyNotFound(String),
+
+ #[error("Skill not found: {0}")]
+ SkillNotFound(String),
+
+ #[error("Skill parse error: {0}")]
+ SkillParse(String),
+
+ #[error("MCP error: {0}")]
+ Mcp(String),
+
+ #[error("Database error: {0}")]
+ Database(String),
+
+ #[error("IO error: {0}")]
+ Io(#[from] std::io::Error),
+
+ #[error("JSON error: {0}")]
+ Json(#[from] serde_json::Error),
+
+ #[error("HTTP error: {0}")]
+ Http(#[from] reqwest::Error),
+
+ #[error("{0}")]
+ Other(String),
+}
+
+pub type Result = std::result::Result;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn error_display() {
+ let err = AgentError::ToolNotFound("test_tool".into());
+ assert!(err.to_string().contains("test_tool"));
+ }
+
+ #[test]
+ fn rate_limited_displays_retry() {
+ let err = AgentError::RateLimited {
+ retry_after_secs: 30,
+ };
+ assert!(err.to_string().contains("30"));
+ }
+}
diff --git a/crates/cherrypick_agent/src/keys.rs b/crates/cherrypick_agent/src/keys.rs
new file mode 100644
index 00000000000000..2fc77a02f5230f
--- /dev/null
+++ b/crates/cherrypick_agent/src/keys.rs
@@ -0,0 +1,143 @@
+use crate::error::Result;
+
+pub trait KeyBackend: Send + Sync {
+ fn get(&self, key: &str) -> Result