From 8e86a87c2fed452b8387e149aaed6f477e9ffab4 Mon Sep 17 00:00:00 2001 From: Alex Mikhalev Date: Mon, 6 Apr 2026 12:26:49 +0200 Subject: [PATCH] feat(terraphim_agent): refactor to minimalist feature gates --- crates/terraphim_agent/Cargo.toml | 31 +- crates/terraphim_agent/src/lib.rs | 2 + crates/terraphim_agent/src/main.rs | 355 ++++++++++-------- crates/terraphim_agent/src/onboarding/mod.rs | 1 + .../terraphim_agent/src/onboarding/wizard.rs | 1 + crates/terraphim_agent/src/repl/commands.rs | 34 +- crates/terraphim_agent/src/repl/handler.rs | 96 +++-- crates/terraphim_agent/src/repl/mod.rs | 5 +- crates/terraphim_agent/src/service.rs | 4 +- .../src/shared_learning/store.rs | 15 +- .../src/shared_learning/wiki_sync.rs | 46 ++- crates/terraphim_agent/src/tui_backend.rs | 97 ++--- .../tests/repl_integration_tests.rs | 5 +- .../tests/vm_management_tests.rs | 2 + 14 files changed, 373 insertions(+), 321 deletions(-) diff --git a/crates/terraphim_agent/Cargo.toml b/crates/terraphim_agent/Cargo.toml index d6e9e6f60..59f67d21a 100644 --- a/crates/terraphim_agent/Cargo.toml +++ b/crates/terraphim_agent/Cargo.toml @@ -12,26 +12,23 @@ license = "Apache-2.0" readme = "../../README.md" [features] -default = ["repl-interactive"] +default = ["repl-interactive", "llm"] +server = ["dep:reqwest", "dep:urlencoding"] +llm = ["terraphim_service/ollama", "terraphim_service/llm_router"] repl = ["dep:rustyline", "dep:colored", "dep:comfy-table"] repl-interactive = ["repl"] -# NOTE: repl-sessions re-enabled for local development (path dependency) -repl-full = ["repl", "repl-chat", "repl-mcp", "repl-file", "repl-custom", "repl-web", "repl-interactive", "repl-sessions"] -repl-chat = ["repl"] # Chat functionality -repl-mcp = ["repl"] # MCP tools integration -repl-file = ["repl"] # Enhanced file operations -repl-custom = ["repl"] # Markdown-defined custom commands -repl-web = ["repl"] # Web operations and configuration -repl-web-advanced = ["repl-web"] # Advanced web operations (screenshot, PDF, scraping) -# Update tests - gated because they require built release binary and network access +repl-full = ["repl", "repl-chat", "repl-mcp", "repl-file", "repl-custom", "repl-web", "repl-interactive", "repl-sessions", "llm", "server"] +repl-chat = ["repl", "llm"] +repl-mcp = ["repl"] +repl-file = ["repl"] +repl-custom = ["repl"] +repl-web = ["repl"] +repl-web-advanced = ["repl-web"] +firecracker = ["repl"] update-tests = [] -# Session history search - enabled for local development repl-sessions = ["repl", "dep:terraphim_sessions"] shared-learning = [] -[lints.rust] -# repl-sessions feature is now enabled for local development - [dependencies] anyhow = { workspace = true } thiserror = { workspace = true } @@ -43,12 +40,12 @@ futures = "0.3" serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" -reqwest = { workspace = true } +reqwest = { workspace = true, optional = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } log = { workspace = true } -urlencoding = "2.1" +urlencoding = { version = "2.1", optional = true } ahash = "0.8" terraphim_update = { path = "../terraphim_update", version = "1.0.0" } pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } @@ -70,7 +67,7 @@ terraphim_settings = { path = "../terraphim_settings", version = "1.0.0" } terraphim_persistence = { path = "../terraphim_persistence", version = "1.0.0" } terraphim_config = { path = "../terraphim_config", version = "1.0.0" } terraphim_automata = { path = "../terraphim_automata", version = "1.0.0" } -terraphim_service = { path = "../terraphim_service", version = "1.0.0" } +terraphim_service = { path = "../terraphim_service", version = "1.0.0", default-features = false } terraphim_middleware = { path = "../terraphim_middleware", version = "1.0.0" } terraphim_rolegraph = { path = "../terraphim_rolegraph", version = "1.0.0" } terraphim_hooks = { path = "../terraphim_hooks", version = "1.0.0" } diff --git a/crates/terraphim_agent/src/lib.rs b/crates/terraphim_agent/src/lib.rs index 495a8a5b3..7cc0ffcea 100644 --- a/crates/terraphim_agent/src/lib.rs +++ b/crates/terraphim_agent/src/lib.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "server")] pub mod client; pub mod onboarding; pub mod service; @@ -20,6 +21,7 @@ pub mod repl; #[cfg(feature = "repl-custom")] pub mod commands; +#[cfg(feature = "server")] pub use client::*; // Re-export robot mode types diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index a0a17c11b..e63d45d63 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -21,7 +21,11 @@ use serde::Serialize; use terraphim_persistence::Persistable; use tokio::runtime::Runtime; +#[cfg(feature = "server")] mod client; + +mod tui_backend; + mod guard_patterns; mod onboarding; mod service; @@ -36,6 +40,7 @@ mod learnings; #[cfg(feature = "repl")] mod repl; +#[cfg(feature = "server")] use client::{ApiClient, SearchResponse}; use service::TuiService; use terraphim_types::{ @@ -70,11 +75,13 @@ fn show_usage_info() { println!(" terraphim-agent help # Show command-specific help"); } +#[cfg(feature = "server")] fn resolve_tui_server_url(explicit: Option<&str>) -> String { let env_server = std::env::var("TERRAPHIM_SERVER").ok(); resolve_tui_server_url_with_env(explicit, env_server.as_deref()) } +#[cfg(feature = "server")] fn resolve_tui_server_url_with_env(explicit: Option<&str>, env_server: Option<&str>) -> String { explicit .map(ToOwned::to_owned) @@ -82,6 +89,7 @@ fn resolve_tui_server_url_with_env(explicit: Option<&str>, env_server: Option<&s .unwrap_or_else(|| "http://localhost:8000".to_string()) } +#[cfg(feature = "server")] fn tui_server_requirement_error(url: &str, cause: &anyhow::Error) -> anyhow::Error { anyhow::anyhow!( "Fullscreen TUI requires a running Terraphim server at {}. \ @@ -92,6 +100,7 @@ fn tui_server_requirement_error(url: &str, cause: &anyhow::Error) -> anyhow::Err ) } +#[cfg(feature = "server")] fn ensure_tui_server_reachable( runtime: &tokio::runtime::Runtime, api: &ApiClient, @@ -561,6 +570,7 @@ enum Command { top_k: usize, }, /// Chat with the AI using a specific role + #[cfg(feature = "llm")] Chat { #[arg(long)] role: Option, @@ -870,31 +880,55 @@ fn main() -> Result<()> { std::process::exit(0); } - if cli.server { - run_tui_server_mode(&cli.server_url, cli.transparent) - } else { - // Run TUI mode - it will create its own runtime + #[cfg(feature = "server")] + { + if cli.server { + run_tui_server_mode(&cli.server_url, cli.transparent) + } else { + run_tui_offline_mode(cli.transparent) + } + } + #[cfg(not(feature = "server"))] + { + if cli.server { + eprintln!( + "TUI server mode requires the 'server' feature. Use offline mode instead." + ); + return Err(anyhow::anyhow!("TUI server mode requires server feature")); + } run_tui_offline_mode(cli.transparent) } } #[cfg(feature = "repl")] - Some(Command::Repl { server, server_url }) => { + Some(Command::Repl { server, .. }) => { let rt = Runtime::new()?; - if server { - rt.block_on(repl::run_repl_server_mode(&server_url)) - } else { - rt.block_on(repl::run_repl_offline_mode()) + #[cfg(feature = "server")] + { + if server { + return rt.block_on(repl::run_repl_server_mode("http://localhost:8000")); + } + } + #[cfg(not(feature = "server"))] + { + if server { + eprintln!( + "REPL server mode requires the 'server' feature. Starting in offline mode instead." + ); + } } + rt.block_on(repl::run_repl_offline_mode()) } Some(command) => { let rt = Runtime::new()?; - if cli.server { - rt.block_on(run_server_command(command, &cli.server_url, output)) - } else { - rt.block_on(run_offline_command(command, output, cli.config)) + #[cfg(feature = "server")] + { + if cli.server { + return rt.block_on(run_server_command(command, &cli.server_url, output)); + } } + rt.block_on(run_offline_command(command, output, cli.config)) } } } @@ -904,6 +938,7 @@ fn run_tui_offline_mode(transparent: bool) -> Result<()> { run_tui(None, transparent) } +#[allow(dead_code)] fn run_tui_server_mode(server_url: &str, transparent: bool) -> Result<()> { run_tui(Some(server_url.to_string()), transparent) } @@ -1285,6 +1320,7 @@ async fn run_offline_command( } Ok(()) } + #[cfg(feature = "llm")] Command::Chat { role, prompt, @@ -2067,6 +2103,7 @@ async fn run_learn_command(sub: LearnSub) -> Result<()> { } } +#[cfg(feature = "server")] async fn run_server_command( command: Command, server_url: &str, @@ -2260,6 +2297,7 @@ async fn run_server_command( } Ok(()) } + #[cfg(feature = "llm")] Command::Chat { role, prompt, @@ -2728,6 +2766,7 @@ fn run_tui(server_url: Option, transparent: bool) -> Result<()> { } } +#[allow(unused_variables)] fn ui_loop( terminal: &mut Terminal>, server_url: Option, @@ -2741,19 +2780,27 @@ fn ui_loop( let mut current_role = String::from("Terraphim Engineer"); // Default to Terraphim Engineer let mut selected_result_index = 0; let mut view_mode = ViewMode::Search; - let effective_url = resolve_tui_server_url(server_url.as_deref()); - let api = ApiClient::new(effective_url.clone()); - - // Create a tokio runtime for this TUI session - // We need a local runtime because we're in a synchronous function (terminal event loop) let rt = tokio::runtime::Runtime::new()?; - ensure_tui_server_reachable(&rt, &api, &effective_url)?; + + #[cfg(feature = "server")] + let backend = { + let effective_url = resolve_tui_server_url(server_url.as_deref()); + let api = ApiClient::new(effective_url.clone()); + ensure_tui_server_reachable(&rt, &api, &effective_url)?; + crate::tui_backend::TuiBackend::Remote(api) + }; + + #[cfg(not(feature = "server"))] + let backend = { + let service = rt.block_on(async { TuiService::new(None).await })?; + crate::tui_backend::TuiBackend::Local(service) + }; // Initialize terms from rolegraph (selected role) - if let Ok(cfg) = rt.block_on(async { api.get_config().await }) { - current_role = cfg.config.selected_role.to_string(); - if let Ok(rg) = rt.block_on(async { api.rolegraph(Some(current_role.as_str())).await }) { - terms = rg.nodes.into_iter().map(|n| n.label).collect(); + if let Ok(cfg) = rt.block_on(async { backend.get_config().await }) { + current_role = cfg.selected_role.to_string(); + if let Ok(rg) = rt.block_on(async { backend.get_rolegraph_terms(¤t_role).await }) { + terms = rg; } } @@ -2850,175 +2897,151 @@ fn ui_loop( if event::poll(std::time::Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { match view_mode { - ViewMode::Search => { - match map_search_key_event(key) { - TuiAction::Quit => break, - TuiAction::SearchOrOpen => { - let query = input.trim().to_string(); - let api = api.clone(); - let role = current_role.clone(); - if !query.is_empty() { - if let Ok((lines, docs)) = rt.block_on(async move { - let q = SearchQuery { - search_term: NormalizedTermValue::from(query.as_str()), - search_terms: None, - operator: None, - skip: Some(0), - limit: Some(10), - role: Some(RoleName::new(&role)), - layer: Layer::default(), - }; - let resp = api.search(&q).await?; - let lines: Vec = resp - .results - .iter() - .map(|d| { - format!( - "{} {}", - d.rank.unwrap_or_default(), - d.title - ) - }) - .collect(); - let docs = resp.results; - Ok::<(Vec, Vec), anyhow::Error>(( - lines, docs, - )) - }) { - results = lines; - detailed_results = docs; - selected_result_index = 0; - } - } else if selected_result_index < detailed_results.len() { - view_mode = ViewMode::ResultDetail; + ViewMode::Search => match map_search_key_event(key) { + TuiAction::Quit => break, + TuiAction::SearchOrOpen => { + let query = input.trim().to_string(); + let backend = backend.clone(); + let role = current_role.clone(); + if !query.is_empty() { + if let Ok(docs) = rt.block_on(async move { + let q = SearchQuery { + search_term: NormalizedTermValue::from(query.as_str()), + search_terms: None, + operator: None, + skip: Some(0), + limit: Some(10), + role: Some(RoleName::new(&role)), + layer: Layer::default(), + }; + backend.search(&q).await + }) { + let lines: Vec = docs + .iter() + .map(|d| { + format!("{} {}", d.rank.unwrap_or_default(), d.title) + }) + .collect(); + results = lines; + detailed_results = docs; + selected_result_index = 0; } + } else if selected_result_index < detailed_results.len() { + view_mode = ViewMode::ResultDetail; } - TuiAction::MoveUp => { - selected_result_index = selected_result_index.saturating_sub(1); - } - TuiAction::MoveDown => { - if selected_result_index + 1 < results.len() { - selected_result_index += 1; - } + } + TuiAction::MoveUp => { + selected_result_index = selected_result_index.saturating_sub(1); + } + TuiAction::MoveDown => { + if selected_result_index + 1 < results.len() { + selected_result_index += 1; } - TuiAction::Autocomplete => { - // Real autocomplete from API - let query = input.trim(); - if !query.is_empty() { - let api = api.clone(); - let role = current_role.clone(); - if let Ok(autocomplete_resp) = rt.block_on(async move { - api.get_autocomplete(&role, query).await - }) { - suggestions = autocomplete_resp - .suggestions - .into_iter() - .take(5) - .map(|s| s.text) - .collect(); - } + } + TuiAction::Autocomplete => { + let query = input.trim(); + if !query.is_empty() { + let backend = backend.clone(); + let role = current_role.clone(); + if let Ok(autocomplete_resp) = + rt.block_on( + async move { backend.autocomplete(&role, query).await }, + ) + { + suggestions = autocomplete_resp.into_iter().take(5).collect(); } } - TuiAction::SwitchRole => { - // Switch role - let api = api.clone(); - if let Ok(cfg) = rt.block_on(async { api.get_config().await }) { - let roles: Vec = - cfg.config.roles.keys().map(|k| k.to_string()).collect(); - if !roles.is_empty() { - if let Some(current_idx) = - roles.iter().position(|r| r == ¤t_role) - { - let next_idx = (current_idx + 1) % roles.len(); - current_role = roles[next_idx].clone(); - // Update terms for new role - if let Ok(rg) = rt.block_on(async { - api.rolegraph(Some(¤t_role)).await - }) { - terms = - rg.nodes.into_iter().map(|n| n.label).collect(); - } + } + TuiAction::SwitchRole => { + let backend = backend.clone(); + if let Ok(cfg) = rt.block_on(async { backend.get_config().await }) { + let roles: Vec = + cfg.roles.keys().map(|k| k.to_string()).collect(); + if !roles.is_empty() { + if let Some(current_idx) = + roles.iter().position(|r| r == ¤t_role) + { + let next_idx = (current_idx + 1) % roles.len(); + current_role = roles[next_idx].clone(); + if let Ok(rg) = rt.block_on(async { + backend.get_rolegraph_terms(¤t_role).await + }) { + terms = rg; } } } } - TuiAction::SummarizeSelection => { - // Summarize current selection + } + TuiAction::SummarizeSelection => { + #[cfg(feature = "llm")] + { if selected_result_index < detailed_results.len() { let doc = detailed_results[selected_result_index].clone(); - let api = api.clone(); + let backend = backend.clone(); let role = current_role.clone(); - if let Ok(summary) = rt.block_on(async move { - api.summarize_document(&doc, Some(&role)).await + if let Ok(Some(summary_text)) = rt.block_on(async move { + backend.summarize(&doc, Some(&role)).await }) { - if let Some(summary_text) = summary.summary { - // Replace result with summary for display - if selected_result_index < results.len() { - results[selected_result_index] = - format!("SUMMARY: {}", summary_text); - } + if selected_result_index < results.len() { + results[selected_result_index] = + format!("SUMMARY: {}", summary_text); } } } } - TuiAction::Backspace => { - input.pop(); - update_local_suggestions(&input, &terms, &mut suggestions); - } - TuiAction::InsertChar(c) => { - input.push(c); - update_local_suggestions(&input, &terms, &mut suggestions); - } - TuiAction::None - | TuiAction::BackToSearch - | TuiAction::SummarizeDetail => {} } - } - ViewMode::ResultDetail => { - match map_detail_key_event(key) { - TuiAction::BackToSearch => { - view_mode = ViewMode::Search; - } - TuiAction::SummarizeDetail => { - // Summarize current document in detail view + TuiAction::Backspace => { + input.pop(); + update_local_suggestions(&input, &terms, &mut suggestions); + } + TuiAction::InsertChar(c) => { + input.push(c); + update_local_suggestions(&input, &terms, &mut suggestions); + } + TuiAction::None | TuiAction::BackToSearch | TuiAction::SummarizeDetail => {} + }, + ViewMode::ResultDetail => match map_detail_key_event(key) { + TuiAction::BackToSearch => { + view_mode = ViewMode::Search; + } + TuiAction::SummarizeDetail => { + #[cfg(feature = "llm")] + { if selected_result_index < detailed_results.len() { let doc = detailed_results[selected_result_index].clone(); - let api = api.clone(); + let backend = backend.clone(); let role = current_role.clone(); - if let Ok(summary) = rt.block_on(async move { - api.summarize_document(&doc, Some(&role)).await + if let Ok(Some(summary_text)) = rt.block_on(async move { + backend.summarize(&doc, Some(&role)).await }) { - if let Some(summary_text) = summary.summary { - // Update the document body with summary - let original_body = if detailed_results - [selected_result_index] - .body - .is_empty() - { - "No content" - } else { - &detailed_results[selected_result_index].body - }; - detailed_results[selected_result_index].body = format!( - "SUMMARY:\n{}\n\nORIGINAL:\n{}", - summary_text, original_body - ); - } + let original_body = if detailed_results + [selected_result_index] + .body + .is_empty() + { + "No content" + } else { + &detailed_results[selected_result_index].body + }; + detailed_results[selected_result_index].body = format!( + "SUMMARY:\n{}\n\nORIGINAL:\n{}", + summary_text, original_body + ); } } } - TuiAction::Quit => break, - TuiAction::None - | TuiAction::SearchOrOpen - | TuiAction::MoveUp - | TuiAction::MoveDown - | TuiAction::Autocomplete - | TuiAction::SwitchRole - | TuiAction::SummarizeSelection - | TuiAction::Backspace - | TuiAction::InsertChar(_) => {} } - } + TuiAction::Quit => break, + TuiAction::None + | TuiAction::SearchOrOpen + | TuiAction::MoveUp + | TuiAction::MoveDown + | TuiAction::Autocomplete + | TuiAction::SwitchRole + | TuiAction::SummarizeSelection + | TuiAction::Backspace + | TuiAction::InsertChar(_) => {} + }, } } } diff --git a/crates/terraphim_agent/src/onboarding/mod.rs b/crates/terraphim_agent/src/onboarding/mod.rs index 05b2dc3c6..02e99c8b8 100644 --- a/crates/terraphim_agent/src/onboarding/mod.rs +++ b/crates/terraphim_agent/src/onboarding/mod.rs @@ -51,6 +51,7 @@ pub enum OnboardingError { #[error( "Not a TTY - interactive mode requires a terminal. Use --template for non-interactive mode." )] + #[allow(dead_code)] NotATty, /// JSON serialization/deserialization error diff --git a/crates/terraphim_agent/src/onboarding/wizard.rs b/crates/terraphim_agent/src/onboarding/wizard.rs index 5630adae5..a2b25bd96 100644 --- a/crates/terraphim_agent/src/onboarding/wizard.rs +++ b/crates/terraphim_agent/src/onboarding/wizard.rs @@ -23,6 +23,7 @@ pub enum SetupResult { /// The template that was applied template: ConfigTemplate, /// Custom path if provided + #[allow(dead_code)] custom_path: Option, /// The built role role: Role, diff --git a/crates/terraphim_agent/src/repl/commands.rs b/crates/terraphim_agent/src/repl/commands.rs index a3359d95a..178c7d18d 100644 --- a/crates/terraphim_agent/src/repl/commands.rs +++ b/crates/terraphim_agent/src/repl/commands.rs @@ -23,13 +23,13 @@ pub enum ReplCommand { top_k: Option, }, - // Chat commands (requires 'repl-chat' feature) - #[cfg(feature = "repl-chat")] + // Chat commands (requires 'llm' feature) + #[cfg(feature = "llm")] Chat { message: Option, }, - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] Summarize { target: String, }, @@ -75,7 +75,8 @@ pub enum ReplCommand { subcommand: WebSubcommand, }, - // VM commands + // VM commands (requires 'firecracker' feature) + #[cfg(feature = "firecracker")] Vm { subcommand: VmSubcommand, }, @@ -192,6 +193,7 @@ pub enum SessionsSubcommand { ByFile { file_path: String, json: bool }, } +#[cfg(feature = "firecracker")] #[derive(Debug, Clone, PartialEq)] pub enum VmSubcommand { List, @@ -458,7 +460,7 @@ impl FromStr for ReplCommand { Ok(ReplCommand::Graph { top_k }) } - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] "chat" => { let message = if parts.len() > 1 { Some(parts[1..].join(" ")) @@ -468,12 +470,12 @@ impl FromStr for ReplCommand { Ok(ReplCommand::Chat { message }) } - #[cfg(not(feature = "repl-chat"))] + #[cfg(not(feature = "llm"))] "chat" => Err(anyhow!( - "Chat feature not enabled. Rebuild with --features repl-chat" + "Chat feature not enabled. Rebuild with --features llm" )), - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] "summarize" => { if parts.len() < 2 { return Err(anyhow!( @@ -485,9 +487,9 @@ impl FromStr for ReplCommand { }) } - #[cfg(not(feature = "repl-chat"))] + #[cfg(not(feature = "llm"))] "summarize" => Err(anyhow!( - "Summarize feature not enabled. Rebuild with --features repl-chat" + "Summarize feature not enabled. Rebuild with --features llm" )), #[cfg(feature = "repl-mcp")] @@ -865,6 +867,12 @@ impl FromStr for ReplCommand { "Web operations not enabled. Rebuild with --features repl-web" )), + #[cfg(not(feature = "firecracker"))] + "vm" => Err(anyhow!( + "VM commands not enabled. Rebuild with --features firecracker" + )), + + #[cfg(feature = "firecracker")] "vm" => { if parts.len() < 2 { return Err(anyhow!("VM command requires a subcommand")); @@ -1376,7 +1384,7 @@ impl ReplCommand { "clear", ]; - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] { commands.extend_from_slice(&["chat", "summarize"]); } @@ -1441,9 +1449,9 @@ impl ReplCommand { "/web [args] - Web operations (get, post, scrape, screenshot, pdf, form, api, status, cancel, history, config)", ), - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] "chat" => Some("/chat [message] - Interactive chat with AI"), - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] "summarize" => Some("/summarize - Summarize content"), #[cfg(feature = "repl-mcp")] diff --git a/crates/terraphim_agent/src/repl/handler.rs b/crates/terraphim_agent/src/repl/handler.rs index 2047d9d03..57436e294 100644 --- a/crates/terraphim_agent/src/repl/handler.rs +++ b/crates/terraphim_agent/src/repl/handler.rs @@ -5,7 +5,9 @@ use super::commands::SessionsSubcommand; use super::commands::{ ConfigSubcommand, ReplCommand, RobotSubcommand, RoleSubcommand, UpdateSubcommand, }; -use crate::{client::ApiClient, service::TuiService}; +#[cfg(feature = "server")] +use crate::client::ApiClient; +use crate::service::TuiService; // Import robot module types use crate::robot::{ExitCode, SelfDocumentation}; @@ -24,6 +26,7 @@ use colored::Colorize; pub struct ReplHandler { service: Option, + #[cfg(feature = "server")] api_client: Option, current_role: String, #[cfg(feature = "repl-mcp")] @@ -40,6 +43,7 @@ impl ReplHandler { Self { service: Some(service), + #[cfg(feature = "server")] api_client: None, current_role: "Default".to_string(), #[cfg(feature = "repl-mcp")] @@ -47,6 +51,7 @@ impl ReplHandler { } } + #[cfg(feature = "server")] pub fn new_server(api_client: ApiClient) -> Self { Self { service: None, @@ -218,7 +223,7 @@ impl ReplHandler { println!(" {} - Manage roles", "/role [list|select]".yellow()); println!(" {} - Show knowledge graph", "/graph".yellow()); - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] { println!(" {} - Chat with AI", "/chat [message]".yellow()); println!(" {} - Summarize content", "/summarize ".yellow()); @@ -282,12 +287,12 @@ impl ReplHandler { self.handle_clear().await?; } - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] ReplCommand::Chat { message } => { self.handle_chat(message).await?; } - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] ReplCommand::Summarize { target } => { self.handle_summarize(target).await?; } @@ -327,6 +332,7 @@ impl ReplHandler { self.handle_web(subcommand).await?; } + #[cfg(feature = "firecracker")] ReplCommand::Vm { subcommand } => { self.handle_vm(subcommand).await?; } @@ -409,7 +415,9 @@ impl ReplHandler { results.len().to_string().green() ); } - } else if let Some(api_client) = &self.api_client { + } + #[cfg(feature = "server")] + if let Some(api_client) = &self.api_client { // Server mode - use current role if no role specified use terraphim_types::{Layer, NormalizedTermValue, RoleName, SearchQuery}; @@ -480,7 +488,9 @@ impl ReplHandler { let config = service.get_config().await; let config_json = serde_json::to_string_pretty(&config)?; println!("{}", config_json); - } else if let Some(api_client) = &self.api_client { + } + #[cfg(feature = "server")] + if let Some(api_client) = &self.api_client { match api_client.get_config().await { Ok(response) => { let config_json = serde_json::to_string_pretty(&response.config)?; @@ -521,7 +531,9 @@ impl ReplHandler { println!(" {} {}", marker.green(), role); } } - } else if let Some(api_client) = &self.api_client { + } + #[cfg(feature = "server")] + if let Some(api_client) = &self.api_client { match api_client.get_config().await { Ok(response) => { println!("{}", "Available roles:".bold()); @@ -549,36 +561,39 @@ impl ReplHandler { } } RoleSubcommand::Select { name } => { - // Try to find role by name or shortname - let resolved_name = if let Some(service) = &self.service { + #[cfg_attr(not(feature = "server"), allow(unused_mut))] + let mut resolved_name = if let Some(service) = &self.service { service .find_role_by_name_or_shortname(&name) .await .map(|r| r.to_string()) - } else if let Some(api_client) = &self.api_client { - // For API mode, fetch config and resolve shortname client-side - match api_client.get_config().await { - Ok(cfg) => { - let query_lower = name.to_lowercase(); - cfg.config - .roles - .iter() - .find(|(n, _)| n.to_string().to_lowercase() == query_lower) - .or_else(|| { - cfg.config.roles.iter().find(|(_, role)| { - role.shortname - .as_ref() - .map(|s| s.to_lowercase() == query_lower) - .unwrap_or(false) - }) - }) - .map(|(n, _)| n.to_string()) - } - Err(_) => None, - } } else { None }; + #[cfg(feature = "server")] + if resolved_name.is_none() { + if let Some(api_client) = &self.api_client { + resolved_name = match api_client.get_config().await { + Ok(cfg) => { + let query_lower = name.to_lowercase(); + cfg.config + .roles + .iter() + .find(|(n, _)| n.to_string().to_lowercase() == query_lower) + .or_else(|| { + cfg.config.roles.iter().find(|(_, role)| { + role.shortname + .as_ref() + .map(|s| s.to_lowercase() == query_lower) + .unwrap_or(false) + }) + }) + .map(|(n, _)| n.to_string()) + } + Err(_) => None, + }; + } + } let actual_name = match resolved_name { Some(n) => n, @@ -621,7 +636,10 @@ impl ReplHandler { for (i, concept) in concepts.iter().enumerate() { println!(" {}. {}", (i + 1).to_string().yellow(), concept); } - } else if let Some(api_client) = &self.api_client { + } + + #[cfg(feature = "server")] + if let Some(api_client) = &self.api_client { match api_client.rolegraph(Some(&self.current_role)).await { Ok(response) => { let mut nodes = response.nodes; @@ -673,7 +691,7 @@ impl ReplHandler { Ok(()) } - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] async fn handle_chat(&self, message: Option) -> Result<()> { #[cfg(feature = "repl")] { @@ -694,8 +712,9 @@ impl ReplHandler { println!("{} Chat failed: {}", "❌".bold(), e.to_string().red()); } } - } else if let Some(api_client) = &self.api_client { - // Server mode chat + } + #[cfg(feature = "server")] + if let Some(api_client) = &self.api_client { match api_client.chat(&self.current_role, &msg, None).await { Ok(response) => { println!("\n{} {}\n", "🤖".bold(), "Response:".bold()); @@ -720,7 +739,7 @@ impl ReplHandler { Ok(()) } - #[cfg(feature = "repl-chat")] + #[cfg(feature = "llm")] async fn handle_summarize(&self, target: String) -> Result<()> { #[cfg(feature = "repl")] { @@ -744,8 +763,9 @@ impl ReplHandler { ); } } - } else if let Some(api_client) = &self.api_client { - // Server mode summarization - create a temporary document + } + #[cfg(feature = "server")] + if let Some(api_client) = &self.api_client { use terraphim_types::{Document, DocumentType}; let doc = Document { @@ -1181,6 +1201,7 @@ impl ReplHandler { Ok(()) } + #[cfg(feature = "firecracker")] async fn handle_vm(&self, subcommand: super::commands::VmSubcommand) -> Result<()> { #[cfg(feature = "repl")] { @@ -2459,6 +2480,7 @@ pub async fn run_repl_offline_mode() -> Result<()> { handler.run().await } +#[cfg(feature = "server")] /// Run REPL in server mode pub async fn run_repl_server_mode(server_url: &str) -> Result<()> { let api_client = ApiClient::new(server_url.to_string()); diff --git a/crates/terraphim_agent/src/repl/mod.rs b/crates/terraphim_agent/src/repl/mod.rs index 5917ed7a4..e9d18f6ec 100644 --- a/crates/terraphim_agent/src/repl/mod.rs +++ b/crates/terraphim_agent/src/repl/mod.rs @@ -20,4 +20,7 @@ pub mod chat; pub mod mcp_tools; #[cfg(feature = "repl")] -pub use handler::{run_repl_offline_mode, run_repl_server_mode}; +pub use handler::run_repl_offline_mode; + +#[cfg(all(feature = "repl", feature = "server"))] +pub use handler::run_repl_server_mode; diff --git a/crates/terraphim_agent/src/service.rs b/crates/terraphim_agent/src/service.rs index ec213c5ad..32814ce88 100644 --- a/crates/terraphim_agent/src/service.rs +++ b/crates/terraphim_agent/src/service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use terraphim_config::{Config, ConfigBuilder, ConfigId, ConfigState}; use terraphim_persistence::Persistable; use terraphim_service::TerraphimService; +#[cfg(feature = "llm")] use terraphim_service::llm::{ChatOptions, build_llm_from_role}; use terraphim_settings::{DeviceSettings, Error as DeviceSettingsError}; use terraphim_types::{Document, Layer, NormalizedTermValue, RoleName, SearchQuery, Thesaurus}; @@ -330,6 +331,7 @@ impl TuiService { } /// Generate chat response using LLM + #[cfg(feature = "llm")] pub async fn chat( &self, role_name: &RoleName, @@ -460,7 +462,7 @@ impl TuiService { } /// Summarize content using available AI services - #[cfg_attr(not(feature = "repl-chat"), allow(dead_code))] + #[cfg(feature = "llm")] pub async fn summarize(&self, role_name: &RoleName, content: &str) -> Result { // For now, use the chat method with a summarization prompt let prompt = format!("Please summarize the following content:\n\n{}", content); diff --git a/crates/terraphim_agent/src/shared_learning/store.rs b/crates/terraphim_agent/src/shared_learning/store.rs index 2447c228e..2cd038c8c 100644 --- a/crates/terraphim_agent/src/shared_learning/store.rs +++ b/crates/terraphim_agent/src/shared_learning/store.rs @@ -12,9 +12,7 @@ use thiserror::Error; use tokio::sync::RwLock; use tracing::{debug, info}; -use crate::shared_learning::types::{ - LearningSource, SharedLearning, TrustLevel, -}; +use crate::shared_learning::types::{LearningSource, SharedLearning, TrustLevel}; #[derive(Error, Debug)] pub enum StoreError { @@ -179,6 +177,7 @@ impl Bm25Scorer { } } +#[allow(dead_code)] pub struct SharedLearningStore { storage: Arc, index: RwLock>, @@ -262,7 +261,10 @@ impl SharedLearningStore { if let Some((existing_id, score)) = best_match { if score >= self.config.similarity_threshold { - debug!("Merging with existing learning {} (score={:.3})", existing_id, score); + debug!( + "Merging with existing learning {} (score={:.3})", + existing_id, score + ); self.merge_learning(&existing_id, &learning).await?; return Ok(StoreResult::Merged(existing_id)); } @@ -626,7 +628,10 @@ mod tests { store.insert(learning).await.unwrap(); - let suggestions = store.suggest("git push problems", "test-agent", 5).await.unwrap(); + let suggestions = store + .suggest("git push problems", "test-agent", 5) + .await + .unwrap(); assert!(!suggestions.is_empty()); assert_eq!(suggestions[0].title, "Git Push Error"); } diff --git a/crates/terraphim_agent/src/shared_learning/wiki_sync.rs b/crates/terraphim_agent/src/shared_learning/wiki_sync.rs index 60fc07687..e1815aaf1 100644 --- a/crates/terraphim_agent/src/shared_learning/wiki_sync.rs +++ b/crates/terraphim_agent/src/shared_learning/wiki_sync.rs @@ -130,12 +130,18 @@ impl GiteaWikiClient { if exists { // Update existing page self.update_wiki_page(&page_name, &content).await?; - info!("Updated wiki page for learning {}: {}", learning.id, page_name); + info!( + "Updated wiki page for learning {}: {}", + learning.id, page_name + ); Ok(SyncResult::Updated(page_name)) } else { // Create new page self.create_wiki_page(&page_name, &content).await?; - info!("Created wiki page for learning {}: {}", learning.id, page_name); + info!( + "Created wiki page for learning {}: {}", + learning.id, page_name + ); Ok(SyncResult::Created(page_name)) } } @@ -155,7 +161,9 @@ impl GiteaWikiClient { page_name, ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { Ok(true) @@ -170,11 +178,7 @@ impl GiteaWikiClient { } /// Create a new wiki page - async fn create_wiki_page( - &self, - page_name: &str, - content: &str, - ) -> Result<(), WikiSyncError> { + async fn create_wiki_page(&self, page_name: &str, content: &str) -> Result<(), WikiSyncError> { let output = Command::new(&self.config.robot_path) .env("GITEA_URL", &self.config.gitea_url) .env("GITEA_TOKEN", &self.config.token) @@ -192,7 +196,9 @@ impl GiteaWikiClient { &format!("Add shared learning: {}", page_name), ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { Ok(()) @@ -207,11 +213,7 @@ impl GiteaWikiClient { } /// Update an existing wiki page - async fn update_wiki_page( - &self, - page_name: &str, - content: &str, - ) -> Result<(), WikiSyncError> { + async fn update_wiki_page(&self, page_name: &str, content: &str) -> Result<(), WikiSyncError> { let output = Command::new(&self.config.robot_path) .env("GITEA_URL", &self.config.gitea_url) .env("GITEA_TOKEN", &self.config.token) @@ -229,7 +231,9 @@ impl GiteaWikiClient { &format!("Update shared learning: {}", page_name), ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { Ok(()) @@ -258,7 +262,9 @@ impl GiteaWikiClient { page_name, ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { info!("Deleted wiki page: {}", page_name); @@ -299,7 +305,9 @@ impl GiteaWikiClient { &self.config.repo, ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -317,10 +325,12 @@ impl GiteaWikiClient { } /// Sync service that periodically syncs learnings to Gitea wiki +#[allow(dead_code)] pub struct WikiSyncService { client: GiteaWikiClient, } +#[allow(dead_code)] impl WikiSyncService { /// Create new sync service pub fn new(client: GiteaWikiClient) -> Self { @@ -357,6 +367,7 @@ impl WikiSyncService { } /// Report of a wiki sync operation +#[allow(dead_code)] #[derive(Debug, Clone)] pub struct WikiSyncReport { pub created: usize, @@ -367,6 +378,7 @@ pub struct WikiSyncReport { pub results: Vec<(String, Result)>, } +#[allow(dead_code)] impl WikiSyncReport { /// Check if all operations were successful pub fn all_success(&self) -> bool { diff --git a/crates/terraphim_agent/src/tui_backend.rs b/crates/terraphim_agent/src/tui_backend.rs index 7b0730647..0917e39b0 100644 --- a/crates/terraphim_agent/src/tui_backend.rs +++ b/crates/terraphim_agent/src/tui_backend.rs @@ -2,21 +2,26 @@ //! //! This module provides an enum-based abstraction that allows the TUI to work with either //! a local TuiService (offline, no server required) or a remote ApiClient (server-backed). -//! Both variants are always compiled; the choice is made at runtime based on CLI flags. +//! +//! When the `server` feature is disabled, only the `Local` variant is available. use anyhow::Result; use terraphim_config::Config; -use terraphim_types::{Document, RoleName, SearchQuery}; +use terraphim_types::{Document, SearchQuery}; -use crate::client::ApiClient; use crate::service::TuiService; +#[cfg(feature = "server")] +use crate::client::ApiClient; + /// Backend for TUI operations, supporting both local (offline) and remote (server) modes. #[derive(Clone)] pub enum TuiBackend { /// Local/offline backend using TuiService directly. + #[allow(dead_code)] Local(TuiService), /// Remote/server backend using HTTP API client. + #[cfg(feature = "server")] Remote(ApiClient), } @@ -24,6 +29,7 @@ impl std::fmt::Debug for TuiBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Local(_) => f.debug_tuple("Local").field(&"TuiService").finish(), + #[cfg(feature = "server")] Self::Remote(api) => f.debug_tuple("Remote").field(api).finish(), } } @@ -31,18 +37,13 @@ impl std::fmt::Debug for TuiBackend { impl TuiBackend { /// Execute a search query and return matching documents. - /// - /// # Arguments - /// * `query` - The search query containing search term, role, limits, etc. - /// - /// # Returns - /// A vector of Document objects matching the query. pub async fn search(&self, query: &SearchQuery) -> Result> { match self { Self::Local(svc) => { let results = svc.search_with_query(query).await?; Ok(results) } + #[cfg(feature = "server")] Self::Remote(api) => { let resp = api.search(query).await?; Ok(resp.results) @@ -51,15 +52,13 @@ impl TuiBackend { } /// Get the current configuration. - /// - /// # Returns - /// The current terraphim_config::Config. pub async fn get_config(&self) -> Result { match self { Self::Local(svc) => { let config = svc.get_config().await; Ok(config) } + #[cfg(feature = "server")] Self::Remote(api) => { let resp = api.get_config().await?; Ok(resp.config) @@ -68,19 +67,15 @@ impl TuiBackend { } /// Get the top terms from a role's knowledge graph. - /// - /// # Arguments - /// * `role` - The role name to get terms for. - /// - /// # Returns - /// A vector of term strings from the rolegraph. pub async fn get_rolegraph_terms(&self, role: &str) -> Result> { + use terraphim_types::RoleName; match self { Self::Local(svc) => { let role_name = RoleName::new(role); let terms = svc.get_role_graph_top_k(&role_name, 50).await?; Ok(terms) } + #[cfg(feature = "server")] Self::Remote(api) => { let resp = api.rolegraph(Some(role)).await?; let labels: Vec = resp.nodes.into_iter().map(|n| n.label).collect(); @@ -90,14 +85,8 @@ impl TuiBackend { } /// Get autocomplete suggestions for a partial query. - /// - /// # Arguments - /// * `role` - The role context for autocomplete. - /// * `query` - The partial query string to complete. - /// - /// # Returns - /// A vector of suggestion strings. pub async fn autocomplete(&self, role: &str, query: &str) -> Result> { + use terraphim_types::RoleName; match self { Self::Local(svc) => { let role_name = RoleName::new(role); @@ -105,6 +94,7 @@ impl TuiBackend { let suggestions: Vec = results.into_iter().map(|r| r.term).collect(); Ok(suggestions) } + #[cfg(feature = "server")] Self::Remote(api) => { let resp = api.get_autocomplete(role, query).await?; let suggestions: Vec = @@ -116,23 +106,21 @@ impl TuiBackend { /// Summarize a document using the configured AI/LLM. /// - /// # Arguments - /// * `document` - The document to summarize. - /// * `role` - Optional role context for the summary. - /// - /// # Returns - /// An optional summary string (None if summarization is unavailable). + /// Returns None if summarization is unavailable (llm feature disabled). + #[cfg(feature = "llm")] pub async fn summarize( &self, document: &Document, role: Option<&str>, ) -> Result> { + use terraphim_types::RoleName; match self { Self::Local(svc) => { let role_name = RoleName::new(role.unwrap_or("Terraphim Engineer")); let summary = svc.summarize(&role_name, &document.body).await?; Ok(Some(summary)) } + #[cfg(feature = "server")] Self::Remote(api) => { let resp = api.summarize_document(document, role).await?; Ok(resp.summary) @@ -141,20 +129,16 @@ impl TuiBackend { } /// Switch to a different role and return the updated config. - /// - /// # Arguments - /// * `role` - The role name to switch to. - /// - /// # Returns - /// The updated configuration after switching roles. #[allow(dead_code)] pub async fn switch_role(&self, role: &str) -> Result { + use terraphim_types::RoleName; match self { Self::Local(svc) => { let role_name = RoleName::new(role); let config = svc.update_selected_role(role_name).await?; Ok(config) } + #[cfg(feature = "server")] Self::Remote(api) => { let resp = api.update_selected_role(role).await?; Ok(resp.config) @@ -167,45 +151,32 @@ impl TuiBackend { mod tests { use super::*; - /// Test that TuiBackend::Local variant can be constructed. - /// Full method testing requires a running service or mocked dependencies. + /// Test that TuiBackend::Local variant type signature is correct. #[tokio::test] async fn test_tuibackend_local_variant_exists() { - // This test verifies the enum structure compiles correctly. - // Actual TuiService::new() requires filesystem/config access. - // We just verify the type signatures are correct. fn assert_send_sync() {} assert_send_sync::(); } - /// Test that TuiBackend::Remote variant can be constructed. + /// Test that the backend enum is Clone and Debug (with Local variant). + #[test] + fn test_tuibackend_is_clone_and_debug() { + // We can only test with Local variant without a real TuiService + fn assert_clone() {} + fn assert_debug() {} + assert_clone::(); + assert_debug::(); + } + + #[cfg(feature = "server")] #[test] fn test_tuibackend_remote_variant_exists() { let api = ApiClient::new("http://localhost:8000".to_string()); let backend = TuiBackend::Remote(api); - // Verify we can match on the variant match backend { - TuiBackend::Remote(_) => (), // Expected + TuiBackend::Remote(_) => (), TuiBackend::Local(_) => panic!("Expected Remote variant"), } } - - /// Test that the backend enum is Clone. - #[test] - fn test_tuibackend_is_clone() { - let api = ApiClient::new("http://localhost:8000".to_string()); - let backend = TuiBackend::Remote(api); - let _cloned = backend.clone(); - // If this compiles, Clone is implemented correctly. - } - - /// Test that the backend enum is Debug. - #[test] - fn test_tuibackend_is_debug() { - let api = ApiClient::new("http://localhost:8000".to_string()); - let backend = TuiBackend::Remote(api); - let _debug_str = format!("{:?}", backend); - // If this compiles, Debug is implemented correctly. - } } diff --git a/crates/terraphim_agent/tests/repl_integration_tests.rs b/crates/terraphim_agent/tests/repl_integration_tests.rs index 1cb83e7c3..c6753cd4d 100644 --- a/crates/terraphim_agent/tests/repl_integration_tests.rs +++ b/crates/terraphim_agent/tests/repl_integration_tests.rs @@ -7,8 +7,10 @@ //! //! Uses ratatui's TestBackend for TUI rendering tests without mocks. +#[cfg(feature = "firecracker")] +use terraphim_agent::repl::commands::VmSubcommand; use terraphim_agent::repl::commands::{ - ConfigSubcommand, ReplCommand, RobotSubcommand, RoleSubcommand, UpdateSubcommand, VmSubcommand, + ConfigSubcommand, ReplCommand, RobotSubcommand, RoleSubcommand, UpdateSubcommand, }; /// Test that the REPL command parser correctly parses basic commands @@ -657,6 +659,7 @@ fn test_command_help_retrieval() { } /// Test VM command parsing +#[cfg(feature = "firecracker")] #[test] fn test_repl_vm_command_parsing() { let cmd: Result = "/vm list".parse(); diff --git a/crates/terraphim_agent/tests/vm_management_tests.rs b/crates/terraphim_agent/tests/vm_management_tests.rs index 1293d979d..4888d7c5c 100644 --- a/crates/terraphim_agent/tests/vm_management_tests.rs +++ b/crates/terraphim_agent/tests/vm_management_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "firecracker")] + use std::str::FromStr; use terraphim_agent::repl::commands::*;