feat: Implement RFCs 0002, 0004, and 0005
RFC 0002 (runbook-action-lookup): - Add action metadata storage to runbooks - Implement blue_runbook_lookup with word-based matching - Add blue_runbook_actions to list all actions RFC 0004 (adr-adherence): - Add blue_adr_list, blue_adr_get, blue_adr_relevant, blue_adr_audit - Implement keyword-based relevance matching with stem-like prefixes - Add adr:N query support in blue_search RFC 0005 (local-llm-integration): - Create blue-ollama crate for embedded Ollama server management - Add LlmProvider trait and MockLlm in blue-core - Implement OllamaLlm with HTTP client for model operations - Add MCP tools: blue_llm_start/stop/status, blue_model_pull/list/remove/warmup - Support BLUE_OLLAMA_PATH env var for air-gapped builds Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2fdf29d56e
commit
7dd263f1f9
13 changed files with 3282 additions and 11 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
282
crates/blue-core/src/llm.rs
Normal file
282
crates/blue-core/src/llm.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<usize>,
|
||||
/// Tokens generated
|
||||
pub completion_tokens: Option<usize>,
|
||||
/// 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<CompletionResult, LlmError>;
|
||||
|
||||
/// 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<String>,
|
||||
current: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl MockLlm {
|
||||
/// Create a new mock LLM with predefined responses
|
||||
pub fn new(responses: Vec<String>) -> 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<CompletionResult, LlmError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
applies_when: Vec<String>,
|
||||
anti_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
/// Handle blue_adr_create
|
||||
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let title = args
|
||||
|
|
@ -123,6 +136,600 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
|||
}))
|
||||
}
|
||||
|
||||
// ===== RFC 0004: ADR Adherence =====
|
||||
|
||||
/// Handle blue_adr_list
|
||||
///
|
||||
/// List all ADRs with summaries.
|
||||
pub fn handle_list(state: &ProjectState) -> Result<Value, ServerError> {
|
||||
let adrs = load_adr_summaries(state)?;
|
||||
|
||||
let adr_list: Vec<Value> = 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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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<Value> = 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<Value, ServerError> {
|
||||
let mut findings: Vec<Value> = Vec::new();
|
||||
let mut passed: Vec<Value> = 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<Vec<AdrSummary>, 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<AdrSummary> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
anti_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<i64>) -> Result<Vec<Value>, 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<String>>(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<Value> {
|
||||
// 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<String>),
|
||||
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::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Parse ADR citations from RFC frontmatter
|
||||
///
|
||||
/// Looks for patterns like:
|
||||
/// | **ADRs** | 0004, 0007, 0010 |
|
||||
pub fn parse_adr_citations(content: &str) -> Vec<i64> {
|
||||
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::<i64>() {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ExtractionResult, ServerError>
|
|||
})
|
||||
}
|
||||
|
||||
// ==================== 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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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::<Vec<_>>(),
|
||||
"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<Value, ServerError> {
|
||||
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::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
357
crates/blue-mcp/src/handlers/llm.rs
Normal file
357
crates/blue-mcp/src/handlers/llm.rs
Normal file
|
|
@ -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<Arc<Mutex<Option<OllamaLlm>>>> = OnceLock::new();
|
||||
|
||||
/// Get the shared Ollama instance
|
||||
fn get_ollama() -> &'static Arc<Mutex<Option<OllamaLlm>>> {
|
||||
OLLAMA.get_or_init(|| Arc::new(Mutex::new(None)))
|
||||
}
|
||||
|
||||
/// Start Ollama server
|
||||
pub fn handle_start(args: &Value) -> Result<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
// 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::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
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::<Vec<_>>(),
|
||||
"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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Value, ServerError> {
|
||||
let title = args
|
||||
|
|
@ -31,6 +35,17 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
|
|||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse actions array for runbook lookup (RFC 0002)
|
||||
let actions: Vec<String> = 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<Value, Se
|
|||
let docs_path = state.home.docs_path.clone();
|
||||
let runbook_path = docs_path.join(&file_path);
|
||||
|
||||
// Generate markdown content
|
||||
let markdown = generate_runbook_markdown(title, &source_rfc_doc, service_name, owner, &operations);
|
||||
// Generate markdown content (with actions for RFC 0002)
|
||||
let markdown = generate_runbook_markdown(title, &source_rfc_doc, service_name, owner, &operations, &actions);
|
||||
|
||||
// Create document in SQLite store
|
||||
let doc = Document {
|
||||
|
|
@ -71,11 +86,19 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
|
|||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
state
|
||||
let doc_id = state
|
||||
.store
|
||||
.add_document(&doc)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Store actions in metadata table (RFC 0002)
|
||||
for action in &actions {
|
||||
let _ = state.store.conn().execute(
|
||||
"INSERT OR IGNORE INTO metadata (document_id, key, value) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![doc_id, ACTION_KEY, action.to_lowercase()],
|
||||
);
|
||||
}
|
||||
|
||||
// Write the markdown file
|
||||
if let Some(parent) = runbook_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
|
@ -222,6 +245,7 @@ fn generate_runbook_markdown(
|
|||
service_name: Option<&str>,
|
||||
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<Value, ServerError> {
|
||||
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<String>, 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<Value, ServerError> {
|
||||
let runbooks = state
|
||||
.store
|
||||
.list_documents(DocType::Runbook)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let mut all_actions: Vec<Value> = 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<String> {
|
||||
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<Value> {
|
||||
let mut operations = Vec::new();
|
||||
let mut current_op: Option<ParsedOperation> = 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<String>,
|
||||
verification: Vec<String>,
|
||||
rollback: Vec<String>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<i32>() {
|
||||
// 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<Value, ServerError> {
|
||||
// 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<Value>) -> Result<Value, ServerError> {
|
||||
|
|
@ -2021,6 +2357,28 @@ impl BlueServer {
|
|||
crate::handlers::adr::handle_create(state, args)
|
||||
}
|
||||
|
||||
fn handle_adr_list(&mut self) -> Result<Value, ServerError> {
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::adr::handle_list(state)
|
||||
}
|
||||
|
||||
fn handle_adr_get(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::adr::handle_audit(state)
|
||||
}
|
||||
|
||||
fn handle_decision_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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<Value>) -> Result<Value, ServerError> {
|
||||
|
|
|
|||
24
crates/blue-ollama/Cargo.toml
Normal file
24
crates/blue-ollama/Cargo.toml
Normal file
|
|
@ -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"
|
||||
671
crates/blue-ollama/src/lib.rs
Normal file
671
crates/blue-ollama/src/lib.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<ModelInfo>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Generate response
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenerateResponse {
|
||||
response: String,
|
||||
#[serde(default)]
|
||||
prompt_eval_count: Option<usize>,
|
||||
#[serde(default)]
|
||||
eval_count: Option<usize>,
|
||||
}
|
||||
|
||||
/// Health status of Ollama
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HealthStatus {
|
||||
Healthy { version: String, gpu: Option<String> },
|
||||
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<Option<Child>>,
|
||||
/// 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::<VersionResponse>() {
|
||||
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<u16> {
|
||||
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::<VersionResponse>() {
|
||||
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<Vec<ModelInfo>, 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<CompletionResult, LlmError> {
|
||||
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<CompletionResult, LlmError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue