diff --git a/lib/service/agent/context.rs b/lib/service/agent/context.rs index 38c8ab8..86d7b32 100644 --- a/lib/service/agent/context.rs +++ b/lib/service/agent/context.rs @@ -10,6 +10,7 @@ use ai::{ }, }; use db::sqlx; +use model::repos::RepoModel; use uuid::Uuid; use super::types::SessionContext; @@ -69,6 +70,19 @@ impl AppService { memories_text, )); } + + // Inject repo context from @[repo:...] mentions in the input. + if let Some(workspace_id) = ctx.workspace_id { + let repo_context = self + .agent_resolve_mentioned_repos(workspace_id, &input) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to resolve mentioned repos"); + Vec::new() + }); + all_context.extend(repo_context); + } + if !all_context.is_empty() { request = request.with_context(all_context); } @@ -226,6 +240,72 @@ impl AppService { Ok(all_hits) } + /// Parse `@[repo:name:label]` mentions from the input and resolve them to + /// AgentContextChunks with repo metadata from the database. + pub(crate) async fn agent_resolve_mentioned_repos( + &self, + workspace_id: Uuid, + input: &str, + ) -> Result, AppError> { + let repo_names = extract_repo_mentions(input); + if repo_names.is_empty() { + return Ok(Vec::new()); + } + + let mut chunks = Vec::with_capacity(repo_names.len()); + for name in &repo_names { + match self.resolve_repo_by_name(workspace_id, name).await { + Ok(Some(repo)) => { + let content = format_repo_context(&repo); + chunks.push(AgentContextChunk::new( + format!("repo:{}", repo.name), + content, + )); + tracing::info!( + repo = %repo.name, + "injected repo context from @mention" + ); + } + Ok(None) => { + tracing::debug!( + repo_name = %name, + "mentioned repo not found, skipping" + ); + } + Err(e) => { + tracing::warn!( + repo_name = %name, + error = %e, + "failed to look up mentioned repo" + ); + } + } + } + + Ok(chunks) + } + + /// Look up a single repo by workspace + name. + async fn resolve_repo_by_name( + &self, + workspace_id: Uuid, + name: &str, + ) -> Result, AppError> { + let repo = sqlx::query_as::<_, RepoModel>( + "SELECT id, wk, name, description, default_branch, visibility, \ + size_bytes, is_archived, is_template, is_mirror, created_by, \ + storage_path, created_at, updated_at, deleted_at \ + FROM repo WHERE wk = $1 AND name = $2 AND deleted_at IS NULL", + ) + .bind(workspace_id) + .bind(name) + .fetch_optional(self.db.reader()) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + Ok(repo) + } + #[allow(dead_code)] pub(crate) async fn agent_upsert_knowledge( &self, @@ -264,3 +344,115 @@ impl AppService { Ok(()) } } + +// --------------------------------------------------------------------------- +// Repo mention helpers +// --------------------------------------------------------------------------- + +/// Extract unique repo names from `@[repo:name:label]` mentions in the input. +fn extract_repo_mentions(input: &str) -> Vec { + let mut names = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + // Simple manual parser for @[repo:name:label] + let bytes = input.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + // Look for "@[repo:" + if i + 7 < len + && bytes[i] == b'@' + && bytes[i + 1] == b'[' + && bytes[i + 2] == b'r' + && bytes[i + 3] == b'e' + && bytes[i + 4] == b'p' + && bytes[i + 5] == b'o' + && bytes[i + 6] == b':' + { + let start = i + 7; // after "@[repo:" + // Find the closing ']' — but skip ':' and the label part. + // Format is @[repo:name:label], we want "name". + if let Some(name_end) = input[start..].find(':') { + let name = &input[start..start + name_end]; + if !name.is_empty() && seen.insert(name.to_string()) { + names.push(name.to_string()); + } + // Skip past the closing ']' + if let Some(closing) = input[start + name_end..].find(']') { + i = start + name_end + closing + 1; + continue; + } + } + } + i += 1; + } + + names +} + +/// Format a RepoModel into a concise context string for the AI. +fn format_repo_context(repo: &RepoModel) -> String { + let mut s = format!( + "Repository: {} (id: {})\n", + repo.name, repo.id + ); + if let Some(ref desc) = repo.description { + if !desc.trim().is_empty() { + s.push_str(&format!("Description: {}\n", desc.trim())); + } + } + s.push_str(&format!("Default branch: {}\n", repo.default_branch)); + s.push_str(&format!("Visibility: {}\n", repo.visibility)); + if repo.is_archived { + s.push_str("Status: archived\n"); + } + if repo.is_template { + s.push_str("Kind: template repository\n"); + } + s.push_str(&format!( + "Created: {}\n", + repo.created_at.format("%Y-%m-%d") + )); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_repo_mentions_single() { + let input = "@[repo:my-repo:my-repo] what's the latest commit?"; + let names = extract_repo_mentions(input); + assert_eq!(names, vec!["my-repo"]); + } + + #[test] + fn test_extract_repo_mentions_multiple() { + let input = "compare @[repo:backend:backend] with @[repo:frontend:frontend]"; + let names = extract_repo_mentions(input); + assert_eq!(names, vec!["backend", "frontend"]); + } + + #[test] + fn test_extract_repo_mentions_dedupe() { + let input = "look at @[repo:a:a] and also @[repo:a:a] please"; + let names = extract_repo_mentions(input); + assert_eq!(names, vec!["a"]); + } + + #[test] + fn test_extract_repo_mentions_none() { + let input = "hello world no mentions here"; + let names = extract_repo_mentions(input); + assert!(names.is_empty()); + } + + #[test] + fn test_extract_ignores_other_mention_types() { + let input = "@[user:abc:John] and @[repo:myrepo:myrepo]"; + let names = extract_repo_mentions(input); + assert_eq!(names, vec!["myrepo"]); + } +} diff --git a/lib/service/agent/run.rs b/lib/service/agent/run.rs index 1c17ff3..7edca60 100644 --- a/lib/service/agent/run.rs +++ b/lib/service/agent/run.rs @@ -183,16 +183,21 @@ impl AppService { .pending_title .filter(|t| !t.trim().is_empty()) .or_else(|| { - let first_line = - req.input.lines().next().unwrap_or(&req.input); - let truncated: String = - first_line.chars().take(50).collect(); - if truncated.trim().is_empty() { - None - } else if first_line.len() > 50 { - Some(format!("{}…", truncated.trim_end())) + // Only auto-set title from input when still default. + if conversation.title == "New Chat" || conversation.title.trim().is_empty() { + let first_line = + req.input.lines().next().unwrap_or(&req.input); + let truncated: String = + first_line.chars().take(50).collect(); + if truncated.trim().is_empty() { + None + } else if first_line.len() > 50 { + Some(format!("{}…", truncated.trim_end())) + } else { + Some(truncated.trim().to_string()) + } } else { - Some(truncated.trim().to_string()) + None } }); diff --git a/lib/service/agent/sse.rs b/lib/service/agent/sse.rs index e3d081b..e7bcfcf 100644 --- a/lib/service/agent/sse.rs +++ b/lib/service/agent/sse.rs @@ -213,10 +213,15 @@ impl AppService { let title = agent_ctx.pending_title .filter(|t| !t.trim().is_empty()) .or_else(|| { - let first_line = first_input.lines().next().unwrap_or(&first_input); - let truncated: String = first_line.chars().take(50).collect(); - if truncated.trim().is_empty() { None } - else { Some(if first_line.len() > 50 { format!("{}…", truncated.trim_end()) } else { truncated.trim().to_string() }) } + // Only auto-set title from input when still default. + if conversation.title == "New Chat" || conversation.title.trim().is_empty() { + let first_line = first_input.lines().next().unwrap_or(&first_input); + let truncated: String = first_line.chars().take(50).collect(); + if truncated.trim().is_empty() { None } + else { Some(if first_line.len() > 50 { format!("{}…", truncated.trim_end()) } else { truncated.trim().to_string() }) } + } else { + None + } }); if let Some(new_title) = &title { if self_clone.update_conversation_title(conversation_id, new_title).await.is_ok() {