diff --git a/Cargo.toml b/Cargo.toml index d2d9e18..bcaf35b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/blue-core", "crates/blue-mcp", + "crates/blue-ollama", "apps/blue-cli", ] @@ -52,8 +53,8 @@ semver = { version = "1.0", features = ["serde"] } axum = "0.8" tower-http = { version = "0.6", features = ["cors", "trace"] } -# HTTP client (Forgejo API) -reqwest = { version = "0.12", features = ["json"] } +# HTTP client (Forgejo API, Ollama) +reqwest = { version = "0.12", features = ["json", "blocking"] } # Directories dirs = "5.0" @@ -71,3 +72,4 @@ tempfile = "3.15" # Internal blue-core = { path = "crates/blue-core" } blue-mcp = { path = "crates/blue-mcp" } +blue-ollama = { path = "crates/blue-ollama" } diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index 7bb9865..ec5d1ba 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -15,6 +15,7 @@ const _BLUE_SECRET_NAME: &str = "Sheepey"; // pronounced "Shee-paay" pub mod daemon; pub mod documents; +pub mod llm; pub mod realm; pub mod repo; pub mod state; @@ -23,6 +24,7 @@ pub mod voice; pub mod workflow; pub use documents::*; +pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, MockLlm}; pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; pub use store::{DocType, Document, DocumentStore, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StagingLock, StagingLockQueueEntry, StagingLockResult, StoreError, Task as StoreTask, TaskProgress, Worktree}; diff --git a/crates/blue-core/src/llm.rs b/crates/blue-core/src/llm.rs new file mode 100644 index 0000000..81e4166 --- /dev/null +++ b/crates/blue-core/src/llm.rs @@ -0,0 +1,282 @@ +//! LLM Provider abstraction +//! +//! Implements RFC 0005: Local LLM Integration. +//! Provides a unified interface for LLM access, supporting both +//! local (Ollama) and API (Anthropic/OpenAI) backends. + +use std::fmt; + +/// Options for LLM completion +#[derive(Debug, Clone)] +pub struct CompletionOptions { + /// Maximum tokens to generate + pub max_tokens: usize, + /// Temperature (0.0-1.0) + pub temperature: f32, + /// Stop sequences + pub stop_sequences: Vec, +} + +impl Default for CompletionOptions { + fn default() -> Self { + Self { + max_tokens: 1024, + temperature: 0.7, + stop_sequences: Vec::new(), + } + } +} + +/// Result of an LLM completion +#[derive(Debug, Clone)] +pub struct CompletionResult { + /// Generated text + pub text: String, + /// Tokens used in prompt + pub prompt_tokens: Option, + /// Tokens generated + pub completion_tokens: Option, + /// Provider that generated this + pub provider: String, +} + +/// LLM provider errors +#[derive(Debug)] +pub enum LlmError { + /// Provider not available + NotAvailable(String), + /// Request failed + RequestFailed(String), + /// Model not found + ModelNotFound(String), + /// Insufficient memory for model + InsufficientMemory { + model: String, + required: u64, + available: u64, + }, + /// Binary verification failed + BinaryTampered { + expected: String, + actual: String, + }, + /// Other error + Other(String), +} + +impl fmt::Display for LlmError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LlmError::NotAvailable(msg) => write!(f, "LLM not available: {}", msg), + LlmError::RequestFailed(msg) => write!(f, "LLM request failed: {}", msg), + LlmError::ModelNotFound(model) => write!(f, "Model not found: {}", model), + LlmError::InsufficientMemory { model, required, available } => { + write!(f, "Insufficient memory for {}: need {} bytes, have {}", model, required, available) + } + LlmError::BinaryTampered { expected, actual } => { + write!(f, "Binary verification failed: expected {}, got {}", expected, actual) + } + LlmError::Other(msg) => write!(f, "LLM error: {}", msg), + } + } +} + +impl std::error::Error for LlmError {} + +/// LLM provider trait +/// +/// Implementations: +/// - OllamaLlm: Local Ollama server +/// - ApiLlm: External API (Anthropic/OpenAI) +/// - MockLlm: Testing +pub trait LlmProvider: Send + Sync { + /// Complete a prompt + fn complete( + &self, + prompt: &str, + options: &CompletionOptions, + ) -> Result; + + /// Provider name + fn name(&self) -> &str; + + /// Check if provider is ready + fn is_ready(&self) -> bool; +} + +/// LLM backend selection +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LlmBackendChoice { + /// Auto-detect best backend (CUDA > MPS > CPU) + Auto, + /// Force CUDA (NVIDIA GPU) + Cuda, + /// Force Metal/MPS (Apple Silicon) + Mps, + /// Force CPU only + Cpu, +} + +impl Default for LlmBackendChoice { + fn default() -> Self { + Self::Auto + } +} + +/// LLM configuration +#[derive(Debug, Clone)] +pub struct LlmConfig { + /// Provider preference: auto, local, api, none + pub provider: LlmProviderChoice, + /// Local Ollama configuration + pub local: LocalLlmConfig, + /// API configuration + pub api: ApiLlmConfig, +} + +impl Default for LlmConfig { + fn default() -> Self { + Self { + provider: LlmProviderChoice::Auto, + local: LocalLlmConfig::default(), + api: ApiLlmConfig::default(), + } + } +} + +/// Provider preference +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LlmProviderChoice { + /// Auto: local if available, else API, else keywords + #[default] + Auto, + /// Only use local, fail if unavailable + Local, + /// Only use API, fail if unavailable + Api, + /// Disable LLM features entirely + None, +} + +/// Local (Ollama) configuration +#[derive(Debug, Clone)] +pub struct LocalLlmConfig { + /// Model name (e.g., "qwen2.5:7b") + pub model: String, + /// Backend choice + pub backend: LlmBackendChoice, + /// Context window size + pub context_size: usize, + /// CPU threads (for CPU backend) + pub threads: usize, + /// Ollama port + pub port: u16, + /// Use external Ollama instead of embedded + pub use_external: bool, +} + +impl Default for LocalLlmConfig { + fn default() -> Self { + Self { + model: "qwen2.5:7b".to_string(), + backend: LlmBackendChoice::Auto, + context_size: 8192, + threads: 8, + port: 11434, + use_external: false, + } + } +} + +/// API configuration +#[derive(Debug, Clone)] +pub struct ApiLlmConfig { + /// API provider: anthropic, openai + pub provider: String, + /// Model name + pub model: String, + /// Environment variable for API key + pub api_key_env: String, +} + +impl Default for ApiLlmConfig { + fn default() -> Self { + Self { + provider: "anthropic".to_string(), + model: "claude-3-haiku-20240307".to_string(), + api_key_env: "ANTHROPIC_API_KEY".to_string(), + } + } +} + +/// Mock LLM for testing +pub struct MockLlm { + responses: Vec, + current: std::sync::atomic::AtomicUsize, +} + +impl MockLlm { + /// Create a new mock LLM with predefined responses + pub fn new(responses: Vec) -> Self { + Self { + responses, + current: std::sync::atomic::AtomicUsize::new(0), + } + } + + /// Create a mock that always returns the same response + pub fn constant(response: &str) -> Self { + Self::new(vec![response.to_string()]) + } +} + +impl LlmProvider for MockLlm { + fn complete(&self, _prompt: &str, _options: &CompletionOptions) -> Result { + let idx = self.current.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let response = self.responses.get(idx % self.responses.len()) + .cloned() + .unwrap_or_default(); + + Ok(CompletionResult { + text: response, + prompt_tokens: Some(100), + completion_tokens: Some(50), + provider: "mock".to_string(), + }) + } + + fn name(&self) -> &str { + "mock" + } + + fn is_ready(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mock_llm() { + let llm = MockLlm::new(vec!["response1".to_string(), "response2".to_string()]); + + let result1 = llm.complete("test", &CompletionOptions::default()).unwrap(); + assert_eq!(result1.text, "response1"); + + let result2 = llm.complete("test", &CompletionOptions::default()).unwrap(); + assert_eq!(result2.text, "response2"); + + // Cycles back + let result3 = llm.complete("test", &CompletionOptions::default()).unwrap(); + assert_eq!(result3.text, "response1"); + } + + #[test] + fn test_completion_options_default() { + let opts = CompletionOptions::default(); + assert_eq!(opts.max_tokens, 1024); + assert!((opts.temperature - 0.7).abs() < f32::EPSILON); + } +} diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs index 23f3246..4660284 100644 --- a/crates/blue-core/src/store.rs +++ b/crates/blue-core/src/store.rs @@ -182,6 +182,7 @@ pub enum DocType { Prd, Postmortem, Runbook, + Dialogue, } impl DocType { @@ -194,6 +195,7 @@ impl DocType { DocType::Prd => "prd", DocType::Postmortem => "postmortem", DocType::Runbook => "runbook", + DocType::Dialogue => "dialogue", } } @@ -206,6 +208,7 @@ impl DocType { "prd" => Some(DocType::Prd), "postmortem" => Some(DocType::Postmortem), "runbook" => Some(DocType::Runbook), + "dialogue" => Some(DocType::Dialogue), _ => None, } } @@ -220,6 +223,7 @@ impl DocType { DocType::Prd => "PRDs", DocType::Postmortem => "post-mortems", DocType::Runbook => "runbooks", + DocType::Dialogue => "dialogues", } } } @@ -233,6 +237,8 @@ pub enum LinkType { RfcToAdr, /// PRD leads to RFC PrdToRfc, + /// Dialogue documents an RFC implementation + DialogueToRfc, /// Generic reference References, } @@ -243,6 +249,7 @@ impl LinkType { LinkType::SpikeToRfc => "spike_to_rfc", LinkType::RfcToAdr => "rfc_to_adr", LinkType::PrdToRfc => "prd_to_rfc", + LinkType::DialogueToRfc => "dialogue_to_rfc", LinkType::References => "references", } } diff --git a/crates/blue-mcp/Cargo.toml b/crates/blue-mcp/Cargo.toml index 0694f87..9de7f45 100644 --- a/crates/blue-mcp/Cargo.toml +++ b/crates/blue-mcp/Cargo.toml @@ -7,6 +7,7 @@ description = "MCP server - Blue's voice" [dependencies] blue-core.workspace = true +blue-ollama.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true @@ -18,6 +19,7 @@ chrono.workspace = true git2.workspace = true regex.workspace = true sha2.workspace = true +rusqlite.workspace = true [dev-dependencies] blue-core = { workspace = true, features = ["test-helpers"] } diff --git a/crates/blue-mcp/src/handlers/adr.rs b/crates/blue-mcp/src/handlers/adr.rs index c436d6e..bf1a418 100644 --- a/crates/blue-mcp/src/handlers/adr.rs +++ b/crates/blue-mcp/src/handlers/adr.rs @@ -1,14 +1,27 @@ //! ADR tool handlers //! -//! Handles Architecture Decision Record creation. +//! Handles Architecture Decision Record creation, listing, and adherence checking. +//! Implements RFC 0004: ADR Adherence. use std::fs; +use std::path::Path; use blue_core::{Adr, DocType, Document, ProjectState}; use serde_json::{json, Value}; use crate::error::ServerError; +/// ADR summary for listing and relevance matching +#[derive(Debug, Clone)] +struct AdrSummary { + number: i64, + title: String, + summary: String, + keywords: Vec, + applies_when: Vec, + anti_patterns: Vec, +} + /// Handle blue_adr_create pub fn handle_create(state: &ProjectState, args: &Value) -> Result { let title = args @@ -123,6 +136,600 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result Result { + let adrs = load_adr_summaries(state)?; + + let adr_list: Vec = adrs + .iter() + .map(|adr| { + json!({ + "number": adr.number, + "title": adr.title, + "summary": adr.summary + }) + }) + .collect(); + + Ok(json!({ + "adrs": adr_list, + "count": adr_list.len(), + "message": blue_core::voice::info( + &format!("{} ADR(s) found", adr_list.len()), + Some("Use blue_adr_get to view details") + ) + })) +} + +/// Handle blue_adr_get +/// +/// Get full ADR content with referenced_by information. +pub fn handle_get(state: &ProjectState, args: &Value) -> Result { + let number = args + .get("number") + .and_then(|v| v.as_i64()) + .ok_or(ServerError::InvalidParams)?; + + // Find ADR document + let docs = state + .store + .list_documents(DocType::Adr) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let adr_doc = docs + .into_iter() + .find(|d| d.number == Some(number as i32)) + .ok_or_else(|| ServerError::StateLoadFailed(format!("ADR {} not found", number)))?; + + // Read content + let file_path = adr_doc.file_path.as_ref().ok_or(ServerError::InvalidParams)?; + let full_path = state.home.docs_path.join(file_path); + let content = fs::read_to_string(&full_path) + .map_err(|e| ServerError::CommandFailed(format!("Couldn't read ADR: {}", e)))?; + + // Find documents that reference this ADR + let referenced_by = find_adr_references(state, adr_doc.id)?; + + // Parse metadata from content + let metadata = parse_adr_metadata(&content); + + let ref_hint = if referenced_by.is_empty() { + None + } else { + Some(format!("Referenced by {} document(s)", referenced_by.len())) + }; + + Ok(json!({ + "number": number, + "title": adr_doc.title, + "status": adr_doc.status, + "content": content, + "file": file_path, + "applies_when": metadata.applies_when, + "anti_patterns": metadata.anti_patterns, + "referenced_by": referenced_by, + "message": blue_core::voice::info( + &format!("ADR {:04}: {}", number, adr_doc.title), + ref_hint.as_deref() + ) + })) +} + +/// Handle blue_adr_relevant +/// +/// Find relevant ADRs based on context using keyword matching. +/// Will be upgraded to AI matching when LLM integration is available (RFC 0005). +pub fn handle_relevant(state: &ProjectState, args: &Value) -> Result { + let context = args + .get("context") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)? + .to_lowercase(); + + let adrs = load_adr_summaries(state)?; + + // Check cache first (RFC 0004 requirement) + let context_hash = compute_context_hash(&context); + if let Some(cached) = get_cached_relevance(state, &context_hash) { + return Ok(cached); + } + + // Keyword-based matching (graceful degradation - no LLM available yet) + let mut matches: Vec<(AdrSummary, f64, String)> = Vec::new(); + + let context_words: Vec<&str> = context.split_whitespace().collect(); + + for adr in &adrs { + let (score, reason) = calculate_relevance_score(&context_words, adr); + if score > 0.7 { + matches.push((adr.clone(), score, reason)); + } + } + + // Sort by score descending + matches.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + let relevant: Vec = matches + .iter() + .take(5) // Return top 5 + .map(|(adr, confidence, why)| { + json!({ + "number": adr.number, + "title": adr.title, + "confidence": confidence, + "why": why + }) + }) + .collect(); + + let result = json!({ + "method": "keyword", // Will be "ai" when LLM available + "cached": false, + "relevant": relevant, + "message": if relevant.is_empty() { + blue_core::voice::info("No strongly relevant ADRs found", Some("Proceed with judgment")) + } else { + blue_core::voice::info( + &format!("{} relevant ADR(s) found", relevant.len()), + Some("Consider these beliefs in your work") + ) + } + }); + + // Cache the result + cache_relevance(state, &context_hash, &result); + + Ok(result) +} + +/// Handle blue_adr_audit +/// +/// Scan for potential ADR violations. Only for testable ADRs. +pub fn handle_audit(state: &ProjectState) -> Result { + let mut findings: Vec = Vec::new(); + let mut passed: Vec = Vec::new(); + + // ADR 0004: Evidence - Check for test coverage + // (Placeholder - would need integration with test coverage tools) + passed.push(json!({ + "adr": 4, + "title": "Evidence", + "message": "Test coverage check skipped (no coverage data available)" + })); + + // ADR 0005: Single Source - Check for duplicate definitions + // (Placeholder - would need code analysis) + passed.push(json!({ + "adr": 5, + "title": "Single Source", + "message": "Duplicate definition check skipped (requires code analysis)" + })); + + // ADR 0010: No Dead Code - Check for unused exports + // Try to run cargo clippy for dead code detection + let dead_code_result = check_dead_code(&state.home.root); + match dead_code_result { + DeadCodeResult::Found(locations) => { + findings.push(json!({ + "adr": 10, + "title": "No Dead Code", + "type": "warning", + "message": format!("{} unused items detected", locations.len()), + "locations": locations + })); + } + DeadCodeResult::None => { + passed.push(json!({ + "adr": 10, + "title": "No Dead Code", + "message": "No unused items detected" + })); + } + DeadCodeResult::NotApplicable(reason) => { + passed.push(json!({ + "adr": 10, + "title": "No Dead Code", + "message": format!("Check skipped: {}", reason) + })); + } + } + + Ok(json!({ + "findings": findings, + "passed": passed, + "message": blue_core::voice::info( + &format!("{} finding(s), {} passed", findings.len(), passed.len()), + if findings.is_empty() { + Some("All testable ADRs satisfied") + } else { + Some("Review findings and address as appropriate") + } + ) + })) +} + +// ===== Helper Functions ===== + +/// Load ADR summaries from the docs/adrs directory +fn load_adr_summaries(state: &ProjectState) -> Result, ServerError> { + let adrs_path = state.home.docs_path.join("adrs"); + let mut summaries = Vec::new(); + + if !adrs_path.exists() { + return Ok(summaries); + } + + let entries = fs::read_dir(&adrs_path) + .map_err(|e| ServerError::CommandFailed(format!("Couldn't read ADRs directory: {}", e)))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "md") { + if let Ok(content) = fs::read_to_string(&path) { + if let Some(summary) = parse_adr_file(&path, &content) { + summaries.push(summary); + } + } + } + } + + // Sort by number + summaries.sort_by_key(|s| s.number); + + Ok(summaries) +} + +/// Parse an ADR file to extract summary and metadata +fn parse_adr_file(path: &Path, content: &str) -> Option { + let file_name = path.file_name()?.to_string_lossy(); + + // Extract number from filename (e.g., "0004-evidence.md") + let number: i64 = file_name + .split('-') + .next()? + .parse() + .ok()?; + + // Extract title from first heading + let title = content + .lines() + .find(|l| l.starts_with("# "))? + .trim_start_matches("# ") + .trim_start_matches("ADR ") + .trim_start_matches(&format!("{:04}: ", number)) + .to_string(); + + // Extract first paragraph as summary + let summary = extract_summary(content); + + // Extract keywords from content + let keywords = extract_keywords(content); + + // Parse metadata sections + let metadata = parse_adr_metadata(content); + + Some(AdrSummary { + number, + title, + summary, + keywords, + applies_when: metadata.applies_when, + anti_patterns: metadata.anti_patterns, + }) +} + +/// Extract summary from ADR content +fn extract_summary(content: &str) -> String { + let mut in_summary = false; + let mut summary_lines = Vec::new(); + + for line in content.lines() { + // Start capturing after the metadata table (after "---") + if line == "---" { + in_summary = true; + continue; + } + + if in_summary { + // Stop at next heading or empty line after collecting some content + if line.starts_with('#') && !summary_lines.is_empty() { + break; + } + + let trimmed = line.trim(); + if !trimmed.is_empty() { + summary_lines.push(trimmed); + if summary_lines.len() >= 3 { + break; + } + } + } + } + + summary_lines.join(" ") +} + +/// Extract keywords from ADR content for relevance matching +fn extract_keywords(content: &str) -> Vec { + let mut keywords = Vec::new(); + + // Extract from title + let title_line = content.lines().find(|l| l.starts_with("# ")); + if let Some(title) = title_line { + for word in title.to_lowercase().split_whitespace() { + let clean = word.trim_matches(|c: char| !c.is_alphanumeric()); + if clean.len() > 3 { + keywords.push(clean.to_string()); + } + } + } + + // Common ADR-related keywords to look for + let important_terms = [ + "test", "testing", "evidence", "proof", "verify", + "single", "source", "truth", "duplicate", + "integrity", "whole", "complete", + "honor", "commit", "promise", + "courage", "delete", "remove", "refactor", + "dead", "code", "unused", + "freedom", "constraint", "limit", + "faith", "believe", "trust", + "overflow", "full", "abundance", + "presence", "present", "aware", + "purpose", "meaning", "why", + "home", "belong", "welcome", + "relationship", "connect", "link", + ]; + + let content_lower = content.to_lowercase(); + for term in important_terms { + if content_lower.contains(term) { + keywords.push(term.to_string()); + } + } + + keywords.sort(); + keywords.dedup(); + keywords +} + +struct AdrMetadata { + applies_when: Vec, + anti_patterns: Vec, +} + +/// Parse ADR metadata sections (Applies When, Anti-Patterns) +fn parse_adr_metadata(content: &str) -> AdrMetadata { + let mut applies_when = Vec::new(); + let mut anti_patterns = Vec::new(); + let mut current_section = None; + + for line in content.lines() { + if line.starts_with("## Applies When") { + current_section = Some("applies_when"); + continue; + } + if line.starts_with("## Anti-Patterns") || line.starts_with("## Anti Patterns") { + current_section = Some("anti_patterns"); + continue; + } + if line.starts_with("## ") { + current_section = None; + continue; + } + + if let Some(section) = current_section { + let trimmed = line.trim(); + if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + let item = trimmed.trim_start_matches("- ").trim_start_matches("* ").to_string(); + match section { + "applies_when" => applies_when.push(item), + "anti_patterns" => anti_patterns.push(item), + _ => {} + } + } + } + } + + AdrMetadata { + applies_when, + anti_patterns, + } +} + +/// Calculate relevance score between context and ADR +fn calculate_relevance_score(context_words: &[&str], adr: &AdrSummary) -> (f64, String) { + let mut score = 0.0; + let mut reasons = Vec::new(); + + // Check title match + let title_lower = adr.title.to_lowercase(); + for word in context_words { + if title_lower.contains(word) { + score += 0.3; + reasons.push(format!("Title matches '{}'", word)); + } + } + + // Check keyword match (with stem-like matching) + let mut keyword_matches = 0; + for word in context_words { + // Match if word or keyword share a common stem (3+ chars) + let word_stem = &word[..word.len().min(4)]; + if adr.keywords.iter().any(|k| { + k.contains(word) || + word.contains(k.as_str()) || + (word.len() >= 4 && k.starts_with(word_stem)) || + (k.len() >= 4 && word.starts_with(&k[..k.len().min(4)])) + }) { + keyword_matches += 1; + } + } + if keyword_matches > 0 { + // Give more weight to keyword matches + score += 0.3 * (keyword_matches as f64 / context_words.len().max(1) as f64); + reasons.push(format!("{} keyword(s) match", keyword_matches)); + } + + // Check applies_when match (with stem-like matching) + for applies in &adr.applies_when { + let applies_lower = applies.to_lowercase(); + for word in context_words { + let word_stem = &word[..word.len().min(4)]; + // Check for word match or stem match + if applies_lower.contains(word) || + applies_lower.split_whitespace().any(|w| { + w.contains(word) || + word.contains(w) || + (w.len() >= 4 && w.starts_with(word_stem)) + }) { + score += 0.25; + reasons.push(format!("Applies when: {}", applies)); + break; + } + } + } + + // Check anti-patterns match (important for catching violations) + for anti in &adr.anti_patterns { + let anti_lower = anti.to_lowercase(); + for word in context_words { + let word_stem = &word[..word.len().min(4)]; + if anti_lower.contains(word) || + anti_lower.split_whitespace().any(|w| { + w.contains(word) || + word.contains(w) || + (w.len() >= 4 && w.starts_with(word_stem)) + }) { + score += 0.25; + reasons.push(format!("Anti-pattern match: {}", anti)); + break; + } + } + } + + // Cap at 1.0 + score = score.min(1.0); + + let reason = if reasons.is_empty() { + "Partial content match".to_string() + } else { + reasons.join("; ") + }; + + (score, reason) +} + +/// Find documents that reference an ADR +fn find_adr_references(state: &ProjectState, adr_id: Option) -> Result, ServerError> { + let mut references = Vec::new(); + + let Some(id) = adr_id else { + return Ok(references); + }; + + // Query documents that link to this ADR (where this ADR is the target) + // This requires a direct SQL query since we need to find sources that link to this target + let query = "SELECT d.id, d.doc_type, d.title, d.created_at + FROM documents d + JOIN document_links l ON l.source_id = d.id + WHERE l.target_id = ?1"; + + let conn = state.store.conn(); + let mut stmt = conn + .prepare(query) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let rows = stmt + .query_map(rusqlite::params![id], |row| { + Ok(( + row.get::<_, String>(1)?, // doc_type + row.get::<_, String>(2)?, // title + row.get::<_, Option>(3)?, // created_at + )) + }) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + for row in rows.flatten() { + let (doc_type, title, created_at) = row; + references.push(json!({ + "type": doc_type.to_lowercase(), + "title": title, + "date": created_at + })); + } + + Ok(references) +} + +/// Compute hash for caching relevance results +fn compute_context_hash(context: &str) -> String { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(context.as_bytes()); + format!("{:x}", hasher.finalize())[..16].to_string() +} + +/// Get cached relevance result (placeholder - uses in-memory for now) +fn get_cached_relevance(_state: &ProjectState, _hash: &str) -> Option { + // TODO: Implement SQLite-based caching per RFC 0004 + None +} + +/// Cache relevance result (placeholder) +fn cache_relevance(_state: &ProjectState, _hash: &str, _result: &Value) { + // TODO: Implement SQLite-based caching per RFC 0004 +} + +enum DeadCodeResult { + Found(Vec), + None, + NotApplicable(String), +} + +/// Check for dead code using cargo clippy (for Rust projects) +fn check_dead_code(project_root: &Path) -> DeadCodeResult { + let cargo_toml = project_root.join("Cargo.toml"); + if !cargo_toml.exists() { + return DeadCodeResult::NotApplicable("Not a Rust project".to_string()); + } + + // Try to run clippy with dead_code lint + let output = std::process::Command::new("cargo") + .args(["clippy", "--message-format=short", "--", "-W", "dead_code"]) + .current_dir(project_root) + .output(); + + match output { + Ok(result) => { + let stderr = String::from_utf8_lossy(&result.stderr); + let mut locations = Vec::new(); + + for line in stderr.lines() { + if line.contains("dead_code") || line.contains("unused") { + // Extract file:line format + if let Some(loc) = line.split_whitespace().next() { + if loc.contains(':') { + locations.push(loc.to_string()); + } + } + } + } + + if locations.is_empty() { + DeadCodeResult::None + } else { + DeadCodeResult::Found(locations) + } + } + Err(_) => DeadCodeResult::NotApplicable("Couldn't run cargo clippy".to_string()), + } +} + /// Convert a string to kebab-case fn to_kebab_case(s: &str) -> String { s.to_lowercase() @@ -134,3 +741,90 @@ fn to_kebab_case(s: &str) -> String { .collect::>() .join("-") } + +/// Parse ADR citations from RFC frontmatter +/// +/// Looks for patterns like: +/// | **ADRs** | 0004, 0007, 0010 | +pub fn parse_adr_citations(content: &str) -> Vec { + let mut citations = Vec::new(); + + for line in content.lines() { + if line.contains("**ADRs**") || line.contains("| ADRs |") { + // Extract numbers + for part in line.split(|c: char| !c.is_numeric()) { + if let Ok(num) = part.parse::() { + if num < 100 { + // ADR numbers are typically small + citations.push(num); + } + } + } + break; + } + } + + citations +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_kebab_case() { + assert_eq!(to_kebab_case("Evidence Based"), "evidence-based"); + assert_eq!(to_kebab_case("No Dead Code"), "no-dead-code"); + } + + #[test] + fn test_extract_keywords() { + let content = "# ADR 0004: Evidence\n\nShow, don't tell. Testing is the primary form of evidence."; + let keywords = extract_keywords(content); + assert!(keywords.contains(&"evidence".to_string())); + assert!(keywords.contains(&"testing".to_string())); + } + + #[test] + fn test_parse_adr_citations() { + let content = r#" +| **Status** | Draft | +| **ADRs** | 0004, 0007, 0010 | +"#; + let citations = parse_adr_citations(content); + assert_eq!(citations, vec![4, 7, 10]); + } + + #[test] + fn test_calculate_relevance_score() { + let adr = AdrSummary { + number: 4, + title: "Evidence".to_string(), + summary: "Show, don't tell".to_string(), + keywords: vec!["test".to_string(), "testing".to_string(), "evidence".to_string()], + applies_when: vec!["Writing tests".to_string()], + anti_patterns: vec!["Claiming code works without tests".to_string()], + }; + + let context: Vec<&str> = vec!["testing", "strategy"]; + let (score, reason) = calculate_relevance_score(&context, &adr); + assert!(score > 0.5, "Expected high relevance for testing context, got {}", score); + assert!(!reason.is_empty()); + } + + #[test] + fn test_extract_summary() { + let content = r#"# ADR 0004: Evidence + +| **Status** | Accepted | + +--- + +Show, don't tell. Testing is the primary form of evidence. + +## Context +"#; + let summary = extract_summary(content); + assert!(summary.contains("Show, don't tell")); + } +} diff --git a/crates/blue-mcp/src/handlers/dialogue.rs b/crates/blue-mcp/src/handlers/dialogue.rs index 0f97ccc..ca735ea 100644 --- a/crates/blue-mcp/src/handlers/dialogue.rs +++ b/crates/blue-mcp/src/handlers/dialogue.rs @@ -1,14 +1,17 @@ -//! Dialogue extraction tool handlers +//! Dialogue tool handlers //! -//! Extracts dialogue content from spawned agent JSONL outputs for scoring. +//! Handles dialogue document creation, storage, and extraction. +//! Dialogues capture agent conversations and link them to RFCs. -use serde::Serialize; -use serde_json::Value; use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; +use blue_core::{DocType, Document, LinkType, ProjectState}; +use serde::Serialize; +use serde_json::{json, Value}; + use crate::error::ServerError; /// Extraction status @@ -247,6 +250,371 @@ fn extract_with_rust(file_path: &Path) -> Result }) } +// ==================== Dialogue Document Handlers ==================== + +/// Handle blue_dialogue_create +/// +/// Creates a new dialogue document with SQLite metadata. +pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let rfc_title = args.get("rfc_title").and_then(|v| v.as_str()); + let summary = args.get("summary").and_then(|v| v.as_str()); + let content = args.get("content").and_then(|v| v.as_str()); + + // Validate RFC exists if provided + let rfc_doc = if let Some(rfc) = rfc_title { + Some( + state + .store + .find_document(DocType::Rfc, rfc) + .map_err(|_| { + ServerError::NotFound(format!("RFC '{}' not found", rfc)) + })?, + ) + } else { + None + }; + + // Get next dialogue number + let dialogue_number = state + .store + .next_number(DocType::Dialogue) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Generate file path with date prefix + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + let file_name = format!("{}-{}.dialogue.md", date, to_kebab_case(title)); + let file_path = PathBuf::from("dialogues").join(&file_name); + let docs_path = state.home.docs_path.clone(); + let dialogue_path = docs_path.join(&file_path); + + // Generate markdown content + let markdown = generate_dialogue_markdown( + title, + dialogue_number, + rfc_title, + summary, + content, + ); + + // Create document in SQLite store + let mut doc = Document::new(DocType::Dialogue, title, "recorded"); + doc.number = Some(dialogue_number); + doc.file_path = Some(file_path.to_string_lossy().to_string()); + + let dialogue_id = state + .store + .add_document(&doc) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Link to RFC if provided + if let Some(ref rfc) = rfc_doc { + if let (Some(rfc_id), Some(dialogue_id)) = (rfc.id, Some(dialogue_id)) { + let _ = state.store.link_documents( + dialogue_id, + rfc_id, + LinkType::DialogueToRfc, + ); + } + } + + // Create dialogues directory if it doesn't exist + if let Some(parent) = dialogue_path.parent() { + fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + } + fs::write(&dialogue_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + let hint = if rfc_title.is_some() { + "Dialogue recorded and linked to RFC." + } else { + "Dialogue recorded. Consider linking to an RFC." + }; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("Dialogue recorded: {}", title), + Some(hint) + ), + "dialogue": { + "id": dialogue_id, + "number": dialogue_number, + "title": title, + "file": dialogue_path.display().to_string(), + "linked_rfc": rfc_title, + }, + "content": markdown, + })) +} + +/// Handle blue_dialogue_get +pub fn handle_get(state: &ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let doc = state + .store + .find_document(DocType::Dialogue, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Read file content if available + let content = if let Some(ref rel_path) = doc.file_path { + let file_path = state.home.docs_path.join(rel_path); + fs::read_to_string(&file_path).ok() + } else { + None + }; + + // Get linked RFC if any + let linked_rfc = if let Some(doc_id) = doc.id { + state + .store + .get_linked_documents(doc_id, Some(LinkType::DialogueToRfc)) + .ok() + .and_then(|docs| docs.into_iter().next()) + .map(|d| d.title) + } else { + None + }; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("Dialogue: {}", doc.title), + None + ), + "dialogue": { + "id": doc.id, + "number": doc.number, + "title": doc.title, + "status": doc.status, + "file_path": doc.file_path, + "linked_rfc": linked_rfc, + "created_at": doc.created_at, + }, + "content": content, + })) +} + +/// Handle blue_dialogue_list +pub fn handle_list(state: &ProjectState, args: &Value) -> Result { + let rfc_filter = args.get("rfc_title").and_then(|v| v.as_str()); + + let all_dialogues = state + .store + .list_documents(DocType::Dialogue) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Filter by RFC if specified + let dialogues: Vec<_> = if let Some(rfc_title) = rfc_filter { + // First find the RFC + let rfc_doc = state + .store + .find_document(DocType::Rfc, rfc_title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Find dialogues linked to this RFC + if let Some(rfc_id) = rfc_doc.id { + all_dialogues + .into_iter() + .filter(|d| { + if let Some(doc_id) = d.id { + state + .store + .get_linked_documents(doc_id, Some(LinkType::DialogueToRfc)) + .map(|linked| linked.iter().any(|l| l.id == Some(rfc_id))) + .unwrap_or(false) + } else { + false + } + }) + .collect() + } else { + Vec::new() + } + } else { + all_dialogues + }; + + let hint = if dialogues.is_empty() { + if rfc_filter.is_some() { + "No dialogues for this RFC." + } else { + "No dialogues recorded. Create one with blue_dialogue_create." + } + } else { + "Use blue_dialogue_get to view full content." + }; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("{} dialogue(s)", dialogues.len()), + Some(hint) + ), + "dialogues": dialogues.iter().map(|d| json!({ + "id": d.id, + "number": d.number, + "title": d.title, + "status": d.status, + "file_path": d.file_path, + "created_at": d.created_at, + })).collect::>(), + "count": dialogues.len(), + "rfc_filter": rfc_filter, + })) +} + +/// Handle blue_dialogue_save (extends extract_dialogue to save with metadata) +pub fn handle_save(state: &mut ProjectState, args: &Value) -> Result { + let task_id = args.get("task_id").and_then(|v| v.as_str()); + let file_path_arg = args.get("file_path").and_then(|v| v.as_str()); + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + let rfc_title = args.get("rfc_title").and_then(|v| v.as_str()); + let summary = args.get("summary").and_then(|v| v.as_str()); + + // Resolve and extract content from JSONL + let jsonl_path = match (task_id, file_path_arg) { + (Some(id), _) => resolve_task_output(id)?, + (None, Some(path)) => PathBuf::from(path), + (None, None) => { + return Err(ServerError::InvalidParams); + } + }; + + // Verify file exists + if !jsonl_path.exists() { + return Err(ServerError::CommandFailed(format!( + "JSONL file not found: {}", + jsonl_path.display() + ))); + } + + // Extract dialogue content + let extraction = if jq_available() { + extract_with_jq(&jsonl_path)? + } else { + extract_with_rust(&jsonl_path)? + }; + + // Now create the dialogue document with extracted content + let create_args = json!({ + "title": title, + "rfc_title": rfc_title, + "summary": summary, + "content": extraction.text, + }); + + let mut result = handle_create(state, &create_args)?; + + // Add extraction metadata to result + if let Some(obj) = result.as_object_mut() { + obj.insert("extraction".to_string(), json!({ + "source_file": extraction.source_file, + "message_count": extraction.message_count, + "status": format!("{:?}", extraction.status).to_lowercase(), + })); + } + + Ok(result) +} + +// ==================== Helper Functions ==================== + +/// Generate dialogue markdown content +fn generate_dialogue_markdown( + title: &str, + number: i32, + rfc_title: Option<&str>, + summary: Option<&str>, + content: Option<&str>, +) -> String { + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + let time = chrono::Local::now().format("%H:%M").to_string(); + + let mut md = String::new(); + + // Title + md.push_str(&format!( + "# Dialogue {:04}: {}\n\n", + number, + to_title_case(title) + )); + + // Metadata table + md.push_str("| | |\n|---|---|\n"); + md.push_str(&format!("| **Date** | {} {} |\n", date, time)); + md.push_str("| **Status** | Recorded |\n"); + if let Some(rfc) = rfc_title { + md.push_str(&format!("| **RFC** | {} |\n", rfc)); + } + md.push_str("\n---\n\n"); + + // Summary + if let Some(sum) = summary { + md.push_str("## Summary\n\n"); + md.push_str(sum); + md.push_str("\n\n"); + } + + // Dialogue content + md.push_str("## Dialogue\n\n"); + if let Some(c) = content { + md.push_str(c); + } else { + md.push_str("[Dialogue content to be added]\n"); + } + md.push_str("\n\n"); + + // Rounds section placeholder + md.push_str("## Rounds\n\n"); + md.push_str("| Round | Topic | Outcome |\n"); + md.push_str("|-------|-------|--------|\n"); + md.push_str("| 1 | [Topic] | [Outcome] |\n"); + md.push_str("\n"); + + // Lessons learned + md.push_str("## Lessons Learned\n\n"); + md.push_str("- [Key insight from this dialogue]\n"); + + md +} + +/// Convert a string to kebab-case for filenames +fn to_kebab_case(s: &str) -> String { + s.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Convert slug to title case +fn to_title_case(s: &str) -> String { + s.split('-') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect::>() + .join(" ") +} + #[cfg(test)] mod tests { use super::*; @@ -256,4 +624,25 @@ mod tests { // Just verify this doesn't panic let _ = jq_available(); } + + #[test] + fn test_to_kebab_case() { + assert_eq!(to_kebab_case("RFC Implementation Discussion"), "rfc-implementation-discussion"); + assert_eq!(to_kebab_case("quick-chat"), "quick-chat"); + } + + #[test] + fn test_dialogue_markdown_generation() { + let md = generate_dialogue_markdown( + "test-dialogue", + 1, + Some("test-rfc"), + Some("A test summary"), + Some("Some dialogue content"), + ); + assert!(md.contains("# Dialogue 0001: Test Dialogue")); + assert!(md.contains("| **RFC** | test-rfc |")); + assert!(md.contains("A test summary")); + assert!(md.contains("Some dialogue content")); + } } diff --git a/crates/blue-mcp/src/handlers/llm.rs b/crates/blue-mcp/src/handlers/llm.rs new file mode 100644 index 0000000..8925141 --- /dev/null +++ b/crates/blue-mcp/src/handlers/llm.rs @@ -0,0 +1,357 @@ +//! LLM tool handlers +//! +//! Implements RFC 0005: Local LLM Integration. +//! Provides MCP tools for model management. + +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex, OnceLock}; + +use blue_core::{LocalLlmConfig, LlmProvider}; +use blue_ollama::{EmbeddedOllama, HealthStatus, OllamaLlm}; + +use crate::error::ServerError; + +/// Lazy-initialized shared Ollama instance +static OLLAMA: OnceLock>>> = OnceLock::new(); + +/// Get the shared Ollama instance +fn get_ollama() -> &'static Arc>> { + OLLAMA.get_or_init(|| Arc::new(Mutex::new(None))) +} + +/// Start Ollama server +pub fn handle_start(args: &Value) -> Result { + let port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16); + let model = args + .get("model") + .and_then(|v| v.as_str()) + .map(String::from); + let backend = args.get("backend").and_then(|v| v.as_str()); + let use_external = args + .get("use_external") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let config = LocalLlmConfig { + port: port.unwrap_or(11434), + model: model.unwrap_or_else(|| "qwen2.5:7b".to_string()), + backend: match backend { + Some("cuda") => blue_core::LlmBackendChoice::Cuda, + Some("mps") => blue_core::LlmBackendChoice::Mps, + Some("cpu") => blue_core::LlmBackendChoice::Cpu, + _ => blue_core::LlmBackendChoice::Auto, + }, + use_external, + ..Default::default() + }; + + let ollama = OllamaLlm::new(&config); + ollama.start().map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + let mut guard = get_ollama().lock().unwrap(); + *guard = Some(ollama); + + Ok(json!({ + "started": true, + "port": config.port, + "model": config.model, + "message": format!("Ollama started on port {}", config.port) + })) +} + +/// Stop Ollama server +pub fn handle_stop() -> Result { + let mut guard = get_ollama().lock().unwrap(); + if let Some(ref ollama) = *guard { + ollama.stop().map_err(|e| ServerError::CommandFailed(e.to_string()))?; + } + *guard = None; + + Ok(json!({ + "stopped": true, + "message": "Ollama stopped" + })) +} + +/// Check Ollama status +pub fn handle_status() -> Result { + let guard = get_ollama().lock().unwrap(); + + if let Some(ref ollama) = *guard { + let health = ollama.ollama().health_check(); + match health { + HealthStatus::Healthy { version, gpu } => { + Ok(json!({ + "running": true, + "version": version, + "gpu": gpu, + "ready": ollama.is_ready() + })) + } + HealthStatus::Unhealthy { error } => { + Ok(json!({ + "running": true, + "unhealthy": true, + "error": error + })) + } + HealthStatus::NotRunning => { + Ok(json!({ + "running": false, + "message": "Ollama is not running" + })) + } + } + } else { + // Check if there's an external Ollama running + let config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let external = EmbeddedOllama::new(&config); + if external.is_ollama_running() { + let health = external.health_check(); + match health { + HealthStatus::Healthy { version, gpu } => { + Ok(json!({ + "running": true, + "external": true, + "version": version, + "gpu": gpu + })) + } + _ => Ok(json!({ + "running": false, + "managed": false, + "message": "No managed Ollama instance" + })), + } + } else { + Ok(json!({ + "running": false, + "managed": false, + "message": "No Ollama instance found" + })) + } + } +} + +/// List available models +pub fn handle_model_list() -> Result { + // Try managed instance first + let guard = get_ollama().lock().unwrap(); + if let Some(ref ollama) = *guard { + let models = ollama + .ollama() + .list_models() + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "models": models.iter().map(|m| json!({ + "name": m.name, + "size": m.size, + "modified_at": m.modified_at + })).collect::>() + })); + } + drop(guard); + + // Try external Ollama + let config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let external = EmbeddedOllama::new(&config); + if external.is_ollama_running() { + let models = external + .list_models() + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "models": models.iter().map(|m| json!({ + "name": m.name, + "size": m.size, + "modified_at": m.modified_at + })).collect::>(), + "external": true + })); + } + + Err(ServerError::NotFound( + "No Ollama instance available. Start one first.".to_string(), + )) +} + +/// Pull a model +pub fn handle_model_pull(args: &Value) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + // Try managed instance first + let guard = get_ollama().lock().unwrap(); + if let Some(ref ollama) = *guard { + ollama + .ollama() + .pull_model(name) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "pulled": true, + "name": name, + "message": format!("Model {} pulled successfully", name) + })); + } + drop(guard); + + // Try external Ollama + let config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let external = EmbeddedOllama::new(&config); + if external.is_ollama_running() { + external + .pull_model(name) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "pulled": true, + "name": name, + "external": true, + "message": format!("Model {} pulled successfully", name) + })); + } + + Err(ServerError::NotFound( + "No Ollama instance available. Start one first.".to_string(), + )) +} + +/// Remove a model +pub fn handle_model_remove(args: &Value) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + // Try managed instance first + let guard = get_ollama().lock().unwrap(); + if let Some(ref ollama) = *guard { + ollama + .ollama() + .remove_model(name) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "removed": true, + "name": name, + "message": format!("Model {} removed", name) + })); + } + drop(guard); + + // Try external Ollama + let config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let external = EmbeddedOllama::new(&config); + if external.is_ollama_running() { + external + .remove_model(name) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "removed": true, + "name": name, + "external": true, + "message": format!("Model {} removed", name) + })); + } + + Err(ServerError::NotFound( + "No Ollama instance available. Start one first.".to_string(), + )) +} + +/// Warm up a model (load into memory) +pub fn handle_model_warmup(args: &Value) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + // Try managed instance first + let guard = get_ollama().lock().unwrap(); + if let Some(ref ollama) = *guard { + ollama + .ollama() + .warmup(name) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "warmed_up": true, + "name": name, + "message": format!("Model {} loaded into memory", name) + })); + } + drop(guard); + + // Try external Ollama + let config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let external = EmbeddedOllama::new(&config); + if external.is_ollama_running() { + external + .warmup(name) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + return Ok(json!({ + "warmed_up": true, + "name": name, + "external": true, + "message": format!("Model {} loaded into memory", name) + })); + } + + Err(ServerError::NotFound( + "No Ollama instance available. Start one first.".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_status_no_ollama() { + // Should return not running when no Ollama available + let result = handle_status(); + assert!(result.is_ok()); + let value = result.unwrap(); + // Either running (external) or not running - both are valid + assert!(value.get("running").is_some()); + } + + #[test] + fn test_model_list_requires_ollama() { + // Clear any existing instance + let mut guard = get_ollama().lock().unwrap(); + *guard = None; + drop(guard); + + // Should fail gracefully when no Ollama + let result = handle_model_list(); + // May succeed if external Ollama is running, or fail + // Just verify it doesn't panic + let _ = result; + } + + #[test] + fn test_model_pull_requires_name() { + let result = handle_model_pull(&json!({})); + assert!(result.is_err()); + } + + #[test] + fn test_model_remove_requires_name() { + let result = handle_model_remove(&json!({})); + assert!(result.is_err()); + } +} diff --git a/crates/blue-mcp/src/handlers/mod.rs b/crates/blue-mcp/src/handlers/mod.rs index d25cc40..9f1b1b1 100644 --- a/crates/blue-mcp/src/handlers/mod.rs +++ b/crates/blue-mcp/src/handlers/mod.rs @@ -10,6 +10,7 @@ pub mod dialogue_lint; pub mod env; pub mod guide; pub mod lint; +pub mod llm; pub mod playwright; pub mod postmortem; pub mod pr; diff --git a/crates/blue-mcp/src/handlers/runbook.rs b/crates/blue-mcp/src/handlers/runbook.rs index d823612..91c4fd6 100644 --- a/crates/blue-mcp/src/handlers/runbook.rs +++ b/crates/blue-mcp/src/handlers/runbook.rs @@ -1,6 +1,7 @@ //! Runbook tool handlers //! -//! Handles runbook creation and updates with RFC linking. +//! Handles runbook creation, updates, and action-based lookup with RFC linking. +//! Implements RFC 0002: Runbook Action Lookup. use std::fs; use std::path::PathBuf; @@ -10,6 +11,9 @@ use serde_json::{json, Value}; use crate::error::ServerError; +/// Metadata key for storing runbook actions +const ACTION_KEY: &str = "action"; + /// Handle blue_runbook_create pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result { let title = args @@ -31,6 +35,17 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result = args + .get("actions") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + // Validate source RFC exists if provided let source_rfc_doc = if let Some(rfc_title) = source_rfc { Some( @@ -57,8 +72,8 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result Result, owner: Option<&str>, operations: &[String], + actions: &[String], ) -> String { let mut md = String::new(); @@ -235,6 +259,11 @@ fn generate_runbook_markdown( md.push_str("| | |\n|---|---|\n"); md.push_str("| **Status** | Active |\n"); + // Actions field (RFC 0002) + if !actions.is_empty() { + md.push_str(&format!("| **Actions** | {} |\n", actions.join(", "))); + } + if let Some(o) = owner { md.push_str(&format!("| **Owner** | {} |\n", o)); } @@ -331,6 +360,309 @@ fn to_title_case(s: &str) -> String { .join(" ") } +// ===== RFC 0002: Runbook Action Lookup ===== + +/// Handle blue_runbook_lookup +/// +/// Find runbook by action query using word-based matching. +pub fn handle_lookup(state: &ProjectState, args: &Value) -> Result { + let action_query = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)? + .to_lowercase(); + + // Get all runbooks with actions from metadata + let runbooks = state + .store + .list_documents(DocType::Runbook) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Find best match + let mut best_match: Option<(Document, Vec, i32)> = None; + + for runbook in runbooks { + if let Some(doc_id) = runbook.id { + // Get actions for this runbook + let actions = get_runbook_actions(&state.store, doc_id); + + if actions.is_empty() { + continue; + } + + // Calculate best match score for this runbook + for action in &actions { + let score = calculate_match_score(&action_query, action); + if score > 0 { + if best_match.as_ref().map_or(true, |(_, _, s)| score > *s) { + best_match = Some((runbook.clone(), actions.clone(), score)); + break; // This runbook matches, move to next + } + } + } + } + } + + match best_match { + Some((runbook, actions, _score)) => { + // Parse operations from the runbook file + let operations = if let Some(ref file_path) = runbook.file_path { + let full_path = state.home.docs_path.join(file_path); + if full_path.exists() { + if let Ok(content) = fs::read_to_string(&full_path) { + parse_operations(&content) + } else { + vec![] + } + } else { + vec![] + } + } else { + vec![] + }; + + Ok(json!({ + "found": true, + "runbook": { + "title": runbook.title, + "file": runbook.file_path, + "actions": actions, + "operations": operations + }, + "hint": "Follow the steps above. Use verification to confirm success." + })) + } + None => { + Ok(json!({ + "found": false, + "hint": "No runbook found. Proceed with caution." + })) + } + } +} + +/// Handle blue_runbook_actions +/// +/// List all registered actions across runbooks. +pub fn handle_actions(state: &ProjectState) -> Result { + let runbooks = state + .store + .list_documents(DocType::Runbook) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let mut all_actions: Vec = Vec::new(); + + for runbook in runbooks { + if let Some(doc_id) = runbook.id { + let actions = get_runbook_actions(&state.store, doc_id); + for action in actions { + all_actions.push(json!({ + "action": action, + "runbook": runbook.title + })); + } + } + } + + Ok(json!({ + "actions": all_actions, + "count": all_actions.len() + })) +} + +/// Get actions for a runbook from metadata table +fn get_runbook_actions(store: &blue_core::DocumentStore, doc_id: i64) -> Vec { + let mut actions = Vec::new(); + + if let Ok(mut stmt) = store.conn().prepare( + "SELECT value FROM metadata WHERE document_id = ?1 AND key = ?2" + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![doc_id, ACTION_KEY], |row| { + row.get::<_, String>(0) + }) { + for action in rows.flatten() { + actions.push(action); + } + } + } + + actions +} + +/// Calculate match score between query and action +/// +/// Scoring: +/// - Exact match: 100 +/// - All query words in action: 90 +/// - Partial word match: 80 * (matched_words / query_words) +fn calculate_match_score(query: &str, action: &str) -> i32 { + let query = query.trim().to_lowercase(); + let action = action.trim().to_lowercase(); + + // Exact match + if query == action { + return 100; + } + + let query_words: Vec<&str> = query.split_whitespace().collect(); + let action_words: Vec<&str> = action.split_whitespace().collect(); + + if query_words.is_empty() { + return 0; + } + + // Count how many query words are in action + let matched = query_words.iter().filter(|qw| action_words.contains(qw)).count(); + + // All query words match (subset) + if matched == query_words.len() { + return 90; + } + + // Partial match + if matched > 0 { + return (80 * matched as i32) / query_words.len() as i32; + } + + // Check for substring match in any word + let has_substring = query_words.iter().any(|qw| { + action_words.iter().any(|aw| aw.contains(qw) || qw.contains(aw)) + }); + + if has_substring { + return 50; + } + + 0 +} + +/// Parse operations from runbook markdown content +fn parse_operations(content: &str) -> Vec { + let mut operations = Vec::new(); + let mut current_op: Option = None; + let mut current_section = Section::None; + + for line in content.lines() { + // Detect operation header + if line.starts_with("### Operation:") { + // Save previous operation + if let Some(op) = current_op.take() { + operations.push(op.to_json()); + } + let name = line.trim_start_matches("### Operation:").trim().to_string(); + current_op = Some(ParsedOperation::new(name)); + current_section = Section::None; + continue; + } + + // Skip if we're not in an operation + let Some(ref mut op) = current_op else { + continue; + }; + + // Detect section headers within operation + if line.starts_with("**When to use**:") { + op.when_to_use = line.trim_start_matches("**When to use**:").trim().to_string(); + continue; + } + + if line.starts_with("**Steps**:") { + current_section = Section::Steps; + continue; + } + + if line.starts_with("**Verification**:") { + current_section = Section::Verification; + continue; + } + + if line.starts_with("**Rollback**:") { + current_section = Section::Rollback; + continue; + } + + // New top-level section ends operation parsing + if line.starts_with("## ") { + if let Some(op) = current_op.take() { + operations.push(op.to_json()); + } + break; + } + + // Collect content based on current section + match current_section { + Section::Steps => { + if line.starts_with("1.") || line.starts_with("2.") || line.starts_with("3.") + || line.starts_with("4.") || line.starts_with("5.") { + let step = line.trim_start_matches(|c: char| c.is_numeric() || c == '.').trim(); + if !step.is_empty() { + op.steps.push(step.to_string()); + } + } + } + Section::Verification => { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with("```") { + op.verification.push(trimmed.to_string()); + } + } + Section::Rollback => { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with("```") { + op.rollback.push(trimmed.to_string()); + } + } + Section::None => {} + } + } + + // Don't forget the last operation + if let Some(op) = current_op { + operations.push(op.to_json()); + } + + operations +} + +#[derive(Debug)] +enum Section { + None, + Steps, + Verification, + Rollback, +} + +#[derive(Debug)] +struct ParsedOperation { + name: String, + when_to_use: String, + steps: Vec, + verification: Vec, + rollback: Vec, +} + +impl ParsedOperation { + fn new(name: String) -> Self { + Self { + name, + when_to_use: String::new(), + steps: Vec::new(), + verification: Vec::new(), + rollback: Vec::new(), + } + } + + fn to_json(&self) -> Value { + json!({ + "name": self.name, + "when_to_use": self.when_to_use, + "steps": self.steps, + "verification": self.verification.join("\n"), + "rollback": self.rollback.join("\n") + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -340,4 +672,118 @@ mod tests { assert_eq!(to_kebab_case("Deploy Service"), "deploy-service"); assert_eq!(to_kebab_case("API Gateway Runbook"), "api-gateway-runbook"); } + + #[test] + fn test_match_score_exact() { + assert_eq!(calculate_match_score("docker build", "docker build"), 100); + assert_eq!(calculate_match_score("DOCKER BUILD", "docker build"), 100); + } + + #[test] + fn test_match_score_all_words() { + assert_eq!(calculate_match_score("docker", "docker build"), 90); + assert_eq!(calculate_match_score("build", "docker build"), 90); + } + + #[test] + fn test_match_score_partial() { + // "docker" matches one of two words in "build image" = 0 + // But "build" matches "build image" = 90 + assert_eq!(calculate_match_score("build", "build image"), 90); + // Neither "test" nor "suite" is in "docker build" + assert_eq!(calculate_match_score("test suite", "docker build"), 0); + } + + #[test] + fn test_match_score_no_match() { + assert_eq!(calculate_match_score("deploy", "docker build"), 0); + assert_eq!(calculate_match_score("", "docker build"), 0); + } + + #[test] + fn test_parse_operations() { + let content = r#"# Runbook: Docker Build + +## Common Operations + +### Operation: Build Production Image + +**When to use**: Preparing for deployment + +**Steps**: +1. Ensure on correct branch +2. Pull latest +3. Build image + +**Verification**: +```bash +docker images | grep myapp +``` + +**Rollback**: +```bash +docker rmi myapp:latest +``` + +## Troubleshooting +"#; + + let ops = parse_operations(content); + assert_eq!(ops.len(), 1); + + let op = &ops[0]; + assert_eq!(op["name"], "Build Production Image"); + assert_eq!(op["when_to_use"], "Preparing for deployment"); + + let steps = op["steps"].as_array().unwrap(); + assert_eq!(steps.len(), 3); + assert_eq!(steps[0], "Ensure on correct branch"); + } + + #[test] + fn test_parse_operations_multiple() { + let content = r#"## Common Operations + +### Operation: Start Service + +**When to use**: After deployment + +**Steps**: +1. Run start command + +**Verification**: +```bash +curl localhost:8080/health +``` + +**Rollback**: +```bash +./stop.sh +``` + +### Operation: Stop Service + +**When to use**: Before maintenance + +**Steps**: +1. Run stop command + +**Verification**: +```bash +pgrep myapp || echo "Stopped" +``` + +**Rollback**: +```bash +./start.sh +``` + +## Troubleshooting +"#; + + let ops = parse_operations(content); + assert_eq!(ops.len(), 2); + assert_eq!(ops[0]["name"], "Start Service"); + assert_eq!(ops[1]["name"], "Stop Service"); + } } diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index cc46145..9aa56d5 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -404,6 +404,50 @@ impl BlueServer { "required": ["title"] } }, + { + "name": "blue_adr_list", + "description": "List all ADRs with summaries.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "blue_adr_get", + "description": "Get full ADR content with referenced_by information.", + "inputSchema": { + "type": "object", + "properties": { + "number": { + "type": "number", + "description": "ADR number to retrieve" + } + }, + "required": ["number"] + } + }, + { + "name": "blue_adr_relevant", + "description": "Find relevant ADRs based on context. Uses keyword matching (AI matching when LLM available).", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "Context to match against (e.g., 'testing strategy', 'deleting old code')" + } + }, + "required": ["context"] + } + }, + { + "name": "blue_adr_audit", + "description": "Scan for potential ADR violations. Only checks testable ADRs (Evidence, Single Source, No Dead Code).", + "inputSchema": { + "type": "object", + "properties": {} + } + }, { "name": "blue_decision_create", "description": "Create a lightweight Decision Note.", @@ -1211,6 +1255,89 @@ impl BlueServer { } } }, + { + "name": "blue_dialogue_create", + "description": "Create a new dialogue document with SQLite metadata. Dialogues capture agent conversations and can be linked to RFCs.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Dialogue title" + }, + "rfc_title": { + "type": "string", + "description": "RFC title to link this dialogue to" + }, + "summary": { + "type": "string", + "description": "Brief summary of the dialogue" + }, + "content": { + "type": "string", + "description": "Full dialogue content" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_dialogue_get", + "description": "Get a dialogue document by title.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Dialogue title or number" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_dialogue_list", + "description": "List all dialogue documents, optionally filtered by RFC.", + "inputSchema": { + "type": "object", + "properties": { + "rfc_title": { + "type": "string", + "description": "Filter dialogues by RFC title" + } + } + } + }, + { + "name": "blue_dialogue_save", + "description": "Extract dialogue from JSONL and save as a dialogue document with metadata.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Dialogue title" + }, + "task_id": { + "type": "string", + "description": "Task ID to extract dialogue from" + }, + "file_path": { + "type": "string", + "description": "Path to JSONL file (alternative to task_id)" + }, + "rfc_title": { + "type": "string", + "description": "RFC title to link this dialogue to" + }, + "summary": { + "type": "string", + "description": "Brief summary of the dialogue" + } + }, + "required": ["title"] + } + }, // Phase 8: Playwright verification { "name": "blue_playwright_verify", @@ -1328,6 +1455,11 @@ impl BlueServer { "type": "array", "items": { "type": "string" }, "description": "Initial operations to document" + }, + "actions": { + "type": "array", + "items": { "type": "string" }, + "description": "Action tags for lookup (e.g., ['docker build', 'build image'])" } }, "required": ["title"] @@ -1355,6 +1487,28 @@ impl BlueServer { "required": ["title"] } }, + { + "name": "blue_runbook_lookup", + "description": "Find a runbook by action query. Uses word-based matching to find the best runbook for a given action like 'docker build' or 'deploy staging'.", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action to look up (e.g., 'docker build', 'deploy staging')" + } + }, + "required": ["action"] + } + }, + { + "name": "blue_runbook_actions", + "description": "List all registered actions across runbooks. Use this to discover what runbooks are available.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, { "name": "blue_realm_status", "description": "Get realm overview including repos, domains, contracts, and bindings. Returns pending notifications.", @@ -1503,6 +1657,99 @@ impl BlueServer { }, "required": ["cwd"] } + }, + // RFC 0005: Local LLM Integration + { + "name": "blue_llm_start", + "description": "Start the Ollama LLM server. Manages an embedded Ollama instance or uses an external one.", + "inputSchema": { + "type": "object", + "properties": { + "port": { + "type": "number", + "description": "Port to run on (default: 11434)" + }, + "model": { + "type": "string", + "description": "Default model to use (default: qwen2.5:7b)" + }, + "backend": { + "type": "string", + "enum": ["auto", "cuda", "mps", "cpu"], + "description": "Backend to use (default: auto)" + }, + "use_external": { + "type": "boolean", + "description": "Use external Ollama instead of embedded (default: false)" + } + } + } + }, + { + "name": "blue_llm_stop", + "description": "Stop the managed Ollama LLM server.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "blue_llm_status", + "description": "Check LLM server status. Returns running state, version, and GPU info.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "blue_model_list", + "description": "List available models in the Ollama instance.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "blue_model_pull", + "description": "Pull a model from the Ollama registry.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Model name (e.g., 'qwen2.5:7b', 'llama3.2:3b')" + } + }, + "required": ["name"] + } + }, + { + "name": "blue_model_remove", + "description": "Remove a model from the Ollama instance.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Model name to remove" + } + }, + "required": ["name"] + } + }, + { + "name": "blue_model_warmup", + "description": "Warm up a model by loading it into memory.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Model name to warm up" + } + }, + "required": ["name"] + } } ] })) @@ -1536,6 +1783,10 @@ impl BlueServer { "blue_spike_create" => self.handle_spike_create(&call.arguments), "blue_spike_complete" => self.handle_spike_complete(&call.arguments), "blue_adr_create" => self.handle_adr_create(&call.arguments), + "blue_adr_list" => self.handle_adr_list(), + "blue_adr_get" => self.handle_adr_get(&call.arguments), + "blue_adr_relevant" => self.handle_adr_relevant(&call.arguments), + "blue_adr_audit" => self.handle_adr_audit(), "blue_decision_create" => self.handle_decision_create(&call.arguments), "blue_worktree_create" => self.handle_worktree_create(&call.arguments), "blue_worktree_list" => self.handle_worktree_list(&call.arguments), @@ -1584,6 +1835,10 @@ impl BlueServer { // Phase 8: Dialogue handlers "blue_dialogue_lint" => self.handle_dialogue_lint(&call.arguments), "blue_extract_dialogue" => self.handle_extract_dialogue(&call.arguments), + "blue_dialogue_create" => self.handle_dialogue_create(&call.arguments), + "blue_dialogue_get" => self.handle_dialogue_get(&call.arguments), + "blue_dialogue_list" => self.handle_dialogue_list(&call.arguments), + "blue_dialogue_save" => self.handle_dialogue_save(&call.arguments), // Phase 8: Playwright handler "blue_playwright_verify" => self.handle_playwright_verify(&call.arguments), // Phase 9: Post-mortem handlers @@ -1592,6 +1847,8 @@ impl BlueServer { // Phase 9: Runbook handlers "blue_runbook_create" => self.handle_runbook_create(&call.arguments), "blue_runbook_update" => self.handle_runbook_update(&call.arguments), + "blue_runbook_lookup" => self.handle_runbook_lookup(&call.arguments), + "blue_runbook_actions" => self.handle_runbook_actions(), // Phase 10: Realm tools (RFC 0002) "blue_realm_status" => self.handle_realm_status(&call.arguments), "blue_realm_check" => self.handle_realm_check(&call.arguments), @@ -1601,6 +1858,14 @@ impl BlueServer { "blue_realm_worktree_create" => self.handle_realm_worktree_create(&call.arguments), "blue_realm_pr_status" => self.handle_realm_pr_status(&call.arguments), "blue_notifications_list" => self.handle_notifications_list(&call.arguments), + // RFC 0005: LLM tools + "blue_llm_start" => crate::handlers::llm::handle_start(&call.arguments.unwrap_or_default()), + "blue_llm_stop" => crate::handlers::llm::handle_stop(), + "blue_llm_status" => crate::handlers::llm::handle_status(), + "blue_model_list" => crate::handlers::llm::handle_model_list(), + "blue_model_pull" => crate::handlers::llm::handle_model_pull(&call.arguments.unwrap_or_default()), + "blue_model_remove" => crate::handlers::llm::handle_model_remove(&call.arguments.unwrap_or_default()), + "blue_model_warmup" => crate::handlers::llm::handle_model_warmup(&call.arguments.unwrap_or_default()), _ => Err(ServerError::ToolNotFound(call.name)), }?; @@ -1986,6 +2251,14 @@ impl BlueServer { let state = self.ensure_state()?; + // Check for adr: prefix query (RFC 0004) + if let Some(adr_num_str) = query.strip_prefix("adr:") { + if let Ok(adr_num) = adr_num_str.trim().parse::() { + // Find documents that cite this ADR + return Self::search_adr_citations(state, adr_num, limit); + } + } + let results = state.store.search_documents(query, doc_type, limit) .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; @@ -2001,6 +2274,69 @@ impl BlueServer { })) } + /// Search for documents citing a specific ADR (RFC 0004) + fn search_adr_citations(state: &ProjectState, adr_num: i32, limit: usize) -> Result { + // Find the ADR document first + let adrs = state.store.list_documents(DocType::Adr) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let adr_doc = adrs.into_iter().find(|d| d.number == Some(adr_num)); + + let Some(adr) = adr_doc else { + return Ok(json!({ + "query": format!("adr:{}", adr_num), + "count": 0, + "results": [], + "message": format!("ADR {} not found", adr_num) + })); + }; + + let Some(adr_id) = adr.id else { + return Ok(json!({ + "query": format!("adr:{}", adr_num), + "count": 0, + "results": [] + })); + }; + + // Find documents that link to this ADR + let query = "SELECT d.id, d.doc_type, d.title, d.status + FROM documents d + JOIN document_links l ON l.source_id = d.id + WHERE l.target_id = ?1 + LIMIT ?2"; + + let conn = state.store.conn(); + let mut stmt = conn.prepare(query) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let rows = stmt.query_map(rusqlite::params![adr_id, limit], |row| { + Ok(( + row.get::<_, String>(1)?, // doc_type + row.get::<_, String>(2)?, // title + row.get::<_, String>(3)?, // status + )) + }).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let mut results = Vec::new(); + for row in rows.flatten() { + let (doc_type, title, status) = row; + results.push(json!({ + "title": title, + "type": doc_type, + "status": status, + "score": 1.0 + })); + } + + Ok(json!({ + "query": format!("adr:{}", adr_num), + "adr_title": adr.title, + "count": results.len(), + "results": results + })) + } + // Phase 2: Workflow handlers fn handle_spike_create(&mut self, args: &Option) -> Result { @@ -2021,6 +2357,28 @@ impl BlueServer { crate::handlers::adr::handle_create(state, args) } + fn handle_adr_list(&mut self) -> Result { + let state = self.ensure_state()?; + crate::handlers::adr::handle_list(state) + } + + fn handle_adr_get(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::adr::handle_get(state, args) + } + + fn handle_adr_relevant(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::adr::handle_relevant(state, args) + } + + fn handle_adr_audit(&mut self) -> Result { + let state = self.ensure_state()?; + crate::handlers::adr::handle_audit(state) + } + fn handle_decision_create(&mut self, args: &Option) -> Result { let args = args.as_ref().ok_or(ServerError::InvalidParams)?; let state = self.ensure_state()?; @@ -2278,6 +2636,31 @@ impl BlueServer { crate::handlers::dialogue::handle_extract_dialogue(args) } + fn handle_dialogue_create(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state_mut()?; + crate::handlers::dialogue::handle_create(state, args) + } + + fn handle_dialogue_get(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::dialogue::handle_get(state, args) + } + + fn handle_dialogue_list(&mut self, args: &Option) -> Result { + let empty = json!({}); + let args = args.as_ref().unwrap_or(&empty); + let state = self.ensure_state()?; + crate::handlers::dialogue::handle_list(state, args) + } + + fn handle_dialogue_save(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state_mut()?; + crate::handlers::dialogue::handle_save(state, args) + } + fn handle_playwright_verify(&mut self, args: &Option) -> Result { let args = args.as_ref().ok_or(ServerError::InvalidParams)?; crate::handlers::playwright::handle_verify(args) @@ -2311,6 +2694,17 @@ impl BlueServer { crate::handlers::runbook::handle_update(state, args) } + fn handle_runbook_lookup(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::runbook::handle_lookup(state, args) + } + + fn handle_runbook_actions(&mut self) -> Result { + let state = self.ensure_state()?; + crate::handlers::runbook::handle_actions(state) + } + // Phase 10: Realm handlers (RFC 0002) fn handle_realm_status(&mut self, _args: &Option) -> Result { diff --git a/crates/blue-ollama/Cargo.toml b/crates/blue-ollama/Cargo.toml new file mode 100644 index 0000000..4be3f97 --- /dev/null +++ b/crates/blue-ollama/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "blue-ollama" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Embedded Ollama server management for Blue" + +[dependencies] +blue-core.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +reqwest.workspace = true +sha2.workspace = true +dirs.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[build-dependencies] +reqwest = { version = "0.12", features = ["blocking"] } +sha2 = "0.10" diff --git a/crates/blue-ollama/src/lib.rs b/crates/blue-ollama/src/lib.rs new file mode 100644 index 0000000..5c1efa0 --- /dev/null +++ b/crates/blue-ollama/src/lib.rs @@ -0,0 +1,671 @@ +//! Blue Ollama - Embedded Ollama Server Management +//! +//! Implements RFC 0005: Local LLM Integration. +//! +//! This crate provides: +//! - Embedded Ollama server management +//! - OllamaLlm implementation of LlmProvider trait +//! - Model management (pull, list, remove) +//! - Health monitoring and recovery + +use std::path::PathBuf; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use blue_core::{ + CompletionOptions, CompletionResult, LlmBackendChoice, LlmError, LlmProvider, LocalLlmConfig, +}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +/// Ollama version embedded with Blue +pub const OLLAMA_VERSION: &str = "0.5.4"; + +/// Default Ollama port +pub const DEFAULT_PORT: u16 = 11434; + +/// Ollama API response for version +#[derive(Debug, Deserialize)] +pub struct VersionResponse { + pub version: String, + #[serde(default)] + pub gpu: Option, +} + +/// Ollama model info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub name: String, + pub size: u64, + pub modified_at: String, + #[serde(default)] + pub digest: String, +} + +/// List of models response +#[derive(Debug, Deserialize)] +pub struct ModelsResponse { + pub models: Vec, +} + +/// Generate request +#[derive(Debug, Serialize)] +struct GenerateRequest { + model: String, + prompt: String, + stream: bool, + options: GenerateOptions, +} + +#[derive(Debug, Serialize)] +struct GenerateOptions { + num_predict: usize, + temperature: f32, + stop: Vec, +} + +/// Generate response +#[derive(Debug, Deserialize)] +struct GenerateResponse { + response: String, + #[serde(default)] + prompt_eval_count: Option, + #[serde(default)] + eval_count: Option, +} + +/// Health status of Ollama +#[derive(Debug, Clone)] +pub enum HealthStatus { + Healthy { version: String, gpu: Option }, + Unhealthy { error: String }, + NotRunning, +} + +/// Ollama operation mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OllamaMode { + /// Blue manages embedded Ollama + Embedded, + /// Using external Ollama instance + External, +} + +/// Embedded Ollama server manager +pub struct EmbeddedOllama { + /// Running Ollama process + process: Mutex>, + /// Port Ollama is running on + port: u16, + /// Directory for models + models_dir: PathBuf, + /// Backend configuration + backend: LlmBackendChoice, + /// Operation mode + mode: OllamaMode, + /// Is server ready + ready: AtomicBool, + /// HTTP client + client: reqwest::blocking::Client, +} + +impl EmbeddedOllama { + /// Create a new embedded Ollama manager + pub fn new(config: &LocalLlmConfig) -> Self { + let models_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("ollama") + .join("models"); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(300)) // 5 min for model operations + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()); + + Self { + process: Mutex::new(None), + port: config.port, + models_dir, + backend: config.backend, + mode: if config.use_external { + OllamaMode::External + } else { + OllamaMode::Embedded + }, + ready: AtomicBool::new(false), + client, + } + } + + /// Get the base URL for Ollama API + fn base_url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + /// Check if port is in use + fn port_in_use(port: u16) -> bool { + std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() + } + + /// Check if Ollama is already running on the port + pub fn is_ollama_running(&self) -> bool { + if let Ok(resp) = self.client.get(format!("{}/api/version", self.base_url())).send() { + if let Ok(version) = resp.json::() { + debug!("Found running Ollama: {}", version.version); + return true; + } + } + false + } + + /// Find a free port starting from the given port + #[allow(dead_code)] + fn find_free_port(start: u16) -> Option { + for port in start..start + 100 { + if !Self::port_in_use(port) { + return Some(port); + } + } + None + } + + /// Get path to bundled Ollama binary + /// + /// Resolution order: + /// 1. BLUE_OLLAMA_PATH environment variable (for air-gapped builds) + /// 2. Bundled binary next to executable + /// 3. Common system locations (/usr/local/bin, /opt/homebrew/bin) + /// 4. Fall back to PATH lookup + pub fn bundled_binary_path() -> PathBuf { + // First check BLUE_OLLAMA_PATH for air-gapped/custom builds + if let Ok(custom_path) = std::env::var("BLUE_OLLAMA_PATH") { + let path = PathBuf::from(&custom_path); + if path.exists() { + debug!("Using BLUE_OLLAMA_PATH: {}", custom_path); + return path; + } + } + + // In development, look for it in the target directory + // In production, it's bundled with the binary + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + let binary_name = "ollama"; + #[cfg(target_os = "linux")] + let binary_name = "ollama"; + #[cfg(target_os = "windows")] + let binary_name = "ollama.exe"; + + // Check common locations + let candidates = vec![ + exe_dir.join(binary_name), + exe_dir.join("bin").join(binary_name), + PathBuf::from("/usr/local/bin/ollama"), + PathBuf::from("/opt/homebrew/bin/ollama"), + ]; + + for candidate in candidates { + if candidate.exists() { + return candidate; + } + } + + // Fall back to PATH + PathBuf::from(binary_name) + } + + /// Start the embedded Ollama server + pub fn start(&self) -> Result<(), LlmError> { + // Check if already running + if self.ready.load(Ordering::SeqCst) { + return Ok(()); + } + + // Check if port is in use + if Self::port_in_use(self.port) { + if self.is_ollama_running() { + // Use existing Ollama instance + info!("Using existing Ollama on port {}", self.port); + self.ready.store(true, Ordering::SeqCst); + return Ok(()); + } else { + // Something else is on the port + return Err(LlmError::NotAvailable(format!( + "Port {} is in use by another service", + self.port + ))); + } + } + + // External mode - don't start, just check + if self.mode == OllamaMode::External { + return Err(LlmError::NotAvailable( + "External Ollama not running".to_string(), + )); + } + + // Start embedded Ollama + let binary = Self::bundled_binary_path(); + info!("Starting Ollama from {:?}", binary); + + let mut cmd = Command::new(&binary); + cmd.arg("serve"); + cmd.env("OLLAMA_HOST", format!("127.0.0.1:{}", self.port)); + cmd.env("OLLAMA_MODELS", &self.models_dir); + + // Configure backend + match self.backend { + LlmBackendChoice::Cuda => { + cmd.env("CUDA_VISIBLE_DEVICES", "0"); + } + LlmBackendChoice::Mps => { + cmd.env("CUDA_VISIBLE_DEVICES", ""); + } + LlmBackendChoice::Cpu => { + cmd.env("CUDA_VISIBLE_DEVICES", ""); + cmd.env("OLLAMA_NO_METAL", "1"); + } + LlmBackendChoice::Auto => { + // Let Ollama auto-detect + } + } + + // Suppress stdout/stderr in background + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::null()); + + let child = cmd.spawn().map_err(|e| { + LlmError::NotAvailable(format!("Failed to start Ollama: {}", e)) + })?; + + *self.process.lock().unwrap() = Some(child); + + // Wait for server to be ready + self.wait_for_ready()?; + + Ok(()) + } + + /// Wait for Ollama to be ready + fn wait_for_ready(&self) -> Result<(), LlmError> { + let max_attempts = 30; // 30 seconds + for i in 0..max_attempts { + if self.is_ollama_running() { + info!("Ollama ready after {}s", i); + self.ready.store(true, Ordering::SeqCst); + return Ok(()); + } + std::thread::sleep(Duration::from_secs(1)); + } + + Err(LlmError::NotAvailable( + "Ollama failed to start within 30 seconds".to_string(), + )) + } + + /// Stop the embedded Ollama server + pub fn stop(&self) -> Result<(), LlmError> { + self.ready.store(false, Ordering::SeqCst); + + let mut process = self.process.lock().unwrap(); + if let Some(mut child) = process.take() { + // Try graceful shutdown first + let _ = self.client.post(format!("{}/api/shutdown", self.base_url())).send(); + + // Wait briefly for graceful shutdown + std::thread::sleep(Duration::from_secs(2)); + + // Force kill if still running + let _ = child.kill(); + let _ = child.wait(); + + info!("Ollama stopped"); + } + + Ok(()) + } + + /// Get health status + pub fn health_check(&self) -> HealthStatus { + match self.client.get(format!("{}/api/version", self.base_url())).send() { + Ok(resp) => { + match resp.json::() { + Ok(version) => HealthStatus::Healthy { + version: version.version, + gpu: version.gpu, + }, + Err(e) => HealthStatus::Unhealthy { + error: e.to_string(), + }, + } + } + Err(_) => HealthStatus::NotRunning, + } + } + + /// List available models + pub fn list_models(&self) -> Result, LlmError> { + let resp = self + .client + .get(format!("{}/api/tags", self.base_url())) + .send() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + let models: ModelsResponse = resp + .json() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + Ok(models.models) + } + + /// Pull a model + pub fn pull_model(&self, name: &str) -> Result<(), LlmError> { + info!("Pulling model: {}", name); + + let resp = self + .client + .post(format!("{}/api/pull", self.base_url())) + .json(&serde_json::json!({ "name": name, "stream": false })) + .send() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + if !resp.status().is_success() { + return Err(LlmError::RequestFailed(format!( + "Pull failed: {}", + resp.status() + ))); + } + + info!("Model {} pulled successfully", name); + Ok(()) + } + + /// Remove a model + pub fn remove_model(&self, name: &str) -> Result<(), LlmError> { + let resp = self + .client + .delete(format!("{}/api/delete", self.base_url())) + .json(&serde_json::json!({ "name": name })) + .send() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + if !resp.status().is_success() { + return Err(LlmError::RequestFailed(format!( + "Delete failed: {}", + resp.status() + ))); + } + + Ok(()) + } + + /// Warm up a model (load into memory) + pub fn warmup(&self, model: &str) -> Result<(), LlmError> { + info!("Warming up model: {}", model); + + let resp = self + .client + .post(format!("{}/api/generate", self.base_url())) + .json(&serde_json::json!({ + "model": model, + "prompt": "Hi", + "stream": false, + "options": { "num_predict": 1 } + })) + .send() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + if !resp.status().is_success() { + return Err(LlmError::RequestFailed(format!( + "Warmup failed: {}", + resp.status() + ))); + } + + info!("Model {} warmed up", model); + Ok(()) + } + + /// Generate completion + pub fn generate( + &self, + model: &str, + prompt: &str, + options: &CompletionOptions, + ) -> Result { + let request = GenerateRequest { + model: model.to_string(), + prompt: prompt.to_string(), + stream: false, + options: GenerateOptions { + num_predict: options.max_tokens, + temperature: options.temperature, + stop: options.stop_sequences.clone(), + }, + }; + + let resp = self + .client + .post(format!("{}/api/generate", self.base_url())) + .json(&request) + .send() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(LlmError::RequestFailed(format!( + "Generate failed: {} - {}", + status, body + ))); + } + + let response: GenerateResponse = resp + .json() + .map_err(|e| LlmError::RequestFailed(e.to_string()))?; + + Ok(CompletionResult { + text: response.response, + prompt_tokens: response.prompt_eval_count, + completion_tokens: response.eval_count, + provider: "ollama".to_string(), + }) + } + + /// Check if ready + pub fn is_ready(&self) -> bool { + self.ready.load(Ordering::SeqCst) + } +} + +impl Drop for EmbeddedOllama { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +/// Ollama LLM provider +pub struct OllamaLlm { + ollama: EmbeddedOllama, + model: String, +} + +impl OllamaLlm { + /// Create a new Ollama LLM provider + pub fn new(config: &LocalLlmConfig) -> Self { + Self { + ollama: EmbeddedOllama::new(config), + model: config.model.clone(), + } + } + + /// Start the Ollama server + pub fn start(&self) -> Result<(), LlmError> { + self.ollama.start() + } + + /// Stop the Ollama server + pub fn stop(&self) -> Result<(), LlmError> { + self.ollama.stop() + } + + /// Get the embedded Ollama manager + pub fn ollama(&self) -> &EmbeddedOllama { + &self.ollama + } +} + +impl LlmProvider for OllamaLlm { + fn complete(&self, prompt: &str, options: &CompletionOptions) -> Result { + if !self.ollama.is_ready() { + return Err(LlmError::NotAvailable("Ollama not started".to_string())); + } + + self.ollama.generate(&self.model, prompt, options) + } + + fn name(&self) -> &str { + "ollama" + } + + fn is_ready(&self) -> bool { + self.ollama.is_ready() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_base_url() { + let config = LocalLlmConfig::default(); + let ollama = EmbeddedOllama::new(&config); + assert_eq!(ollama.base_url(), "http://127.0.0.1:11434"); + } + + #[test] + fn test_base_url_custom_port() { + let config = LocalLlmConfig { + port: 12345, + ..Default::default() + }; + let ollama = EmbeddedOllama::new(&config); + assert_eq!(ollama.base_url(), "http://127.0.0.1:12345"); + } + + #[test] + fn test_health_status_not_running() { + let config = LocalLlmConfig { + port: 19999, // Unlikely to be in use + ..Default::default() + }; + let ollama = EmbeddedOllama::new(&config); + matches!(ollama.health_check(), HealthStatus::NotRunning); + } + + #[test] + fn test_ollama_mode_embedded() { + let config = LocalLlmConfig { + use_external: false, + ..Default::default() + }; + let ollama = EmbeddedOllama::new(&config); + assert_eq!(ollama.mode, OllamaMode::Embedded); + } + + #[test] + fn test_ollama_mode_external() { + let config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let ollama = EmbeddedOllama::new(&config); + assert_eq!(ollama.mode, OllamaMode::External); + } + + #[test] + fn test_port_in_use_detection() { + // Port 22 is usually in use (SSH) on most systems + // But we can't rely on that, so just verify the function doesn't panic + let _ = EmbeddedOllama::port_in_use(22); + let _ = EmbeddedOllama::port_in_use(65535); + } + + #[test] + fn test_bundled_binary_path_returns_path() { + // Should return some path (either found or fallback) + let path = EmbeddedOllama::bundled_binary_path(); + assert!(!path.as_os_str().is_empty()); + } + + #[test] + fn test_is_ready_initially_false() { + let config = LocalLlmConfig { + port: 19998, + ..Default::default() + }; + let ollama = EmbeddedOllama::new(&config); + assert!(!ollama.is_ready()); + } + + #[test] + fn test_ollama_llm_name() { + let config = LocalLlmConfig::default(); + let llm = OllamaLlm::new(&config); + assert_eq!(llm.name(), "ollama"); + } + + #[test] + fn test_ollama_llm_not_ready_without_start() { + let config = LocalLlmConfig { + port: 19997, + ..Default::default() + }; + let llm = OllamaLlm::new(&config); + assert!(!llm.is_ready()); + } + + #[test] + fn test_complete_fails_when_not_ready() { + let config = LocalLlmConfig { + port: 19996, + ..Default::default() + }; + let llm = OllamaLlm::new(&config); + let options = CompletionOptions::default(); + let result = llm.complete("test prompt", &options); + assert!(result.is_err()); + } + + #[test] + fn test_generate_options_serialization() { + let options = GenerateOptions { + num_predict: 100, + temperature: 0.5, + stop: vec!["stop1".to_string()], + }; + let json = serde_json::to_string(&options).unwrap(); + assert!(json.contains("\"num_predict\":100")); + assert!(json.contains("\"temperature\":0.5")); + } + + #[test] + fn test_model_info_clone() { + let info = ModelInfo { + name: "test-model".to_string(), + size: 1024, + modified_at: "2024-01-01".to_string(), + digest: "abc123".to_string(), + }; + let cloned = info.clone(); + assert_eq!(cloned.name, info.name); + assert_eq!(cloned.size, info.size); + } +}