diff --git a/apps/infrastructure/src/agent/runner.rs b/apps/infrastructure/src/agent/runner.rs index 49d17b4..8a8f0b1 100644 --- a/apps/infrastructure/src/agent/runner.rs +++ b/apps/infrastructure/src/agent/runner.rs @@ -11,6 +11,7 @@ use tracing::{debug, info, warn}; use zesdex_domain::core::tool_call::sanitize_tool_arguments; use zesdex_domain::core::ChatMessage; use crate::llm::provider::LlmClient; +use crate::subagent::auto::engine::trigger_auto_review; use crate::tools::{all_tools, tool_defs, ToolCtx}; use crate::TurnEvent; @@ -25,6 +26,8 @@ pub struct AgentTurnParams { pub api_key: String, pub model: String, pub api_base: Option, + pub edit_count: u32, + pub consecutive_empty_reviews: u32, } /// Spawns an agent turn on a background OS thread. @@ -198,6 +201,23 @@ fn run_turn(params: &mut AgentTurnParams) { params .messages .push(ChatMessage::tool(tc.id.clone(), output.clone())); + + // Trigger auto-review after write/edit tool execution + if name == "write" || name == "edit" { + params.edit_count = params.edit_count.saturating_add(1); + + // If we have a workspace root, trigger review + if let Some(root) = params.workspace_roots.first() { + let _ = trigger_auto_review( + root, + params.edit_count, + &mut params.consecutive_empty_reviews, + 3, // max_skip: skip after 3 consecutive empty reviews + ¶ms.turn_events, + Some(&client), + ); + } + } } } Err(e) => { diff --git a/apps/infrastructure/src/subagent/auto/engine.rs b/apps/infrastructure/src/subagent/auto/engine.rs new file mode 100644 index 0000000..195aa30 --- /dev/null +++ b/apps/infrastructure/src/subagent/auto/engine.rs @@ -0,0 +1,280 @@ +//! Auto-review engine — automatically checks git diff after file edits +//! using an LLM subagent. +//! +//! Flow: after each write/edit tool execution in the agent turn, the runner +//! calls `trigger_auto_review` which: +//! 1. Runs `git diff --cached` and `git diff` to get working-tree changes +//! 2. Sends the diff to a lightweight LLM call for quick review +//! 3. Emits findings as `TurnEvent::SystemNote` on the event queue + +use std::collections::VecDeque; +use std::path::Path; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +use anyhow::Result; +use tracing::{debug, info, instrument, warn}; + +use crate::llm::provider::LlmClient; +use crate::subagent::gating::should_review; +use crate::TurnEvent; + +/// Trigger an auto-review of recent git changes. +/// +/// Flow: +/// 1. Check gating conditions (edit count, consecutive empty reviews) +/// 2. Run `git diff --cached` to get staged changes +/// 3. Run `git diff` to get unstaged changes +/// 4. If there are changes, call LLM for a quick review +/// 5. Emit findings as TurnEvent::SystemNote +/// +/// Returns `(had_findings, total_findings)` tuple. +#[instrument(skip(turn_events, llm_client))] +pub fn trigger_auto_review( + workspace_root: &Path, + edit_count: u32, + consecutive_empty_reviews: &mut u32, + max_skip: u32, + turn_events: &Arc>>, + llm_client: Option<&LlmClient>, +) -> Result<(bool, usize)> { + // Gating check + if !should_review(edit_count, *consecutive_empty_reviews, max_skip) { + debug!("auto-review skipped by gating"); + return Ok((false, 0)); + } + + info!("triggering auto-review"); + + // Run git diff to get changes + let diff = match get_git_diff(workspace_root) { + Ok(d) => d, + Err(e) => { + debug!(error = %e, "auto-review: git diff failed (not a git repo?)"); + return Ok((false, 0)); + } + }; + + if diff.is_empty() { + debug!("auto-review: no changes to review"); + *consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1); + return Ok((false, 0)); + } + + // If we have an LLM client, do a real review + let review_result = if let Some(client) = llm_client { + perform_llm_review(client, &diff)? + } else { + // Fallback: simple heuristic review without LLM + perform_heuristic_review(&diff) + }; + + let had_findings = !review_result.is_empty(); + let finding_count = review_result.len(); + + if had_findings { + *consecutive_empty_reviews = 0; + info!(finding_count, "auto-review produced findings"); + + // Emit findings as SystemNote events + for finding in &review_result { + let note = TurnEvent::SystemNote { + kind: "info".to_string(), + message: format!("🔍 Auto-Review: {finding}"), + }; + if let Ok(mut q) = turn_events.lock() { + q.push_back(note); + } + } + } else { + *consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1); + info!("auto-review: no issues found"); + } + + Ok((had_findings, finding_count)) +} + +/// Run `git diff` to get workspace changes (both staged and unstaged). +fn get_git_diff(workspace_root: &Path) -> Result { + // Check if this is a git repo + let git_dir = workspace_root.join(".git"); + if !git_dir.exists() { + return Ok(String::new()); + } + + // Get unstaged diff + let unstaged = Command::new("git") + .arg("diff") + .current_dir(workspace_root) + .output() + .map_err(|e| anyhow::anyhow!("git diff failed: {e}"))?; + + // Get staged diff + let staged = Command::new("git") + .arg("diff") + .arg("--cached") + .current_dir(workspace_root) + .output() + .map_err(|e| anyhow::anyhow!("git diff --cached failed: {e}"))?; + + let mut combined = String::new(); + + let staged_out = String::from_utf8_lossy(&staged.stdout).trim().to_string(); + if !staged_out.is_empty() { + combined.push_str("=== Staged Changes ===\n"); + combined.push_str(&staged_out); + combined.push('\n'); + } + + let unstaged_out = String::from_utf8_lossy(&unstaged.stdout).trim().to_string(); + if !unstaged_out.is_empty() { + combined.push_str("=== Unstaged Changes ===\n"); + combined.push_str(&unstaged_out); + combined.push('\n'); + } + + // Run `git diff --stat` for summary + let stat = Command::new("git") + .arg("diff") + .arg("--stat") + .current_dir(workspace_root) + .output() + .map_err(|e| anyhow::anyhow!("git diff --stat failed: {e}"))?; + let stat_out = String::from_utf8_lossy(&stat.stdout).trim().to_string(); + if !stat_out.is_empty() { + combined.push_str("=== Summary ===\n"); + combined.push_str(&stat_out); + combined.push('\n'); + } + + Ok(combined) +} + +/// Perform an LLM-based review of the git diff. +/// +/// Sends the diff to the model with a focused prompt asking it to find +/// typos, missing imports, syntax errors, and other issues. +fn perform_llm_review(client: &LlmClient, diff: &str) -> Result> { + // Truncate very large diffs to avoid token limits + const MAX_DIFF_CHARS: usize = 8000; + let truncated_diff = if diff.len() > MAX_DIFF_CHARS { + format!("{}...\n[diff truncated at {} characters]", + &diff[..MAX_DIFF_CHARS], MAX_DIFF_CHARS) + } else { + diff.to_string() + }; + + let messages = vec![ + zesdex_domain::core::ChatMessage::system( + "You are a focused code reviewer. Review the following git diff for:\n\ + 1. Typos and spelling errors\n\ + 2. Missing imports or undefined references\n\ + 3. Syntax errors or type mismatches\n\ + 4. Logic bugs or off-by-one errors\n\ + 5. Missing error handling\n\ + 6. Security issues\n\n\ + Be concise. List each issue on a new line with severity [HIGH], [MEDIUM], or [LOW].\n\ + If no issues are found, reply with exactly: NO_ISSUES_FOUND" + .to_string(), + ), + zesdex_domain::core::ChatMessage::user(format!( + "Review this git diff:\n\n```diff\n{truncated_diff}\n```" + )), + ]; + + match client.chat_with_tools_non_streaming(&messages, None, Some(1024), Some(0.3), None) { + Ok((response, _usage)) => { + let text = response.content.unwrap_or_default().trim().to_string(); + + if text.contains("NO_ISSUES_FOUND") { + return Ok(Vec::new()); + } + + // Parse findings line by line + let findings: Vec = text + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| { + !l.is_empty() + && !l.starts_with("Here") + && !l.starts_with("Let me") + && !l.starts_with("I've") + && !l.starts_with("The diff") + }) + .collect(); + + Ok(findings) + } + Err(e) => { + warn!(error = %e, "auto-review LLM call failed"); + Ok(Vec::new()) + } + } +} + +/// Perform a simple heuristic-based review without an LLM call. +/// +/// This is a fallback when no LLM client is available. It checks for: +/// - Missing semicolons +/// - Unclosed brackets +/// - `todo!()` or `unimplemented!()` macros left in code +/// - Debug print statements +/// - Extremely long functions +fn perform_heuristic_review(diff: &str) -> Vec { + let mut findings = Vec::new(); + + // Check for added lines (lines starting with +) + let added_lines: Vec<&str> = diff + .lines() + .filter(|l| l.starts_with('+') && !l.starts_with("+++")) + .collect(); + + let added_content: String = added_lines + .iter() + .map(|l| &l[1..]) // Strip leading + + .collect::>() + .join("\n"); + + // Check for todo! and unimplemented! + if added_content.contains("todo!()") { + findings.push("[MEDIUM] `todo!()` found in new code — replace with implementation".to_string()); + } + if added_content.contains("unimplemented!()") { + findings.push("[MEDIUM] `unimplemented!()` found in new code — replace with implementation".to_string()); + } + + // Check for debug print statements + if added_content.contains("println!") || added_content.contains("dbg!") { + findings.push("[LOW] Debug print statements (println!/dbg!) found — consider removing before finalizing".to_string()); + } + if added_content.contains("eprintln!") { + findings.push("[LOW] Debug eprintln! found — consider removing before finalizing".to_string()); + } + + // Check for unreachable or panic statements + if added_content.contains("panic!(\"reached") || added_content.contains("panic!(\"not implemented") { + findings.push("[HIGH] Unreachable code / panic found — implement the missing logic".to_string()); + } + + // Check for very long lines (>120 chars) + for (i, line) in added_lines.iter().enumerate() { + let content = &line[1..]; // Strip leading + + if content.len() > 120 && !content.trim_start().starts_with("//") { + let preview: String = content.chars().take(80).collect(); + findings.push(format!( + "[LOW] Very long line ({} chars, line {} in diff) — consider breaking up:\n `{}…`", + content.len(), + i + 1, + preview + )); + } + } + + // Count new functions to detect very long additions + let fn_count = added_content.matches("fn ").count(); + if fn_count > 5 { + findings.push("[INFO] Large number of new functions ({fn_count}) — consider whether this should be split into separate modules".to_string()); + } + + findings +} diff --git a/apps/infrastructure/src/subagent/auto/mod.rs b/apps/infrastructure/src/subagent/auto/mod.rs index 38cc455..c107ab5 100644 --- a/apps/infrastructure/src/subagent/auto/mod.rs +++ b/apps/infrastructure/src/subagent/auto/mod.rs @@ -1,4 +1,5 @@ //! Auto-subagents — automatically run review/test subagents at the end of //! each turn. +pub mod engine; pub mod paths; diff --git a/apps/infrastructure/src/subagent/mod.rs b/apps/infrastructure/src/subagent/mod.rs index 0bdabdb..07bc11b 100644 --- a/apps/infrastructure/src/subagent/mod.rs +++ b/apps/infrastructure/src/subagent/mod.rs @@ -1,6 +1,7 @@ //! Subagent spawning and execution engine — spawn managed sub-processes //! for test generation, architecture review, security review, etc. +pub mod auto; pub mod context; pub mod division; pub mod engine; diff --git a/apps/infrastructure/src/tools/mod.rs b/apps/infrastructure/src/tools/mod.rs index c50f786..3326b92 100644 --- a/apps/infrastructure/src/tools/mod.rs +++ b/apps/infrastructure/src/tools/mod.rs @@ -19,13 +19,16 @@ pub mod fs; pub mod git; pub mod lsp; pub mod memory; +pub mod parallel_delegate; pub mod plan; pub mod search; +pub mod semantic_search; pub mod sequential_think; pub mod shell; pub mod shell_filter; pub mod spawn; pub mod utility; +pub mod web_search; pub mod workflow; pub use git::git_cred; @@ -204,6 +207,10 @@ pub fn all_tools() -> Vec> { Box::new(lsp::LspDefinition), Box::new(lsp::LspReferences), Box::new(lsp::LspDisconnect), + Box::new(web_search::WebSearch), + Box::new(semantic_search::SemanticSearch), + Box::new(semantic_search::RebuildIndex), + Box::new(parallel_delegate::ParallelDelegate), ] } diff --git a/apps/infrastructure/src/tools/parallel_delegate.rs b/apps/infrastructure/src/tools/parallel_delegate.rs new file mode 100644 index 0000000..707a52a --- /dev/null +++ b/apps/infrastructure/src/tools/parallel_delegate.rs @@ -0,0 +1,377 @@ +//! Parallel delegation tool — splits a large task into sub-tasks that run +//! concurrently across multiple subagents, then synthesises results. +//! +//! Flow: receive task description + optional breakdown → LLM analyses the +//! task and splits it into parallel directives → spawn subagents for each → +//! collect results → return consolidated output. + +use anyhow::Result; +use serde_json::{json, Value}; +use tracing::{debug, info, instrument, warn}; + +use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; +use zesdex_domain::core::Store; + +use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; +use crate::subagent::context::SubagentContext; +use crate::subagent::division::AccessTier; +use crate::subagent::spawn::spawn_subagent; +use crate::tools::{Tool, ToolCtx}; + +/// Delegate a large task to multiple subagents running in parallel. +/// +/// Flow: +/// 1. If `directives` are provided explicitly, use them directly. +/// 2. Otherwise, use LLM to analyse the task and split it into directives. +/// 3. Spawn a subagent for each directive concurrently. +/// 4. Join all results and consolidate into a single response. +pub struct ParallelDelegate; + +impl Tool for ParallelDelegate { + fn name(&self) -> &'static str { + "parallel_delegate" + } + + fn description(&self) -> &'static str { + "Delegate a large task to multiple subagents running in parallel for 3x faster completion" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The task to be split and delegated to parallel agents" + }, + "directives": { + "type": "array", + "items": { + "type": "object", + "properties": { + "directive": {"type": "string", "description": "Directive for one parallel agent"}, + "access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this agent (default: write)"} + }, + "required": ["directive"] + }, + "description": "Optional explicit list of parallel directives (if not provided, the LLM will auto-split the task)" + }, + "synthesize": { + "type": "boolean", + "description": "Whether to synthesize results into a unified response (default: true)", + "default": true + }, + "max_parallel": { + "type": "integer", + "description": "Maximum number of parallel agents (default: 3, max: 8)", + "default": 3 + } + }, + "required": ["task"] + }) + } + + #[instrument(skip(self, ctx, args))] + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let task = crate::tools::arg_str(args, "task")?; + let synthesize = args + .get("synthesize") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let max_parallel = args + .get("max_parallel") + .and_then(|v| v.as_u64()) + .unwrap_or(3) + .min(8) as usize; + + // Load LLM credentials + let store = Store::new(); + let settings = JsonSettingsRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); + let app_config = JsonAppConfigRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); + + let (provider, model) = + crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); + + let base_url = app_config + .providers + .get(&provider) + .map(|p| p.api_base.clone()) + .unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); + + let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); + + // Determine directives + let directives: Vec<(String, AccessTier)> = if let Some(dirs) = + args.get("directives").and_then(|v| v.as_array()) + { + // Explicit directives provided + dirs.iter() + .filter_map(|d| { + let directive = d.get("directive").and_then(|v| v.as_str())?; + let access_str = d + .get("access") + .and_then(|v| v.as_str()) + .unwrap_or("write"); + let access = match access_str { + "read" => AccessTier::Read, + "full" => AccessTier::Full, + _ => AccessTier::Write, + }; + Some((directive.to_string(), access)) + }) + .collect() + } else { + // Auto-split using LLM + let rt = tokio::runtime::Runtime::new()?; + let directives = rt.block_on(auto_split_task( + &task, + max_parallel, + &base_url, + &api_key, + &model, + ))?; + directives + }; + + if directives.is_empty() { + anyhow::bail!("no directives could be derived for the task"); + } + + let directive_count = directives.len(); + info!( + task = %task, + directive_count, + max_parallel, + "parallel delegation: starting subagents" + ); + + // Spawn agents in parallel + let mut handles = Vec::new(); + for (i, (directive, access)) in directives.iter().enumerate() { + let subagent_ctx = SubagentContext::new( + directive.clone(), + ctx.clone(), + format!("{access:?}"), + base_url.clone(), + api_key.clone(), + model.clone(), + ); + + debug!(agent_index = i, access = ?access, "spawning parallel agent"); + let handle = spawn_subagent( + subagent_ctx, + directive.clone(), + access.clone(), + ctx.clone(), + ); + handles.push((i, handle)); + } + + // Join all results + let mut results: Vec<(usize, String, String)> = Vec::new(); + for (i, handle) in handles { + match handle.join() { + Ok(Ok(output)) => { + info!(agent_index = i, "parallel agent completed"); + results.push((i, directives[i].0.clone(), output)); + } + Ok(Err(e)) => { + warn!(agent_index = i, error = %e, "parallel agent failed"); + results.push(( + i, + directives[i].0.clone(), + format!("[ERROR] {e}"), + )); + } + Err(e) => { + warn!(agent_index = i, error = ?e, "parallel agent panicked"); + results.push(( + i, + directives[i].0.clone(), + "[ERROR] Agent panicked".to_string(), + )); + } + } + } + + // Consolidate results + if synthesize && results.len() > 1 { + let consolidated = consolidate_results(&results, &base_url, &api_key, &model)?; + Ok(format!( + "## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}", + results.len(), + consolidated + )) + } else { + let mut output = format!( + "## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n", + results.len() + ); + for (i, directive, result) in &results { + output.push_str(&format!("---\n### Agent {}: {}\n\n{}\n", i, directive, result)); + } + Ok(output) + } + } +} + +/// Use LLM to analyse a task and split it into parallel directives. +async fn auto_split_task( + task: &str, + max_parallel: usize, + base_url: &str, + api_key: &str, + model: &str, +) -> Result> { + let client = crate::llm::provider::LlmClient::new( + api_key.to_string(), + model.to_string(), + Some(base_url.to_string()), + ); + + let sys_msg = zesdex_domain::core::ChatMessage::system( + format!( + "You are a task decomposition expert. Split the following task into {max_parallel} \ + independent sub-tasks that can run in parallel. Each sub-task must be self-contained \ + and produce useful output independently.\n\n\ + Output your response as a JSON array of objects, each with:\n\ + - \"directive\": a clear, detailed instruction for a subagent\n\ + - \"access\": one of \"read\", \"write\", or \"full\"\n\n\ + IMPORTANT: Return ONLY valid JSON, no other text. Example:\n\ + [{{\"directive\": \"Create the User model with fields...\", \"access\": \"write\"}}]" + ) + ); + + let user_msg = + zesdex_domain::core::ChatMessage::user(format!("Task: {task}\n\nSplit into {max_parallel} parallel directives:")); + + match client + .chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.4), None) + { + Ok((response, _)) => { + let text = response.content.unwrap_or_default(); + parse_directives_json(&text, max_parallel) + } + Err(e) => { + warn!(error = %e, "auto-split LLM call failed, using fallback splitting"); + Ok(fallback_split(task, max_parallel)) + } + } +} + +/// Parse JSON directives from LLM response. +fn parse_directives_json(text: &str, max_parallel: usize) -> Result> { + // Find JSON array in the response + let json_start = text.find('['); + let json_end = text.rfind(']'); + + let json_str = match (json_start, json_end) { + (Some(start), Some(end)) if end > start => &text[start..=end], + _ => { + // Try parsing the whole text as JSON + text.trim() + } + }; + + let parsed: Vec = serde_json::from_str(json_str) + .map_err(|e| anyhow::anyhow!("failed to parse directives JSON: {e}"))?; + + let directives: Vec<(String, AccessTier)> = parsed + .into_iter() + .take(max_parallel) + .filter_map(|v| { + let directive = v.get("directive")?.as_str()?.to_string(); + let access_str = v.get("access").and_then(|a| a.as_str()).unwrap_or("write"); + let access = match access_str { + "read" => AccessTier::Read, + "full" => AccessTier::Full, + _ => AccessTier::Write, + }; + Some((directive, access)) + }) + .collect(); + + Ok(directives) +} + +/// Fallback splitting when LLM call fails. +fn fallback_split(task: &str, max_parallel: usize) -> Vec<(String, AccessTier)> { + // Simple heuristic split by common patterns + let mut directives = Vec::new(); + + if task.contains("backend") || task.contains("api") || task.contains("server") { + directives.push(( + format!("Implement the backend/API components for: {task}"), + AccessTier::Write, + )); + } + + if task.contains("frontend") || task.contains("ui") || task.contains("client") { + directives.push(( + format!("Implement the frontend/UI components for: {task}"), + AccessTier::Write, + )); + } + + if task.contains("test") || task.contains("unit") { + directives.push(( + format!("Write unit tests for: {task}"), + AccessTier::Read, + )); + } + + if directives.is_empty() { + // Just split the task into generic chunks + for i in 0..max_parallel { + directives.push(( + format!("Part {} of parallel task: {task}", i + 1), + AccessTier::Write, + )); + } + } + + directives.truncate(max_parallel); + directives +} + +/// Use LLM to consolidate multiple agent results into a single response. +fn consolidate_results( + results: &[(usize, String, String)], + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + let client = crate::llm::provider::LlmClient::new( + api_key.to_string(), + model.to_string(), + Some(base_url.to_string()), + ); + + let mut summary = String::new(); + for (i, directive, result) in results { + summary.push_str(&format!("### Agent {}: {}\n{}\n\n", i, directive, result)); + } + + let sys_msg = zesdex_domain::core::ChatMessage::system( + "You are a synthesis expert. Consolidate the following parallel agent outputs \ + into a single coherent response. Remove duplication, reconcile conflicts, and \ + present the unified result in a well-structured format." + .to_string(), + ); + + let user_msg = zesdex_domain::core::ChatMessage::user(format!( + "Consolidate the following parallel agent outputs:\n\n{summary}" + )); + + match client.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.3), None) { + Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())), + Err(e) => { + warn!(error = %e, "consolidation LLM call failed, using raw concatenation"); + Ok(summary) + } + } +} diff --git a/apps/infrastructure/src/tools/semantic_search.rs b/apps/infrastructure/src/tools/semantic_search.rs new file mode 100644 index 0000000..626ea9b --- /dev/null +++ b/apps/infrastructure/src/tools/semantic_search.rs @@ -0,0 +1,690 @@ +//! Semantic code search tool — indexes all code symbols (functions, structs, +//! enums, traits, modules) in a project and allows searching by name, +//! concept, or meaning. +//! +//! Flow: walk workspace files → parse Rust source for symbol declarations → +//! build in-memory index → search by fuzzy/prefix match on symbol names and +//! doc comments. + +use crate::tools::{Tool, ToolCtx}; +use anyhow::Result; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Mutex; +use tracing::{debug, info, instrument, warn}; + +// --------------------------------------------------------------------------- +// Symbol index types +// --------------------------------------------------------------------------- + +/// The kind of a code symbol. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SymbolKind { + Function, + Struct, + Enum, + Trait, + Module, + Impl, + Type, + Constant, + Macro, + Other, +} + +impl std::fmt::Display for SymbolKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SymbolKind::Function => write!(f, "fn"), + SymbolKind::Struct => write!(f, "struct"), + SymbolKind::Enum => write!(f, "enum"), + SymbolKind::Trait => write!(f, "trait"), + SymbolKind::Module => write!(f, "mod"), + SymbolKind::Impl => write!(f, "impl"), + SymbolKind::Type => write!(f, "type"), + SymbolKind::Constant => write!(f, "const"), + SymbolKind::Macro => write!(f, "macro"), + SymbolKind::Other => write!(f, "symbol"), + } + } +} + +/// A single code symbol entry in the index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeSymbol { + /// Symbol name (e.g. "run_agent", "AppStateRest"). + pub name: String, + /// Kind of symbol. + pub kind: SymbolKind, + /// File path relative to workspace root. + pub file: String, + /// Line number (1-indexed). + pub line: usize, + /// Parent symbol (e.g. struct name for impl methods). + pub parent: Option, + /// Doc comment text, if any. + pub doc_comment: Option, + /// Short context (the declaration line). + pub context: String, +} + +/// The in-memory symbol index, shared via a global static. +static SYMBOL_INDEX: Mutex> = Mutex::new(None); + +/// Lazily compile regexes for symbol extraction. +fn compiled_regexes() -> ( + Regex, + Regex, + Regex, + Regex, + Regex, + Regex, + Regex, + Regex, + Regex, +) { + ( + Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?type\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)").unwrap(), + Regex::new(r"(?m)^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)").unwrap(), + ) +} + +/// A symbol index cache. +#[derive(Debug, Clone)] +pub struct SymbolIndex { + symbols: Vec, + workspace_path: Option, +} + +impl SymbolIndex { + pub fn new() -> Self { + SymbolIndex { + symbols: Vec::new(), + workspace_path: None, + } + } + + pub fn is_empty(&self) -> bool { + self.symbols.is_empty() + } + + pub fn len(&self) -> usize { + self.symbols.len() + } + + pub fn rebuild(&mut self, workspace: &str) -> Result { + let path = std::path::Path::new(workspace); + if !path.exists() { + anyhow::bail!("workspace path does not exist: {workspace}"); + } + + let mut symbols = Vec::new(); + let walker = ignore::Walk::new(path); + + for entry in walker.flatten() { + let file_path = entry.path(); + if !file_path.is_file() { + continue; + } + + // Only index Rust files + let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "rs" { + continue; + } + + let rel_path = file_path + .strip_prefix(path) + .unwrap_or(file_path) + .display() + .to_string(); + + match std::fs::read_to_string(file_path) { + Ok(content) => { + let file_symbols = extract_symbols(&content, &rel_path); + symbols.extend(file_symbols); + } + Err(e) => { + debug!(file = %rel_path, error = %e, "failed to read file for indexing"); + } + } + } + + symbols.sort_by(|a, b| a.name.cmp(&b.name)); + self.symbols = symbols; + self.workspace_path = Some(workspace.to_string()); + + let count = self.symbols.len(); + info!(symbol_count = count, workspace = %workspace, "symbol index rebuilt"); + Ok(count) + } + + pub fn search(&self, query: &str, max_results: usize) -> Vec<&CodeSymbol> { + if self.symbols.is_empty() { + return Vec::new(); + } + + let query_lower = query.to_lowercase(); + let query_words: Vec = query_lower.split_whitespace().map(|s| s.to_string()).collect(); + + let mut scored: Vec<(i32, &CodeSymbol)> = self + .symbols + .iter() + .filter_map(|sym| { + let score = score_symbol(sym, &query_lower, &query_words); + if score > 0 { + Some((score, sym)) + } else { + None + } + }) + .collect(); + + // Sort by score descending, then by name ascending + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name))); + + scored + .into_iter() + .take(max_results) + .map(|(_, sym)| sym) + .collect() + } +} + +impl Default for SymbolIndex { + fn default() -> Self { + Self::new() + } +} + +/// Score a symbol against a search query. +fn score_symbol(sym: &CodeSymbol, query_lower: &str, query_words: &[String]) -> i32 { + let name_lower = sym.name.to_lowercase(); + let mut score: i32 = 0; + + // Exact match = highest score + if name_lower == *query_lower { + score += 1000; + } + + // Prefix match + if name_lower.starts_with(query_lower) { + score += 500; + } + + // Contains + if name_lower.contains(query_lower) { + score += 200; + } + + // Word-by-word matching + for word in query_words { + if name_lower.contains(word) { + score += 50; + } + } + + // Doc comment match + if let Some(ref doc) = sym.doc_comment { + let doc_lower = doc.to_lowercase(); + if doc_lower.contains(query_lower) { + score += 30; + } + for word in query_words { + if doc_lower.contains(word) { + score += 10; + } + } + } + + // Context match + let context_lower = sym.context.to_lowercase(); + if context_lower.contains(query_lower) { + score += 20; + } + + score +} + +/// Extract code symbols from Rust source content. +fn extract_symbols(content: &str, rel_path: &str) -> Vec { + let (fn_re, struct_re, enum_re, trait_re, mod_re, impl_re, type_re, const_re, macro_re) = + compiled_regexes(); + + let mut symbols = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + + // Extract doc comments that precede declarations + let doc_comments = extract_doc_comments(&lines); + + for (i, line) in lines.iter().enumerate() { + let line_num = i + 1; + let trimmed = line.trim(); + + // Check for function declarations + if let Some(caps) = fn_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + let doc = doc_comments.get(&line_num).cloned(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Function, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: doc, + context: trimmed.to_string(), + }); + } + + // Check for struct declarations + if let Some(caps) = struct_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + let doc = doc_comments.get(&line_num).cloned(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Struct, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: doc, + context: trimmed.to_string(), + }); + } + + // Check for enum declarations + if let Some(caps) = enum_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + let doc = doc_comments.get(&line_num).cloned(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Enum, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: doc, + context: trimmed.to_string(), + }); + } + + // Check for trait declarations + if let Some(caps) = trait_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + let doc = doc_comments.get(&line_num).cloned(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Trait, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: doc, + context: trimmed.to_string(), + }); + } + + // Check for module declarations + if let Some(caps) = mod_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Module, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: None, + context: trimmed.to_string(), + }); + } + + // Check for type alias declarations + if let Some(caps) = type_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + let doc = doc_comments.get(&line_num).cloned(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Type, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: doc, + context: trimmed.to_string(), + }); + } + + // Check for const declarations + if let Some(caps) = const_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + let doc = doc_comments.get(&line_num).cloned(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Constant, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: doc, + context: trimmed.to_string(), + }); + } + + // Check for macro declarations + if let Some(caps) = macro_re.captures(trimmed) { + let name = caps.get(1).unwrap().as_str().to_string(); + symbols.push(CodeSymbol { + name, + kind: SymbolKind::Macro, + file: rel_path.to_string(), + line: line_num, + parent: None, + doc_comment: None, + context: trimmed.to_string(), + }); + } + + // Parse impl blocks for method-level indexing + if let Some(caps) = impl_re.captures(trimmed) { + let impl_for = caps.get(1).unwrap().as_str().to_string(); + // Look for methods inside this impl block + let mut brace_depth: i32 = 0; + let mut started = false; + for (j, l) in lines[i..].iter().enumerate() { + for ch in l.chars() { + match ch { + '{' => { + brace_depth += 1; + started = true; + } + '}' => { + brace_depth -= 1; + } + _ => {} + } + } + if started && brace_depth <= 0 && j > 1 { + break; // End of impl block + } + if j > 0 { + let inner_line = l.trim(); + if let Some(mcaps) = fn_re.captures(inner_line) { + let method_name = mcaps.get(1).unwrap().as_str().to_string(); + let abs_line = i + j + 1; + let doc = doc_comments.get(&abs_line).cloned(); + symbols.push(CodeSymbol { + name: format!("{impl_for}::{method_name}"), + kind: SymbolKind::Function, + file: rel_path.to_string(), + line: abs_line, + parent: Some(impl_for.clone()), + doc_comment: doc, + context: inner_line.to_string(), + }); + } + } + } + } + } + + symbols +} + +/// Extract doc comments (/// or //!) that precede each line. +fn extract_doc_comments(lines: &[&str]) -> HashMap { + let mut map = HashMap::new(); + let mut i = 0; + while i < lines.len() { + let line = lines[i].trim(); + if line.starts_with("///") { + let mut doc = String::new(); + while i < lines.len() { + let l = lines[i].trim(); + if l.starts_with("///") { + if !doc.is_empty() { + doc.push(' '); + } + doc.push_str(l.trim_start_matches("///").trim()); + i += 1; + } else { + break; + } + } + // Associate doc with the next non-empty, non-doc, non-attribute line + let target_line = find_next_declaration_line(lines, i); + if let Some(tl) = target_line { + map.insert(tl + 1, doc); + } + } else { + i += 1; + } + } + map +} + +/// Find the next line that looks like a declaration (not doc, not attr). +fn find_next_declaration_line(lines: &[&str], start: usize) -> Option { + for i in start..lines.len() { + let trimmed = lines[i].trim(); + if trimmed.is_empty() + || trimmed.starts_with("///") + || trimmed.starts_with("//!") + || trimmed.starts_with('#') + { + continue; + } + return Some(i); + } + None +} + +// --------------------------------------------------------------------------- +// Tool: SemanticSearch — search the symbol index +// --------------------------------------------------------------------------- + +/// Search for code symbols by name, concept, or semantic meaning. +/// +/// Flow: ensure index is built → search by query → return formatted results. +pub struct SemanticSearch; + +impl Tool for SemanticSearch { + fn name(&self) -> &'static str { + "semantic_search" + } + + fn description(&self) -> &'static str { + "Search for code symbols (functions, structs, enums, traits) by name, concept, or meaning" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query — function name, struct name, or concept (e.g. 'payment handler', 'auth middleware', 'user repository')" + }, + "kind": { + "type": "string", + "enum": ["fn", "struct", "enum", "trait", "mod", "all"], + "description": "Filter by symbol kind (default: all)", + "default": "all" + }, + "max_results": { + "type": "integer", + "description": "Maximum results (default 10, max 30)", + "default": 10 + }, + "rebuild_index": { + "type": "boolean", + "description": "Force rebuild the symbol index before searching (default false)", + "default": false + } + }, + "required": ["query"] + }) + } + + #[instrument(skip(self, ctx, args))] + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let query = crate::tools::arg_str(args, "query")?; + let kind_filter = args + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("all"); + let max_results = args + .get("max_results") + .and_then(|v| v.as_u64()) + .unwrap_or(10) + .min(30) as usize; + let rebuild = args + .get("rebuild_index") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let workspace = ctx + .workspaces + .first() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + + info!(query = %query, kind = %kind_filter, max_results, rebuild, "semantic search"); + + // Get or rebuild the index + let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; + let index = guard.get_or_insert_with(SymbolIndex::new); + + if rebuild || index.is_empty() { + let count = index.rebuild(&workspace)?; + debug!(symbol_count = count, "symbol index rebuilt"); + } + + let results = index.search(&query, max_results * 2); // Get extra for filtering + + // Apply kind filter + let filtered: Vec<&&CodeSymbol> = if kind_filter != "all" { + let target_kind = match kind_filter { + "fn" => SymbolKind::Function, + "struct" => SymbolKind::Struct, + "enum" => SymbolKind::Enum, + "trait" => SymbolKind::Trait, + "mod" => SymbolKind::Module, + _ => SymbolKind::Other, + }; + results + .iter() + .filter(|s| s.kind == target_kind) + .take(max_results) + .collect() + } else { + results.iter().take(max_results).collect() + }; + + if filtered.is_empty() { + return Ok(format!( + "No symbols found matching '{query}'.\n\ + Try a different query, or use `rebuild_index: true` to rebuild the index first." + )); + } + + let total = index.len(); + info!(matched = filtered.len(), total_indexed = total, "semantic search completed"); + + // Group results by file for cleaner output + let mut by_file: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for sym in &filtered { + by_file.entry(sym.file.clone()).or_default().push(*sym); + } + + let mut output = format!( + "## Semantic Search Results\n\n**Query:** {query}\n**Index size:** {total} symbols\n**Matches:** {}\n\n", + filtered.len() + ); + + for (file, symbols) in &by_file { + output.push_str(&format!("### `{file}`\n\n")); + for sym in symbols { + let kind_str = sym.kind.to_string(); + let parent_str = sym + .parent + .as_ref() + .map(|p| format!(" [{p}]")) + .unwrap_or_default(); + let doc_str = sym + .doc_comment + .as_ref() + .map(|d| { + let truncated: String = d.chars().take(100).collect(); + format!(" — {truncated}") + }) + .unwrap_or_default(); + + let context_trimmed = sym.context.trim(); + let context_ellipsis = if context_trimmed.len() > 80 { "…" } else { "" }; + + output.push_str(&format!( + "- `{kind_str}` **{}**{} at line {} `{}`{}{}\n", + sym.name, + parent_str, + sym.line, + context_trimmed, + doc_str, + context_ellipsis + )); + } + output.push('\n'); + } + + output.push_str(&format!( + "---\n*{} symbols indexed. Use `rebuild_index: true` to refresh.*\n", + total + )); + + Ok(output) + } +} + +/// Tool: Rebuild the symbol index explicitly. +pub struct RebuildIndex; + +impl Tool for RebuildIndex { + fn name(&self) -> &'static str { + "rebuild_index" + } + + fn description(&self) -> &'static str { + "Rebuild the code symbol index for semantic search" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": {} + }) + } + + #[instrument(skip(self, ctx, _args))] + fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result { + let workspace = ctx + .workspaces + .first() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + + info!("rebuilding symbol index"); + + let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; + let index = guard.get_or_insert_with(SymbolIndex::new); + let count = index.rebuild(&workspace)?; + + Ok(format!( + "Symbol index rebuilt successfully. {} symbols indexed.", + count + )) + } +} diff --git a/apps/infrastructure/src/tools/web_search.rs b/apps/infrastructure/src/tools/web_search.rs new file mode 100644 index 0000000..b643cef --- /dev/null +++ b/apps/infrastructure/src/tools/web_search.rs @@ -0,0 +1,283 @@ +//! Web search & documentation scraper tool — search the web for docs, APIs, +//! and troubleshooting information using a SearXNG instance. +//! +//! Flow: take a search query → call SearXNG JSON API → parse results → +//! optionally fetch full page content → return formatted markdown. + +use anyhow::Result; +use serde_json::{json, Value}; +use tracing::{debug, info, instrument, warn}; + +use crate::tools::{arg_str, Tool, ToolCtx}; + +/// Default SearXNG instance URL. +const DEFAULT_SEARXNG_URL: &str = "https://searxng.imrnes.team"; + +/// Search the web for documentation, APIs, and technical information. +/// +/// Flow: build query → call SearXNG JSON endpoint → parse results → +/// optionally scrape full page content → return formatted result. +pub struct WebSearch; + +impl Tool for WebSearch { + fn name(&self) -> &'static str { + "web_search" + } + + fn description(&self) -> &'static str { + "Search the web for documentation, APIs, and technical information. Uses SearXNG instance." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (e.g. 'Next.js 15 app router documentation')" + }, + "max_results": { + "type": "integer", + "description": "Maximum number of results to return (default 5, max 10)", + "default": 5 + }, + "fetch_content": { + "type": "boolean", + "description": "Whether to fetch full page content from each result (default false)", + "default": false + }, + "categories": { + "type": "string", + "description": "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general", + "default": "general" + } + }, + "required": ["query"] + }) + } + + #[instrument(skip(self, _ctx, args))] + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { + let query = arg_str(args, "query")?; + let max_results = args + .get("max_results") + .and_then(|v| v.as_u64()) + .unwrap_or(5) + .min(10) as usize; + let fetch_content = args + .get("fetch_content") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let categories = args + .get("categories") + .and_then(|v| v.as_str()) + .unwrap_or("general"); + + info!(query = %query, max_results, fetch_content, categories = %categories, "web search starting"); + + let searxng_url = std::env::var("SEARXNG_URL") + .unwrap_or_else(|_| DEFAULT_SEARXNG_URL.to_string()); + + // Build the SearXNG JSON search URL + let search_url = format!( + "{}/search?format=json&q={}&categories={}&language=en-US", + searxng_url, + urlencoding(&query), + categories + ); + + debug!(search_url = %search_url, "calling SearXNG API"); + + // Make the HTTP request + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .user_agent("Zesdex/1.0 (AI Coding Agent)") + .build() + .map_err(|e| anyhow::anyhow!("failed to create HTTP client: {e}"))?; + + let response = client + .get(&search_url) + .send() + .map_err(|e| anyhow::anyhow!("search request failed: {e}"))?; + + if !response.status().is_success() { + warn!(status = %response.status(), "SearXNG returned non-success status"); + anyhow::bail!("SearXNG returned HTTP {}", response.status()); + } + + let body: Value = response + .json() + .map_err(|e| anyhow::anyhow!("failed to parse SearXNG JSON response: {e}"))?; + + let results = body + .get("results") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter(|r| { + r.get("url").and_then(|u| u.as_str()).is_some() + && r.get("title").and_then(|t| t.as_str()).is_some() + }) + .take(max_results) + .collect::>() + }) + .unwrap_or_default(); + + let result_count = results.len(); + if results.is_empty() { + info!("no search results found"); + return Ok(format!("No search results found for '{query}'.")); + } + + info!(result_count, "web search completed"); + + let mut output = format!("## Web Search Results for: {query}\n\n"); + + for (i, result) in results.iter().enumerate() { + let title = result + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled"); + let url = result + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let snippet = result + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + + output.push_str(&format!("### {}. [{title}]({url})\n\n", i + 1)); + + if !snippet.is_empty() { + output.push_str(&format!("> {snippet}\n\n")); + } + + // Optionally fetch full page content + if fetch_content && !url.is_empty() { + match fetch_page_content(url, &client) { + Ok(content) => { + let truncated = limit_lines(&content, 80); + output.push_str(&format!("**Content:**\n```\n{truncated}\n```\n\n")); + } + Err(e) => { + debug!(url = %url, error = %e, "failed to fetch page content"); + output.push_str(&format!("*(Content fetch failed: {e})*\n\n")); + } + } + } + + // Show engine info if available + if let Some(engine) = result.get("engine").and_then(|v| v.as_str()) { + output.push_str(&format!("*Source: {engine}*\n\n")); + } + } + + // Add suggestion for more specific queries if very few results + if result_count < 3 { + output.push_str("---\n*Few results. Try a more specific query or different categories.*\n"); + } + + Ok(output) + } +} + +/// URL-encode a string for use in query parameters. +fn urlencoding(input: &str) -> String { + input + .chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), + ' ' => '+'.to_string(), + _ => { + let bytes = c.to_string().into_bytes(); + bytes + .iter() + .map(|&b| format!("%{:02X}", b)) + .collect::() + } + }) + .collect() +} + +/// Fetch and extract readable text content from a URL. +fn fetch_page_content(url: &str, client: &reqwest::blocking::Client) -> Result { + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(15)) + .send() + .map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?; + + let html = resp + .text() + .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?; + + // Parse HTML and extract text + let document = scraper::Html::parse_document(&html); + + // Remove script and style elements + let selector = scraper::Selector::parse("script, style, nav, footer, header") + .map_err(|e| anyhow::anyhow!("invalid selector: {e}"))?; + let cleaned = document.clone(); + for element in cleaned.select(&selector) { + let inner = element.inner_html(); + let _ = inner; + } + + // Extract text from main content areas + let content_selectors = [ + "article", + "main", + ".content", + ".documentation", + ".doc-content", + ".post-content", + ".entry-content", + "#content", + "body", + ]; + + let mut text = String::new(); + for sel_str in &content_selectors { + if let Ok(sel) = scraper::Selector::parse(sel_str) { + if let Some(element) = cleaned.select(&sel).next() { + text = element.text().collect::>().join(" "); + if text.len() > 100 { + break; + } + } + } + } + + if text.is_empty() { + // Fallback: just get all body text + if let Ok(body_sel) = scraper::Selector::parse("body") { + text = cleaned + .select(&body_sel) + .next() + .map(|e| e.text().collect::>().join(" ")) + .unwrap_or_default(); + } + } + + // Clean up whitespace + let cleaned_text = text + .split_whitespace() + .collect::>() + .join(" "); + + if cleaned_text.is_empty() { + anyhow::bail!("no readable content found on page"); + } + + Ok(cleaned_text) +} + +/// Limit a string to at most `max_lines` lines. +fn limit_lines(s: &str, max_lines: usize) -> String { + s.lines() + .take(max_lines) + .collect::>() + .join("\n") +} diff --git a/apps/interfaces/daemon/src/handler.rs b/apps/interfaces/daemon/src/handler.rs index d65044f..ff28cf2 100644 --- a/apps/interfaces/daemon/src/handler.rs +++ b/apps/interfaces/daemon/src/handler.rs @@ -330,6 +330,8 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) { api_key, model: state.settings.model.clone(), api_base: provider_cfg.map(|cfg| cfg.api_base.clone()), + edit_count: 0, + consecutive_empty_reviews: 0, }; zesdex_infrastructure::agent::spawn_agent_turn(params); diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs index 5d7d2a2..5c898b4 100644 --- a/apps/interfaces/tui/src/action.rs +++ b/apps/interfaces/tui/src/action.rs @@ -12,6 +12,7 @@ //! *how* state is updated — only *what* action to produce. use crate::state::Overlay; +use std::process::Command; use tracing::debug; /// A single well-typed event in the TUI that mutates `AppStateRest`. @@ -85,6 +86,10 @@ pub enum Action { AbortTurn, /// Request AI-summary compaction of the conversation history. Compact, + /// Show the git diff preview overlay. + ShowDiff, + /// Scroll the diff overlay. + DiffScroll(i32), } /// Apply an `Action` to `AppStateRest`. @@ -309,5 +314,107 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { Action::Compact => { state.toast_info("Compacting conversation...".to_string()); } + Action::ShowDiff => { + // Run git diff to get current changes + let diff_output = git_diff_output(state); + state.misc.diff_content = diff_output; + state.misc.diff_scroll = 0; + state.misc.overlay = crate::state::Overlay::Diff; + state.mark_dirty(); + } + Action::DiffScroll(amount) => { + let max_scroll = state + .misc + .diff_content + .lines() + .count() + .saturating_sub(1); + let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize; + state.misc.diff_scroll = new_scroll.min(max_scroll); + state.mark_dirty(); + } } } + +/// Run `git diff` and return the output for display in the diff overlay. +/// +/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back +/// to `git diff` if HEAD has no commits yet. Returns a summary of what +/// changed with colored +/- markers. +fn git_diff_output(state: &crate::state::AppStateRest) -> String { + let root = state + .workspace_roots + .first() + .cloned() + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + let mut output = String::new(); + + // Try diff against HEAD + let head_result = Command::new("git") + .args(["diff", "HEAD"]) + .current_dir(&root) + .output(); + + match head_result { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + output.push_str("=== Changes (against HEAD) ===\n"); + output.push_str(&stdout); + output.push('\n'); + } + } + Err(_) => { + // Fallback: no HEAD yet (new repo) + let diff_result = Command::new("git") + .args(["diff"]) + .current_dir(&root) + .output(); + if let Ok(out) = diff_result { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + output.push_str("=== Unstaged Changes ===\n"); + output.push_str(&stdout); + output.push('\n'); + } + } + } + } + + // Get staged changes too + let staged_result = Command::new("git") + .args(["diff", "--cached"]) + .current_dir(&root) + .output(); + + if let Ok(out) = staged_result { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + output.push_str("=== Staged Changes ===\n"); + output.push_str(&stdout); + output.push('\n'); + } + } + + // Also get status summary + let status_result = Command::new("git") + .args(["status", "--short"]) + .current_dir(&root) + .output(); + + if let Ok(out) = status_result { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + output.push_str("=== Summary ===\n"); + output.push_str(&stdout); + output.push('\n'); + } + } + + if output.is_empty() { + output = "No changes detected in the working tree.".to_string(); + } + + output +} diff --git a/apps/interfaces/tui/src/controller/command.rs b/apps/interfaces/tui/src/controller/command.rs index 511d7b4..07bfa06 100644 --- a/apps/interfaces/tui/src/controller/command.rs +++ b/apps/interfaces/tui/src/controller/command.rs @@ -36,6 +36,8 @@ pub enum Command { PlanOpen, /// `/usage` — open the usage-stats overlay. UsageOpen, + /// `/diff` — show git diff preview. + Diff, /// Catch-all: unrecognised or non-slash input. Unknown(String), } @@ -89,6 +91,7 @@ pub fn parse_command(text: &str) -> Command { "/todo" => Command::TodoOpen, "/plan" => Command::PlanOpen, "/usage" => Command::UsageOpen, + "/diff" => Command::Diff, _ => Command::Unknown(cmd.to_string()), }; @@ -142,6 +145,9 @@ pub fn apply_command(cmd: Command) -> Vec { Command::UsageOpen => { vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)] } + Command::Diff => { + vec![crate::action::Action::ShowDiff] + } Command::Unknown(text) => { if text.starts_with('/') { vec![crate::action::Action::SystemNote { diff --git a/apps/interfaces/tui/src/controller/input.rs b/apps/interfaces/tui/src/controller/input.rs index d1b328d..88c83cc 100644 --- a/apps/interfaces/tui/src/controller/input.rs +++ b/apps/interfaces/tui/src/controller/input.rs @@ -82,6 +82,31 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } } + // ── Diff overlay ────────────────────────────────────────────────────── + if state.misc.overlay == Overlay::Diff { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return vec![Action::QuitConfirm]; + } + KeyCode::Up | KeyCode::Char('k') => { + return vec![Action::DiffScroll(-1)]; + } + KeyCode::Down | KeyCode::Char('j') => { + return vec![Action::DiffScroll(1)]; + } + KeyCode::PageUp => { + return vec![Action::DiffScroll(-20)]; + } + KeyCode::PageDown => { + return vec![Action::DiffScroll(20)]; + } + KeyCode::Esc => { + return vec![Action::CloseOverlay]; + } + _ => {} + } + } + // ── Learning overlay ────────────────────────────────────────────────── if state.misc.overlay == Overlay::Learning { match key.code { @@ -164,6 +189,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } Vec::new() } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => { + vec![crate::action::Action::ShowDiff] + } KeyCode::Enter => { if state.input.autocomplete_visible { state.input.select_autocomplete(); diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index aae2677..50028ad 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -439,6 +439,8 @@ pub enum Overlay { ModelSelector, /// "Clear conversation?" confirmation. ClearConfirm, + /// Git diff preview overlay. + Diff, } impl Overlay { @@ -462,6 +464,7 @@ impl Overlay { Overlay::Loading => "loading", Overlay::ModelSelector => "model_selector", Overlay::ClearConfirm => "clear_confirm", + Overlay::Diff => "diff", } } @@ -510,6 +513,10 @@ pub struct MiscState { pub lesson_running: bool, /// Text waiting to be written to the system clipboard. pub pending_clipboard_copy: Option, + /// Cached git diff content for the preview overlay. + pub diff_content: String, + /// Scroll offset for the diff overlay. + pub diff_scroll: usize, } impl MiscState { @@ -529,6 +536,8 @@ impl MiscState { plan_content: String::new(), lesson_running: false, pending_clipboard_copy: None, + diff_content: String::new(), + diff_scroll: 0, } } diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index c17593b..d75395b 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -61,6 +61,17 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { info!("delegating agent turn to infrastructure engine (model: {})", model); + let edit_count = state + .session_runtime + .as_ref() + .map(|rt| rt.edit_count) + .unwrap_or(0); + let consecutive_empty_reviews = state + .session_runtime + .as_ref() + .map(|rt| rt.consecutive_empty_reviews) + .unwrap_or(0); + let params = AgentTurnParams { messages, session_dir, @@ -71,6 +82,8 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { api_key, model, api_base, + edit_count, + consecutive_empty_reviews, }; backend_spawn_agent_turn(params); diff --git a/apps/interfaces/tui/src/view/overlays/diff.rs b/apps/interfaces/tui/src/view/overlays/diff.rs new file mode 100644 index 0000000..b43c1a2 --- /dev/null +++ b/apps/interfaces/tui/src/view/overlays/diff.rs @@ -0,0 +1,138 @@ +//! Diff preview overlay — renders git diff output with color-coded +//! additions (green) and deletions (red) in a scrollable panel. +//! +//! Flow: read `state.misc.diff_content` → parse diff lines → apply syntax +//! coloring (green `+`, red `-`, blue header, dim context) → render in a +//! scrollable paragraph widget. + +use crate::state::AppStateRest; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Wrap}; +use ratatui::Frame; +use super::super::theme::Theme; +use super::overlay_block; + +/// Render the diff preview overlay with color-coded +/- lines and scrolling. +/// +/// The overlay fills most of the screen and shows: +/// - Green lines for additions (`+`) +/// - Red lines for deletions (`-`) +/// - Blue/dim headers (diff --git, @@ hunk headers) +/// - Dim context lines +/// - Scroll hint at the bottom +pub fn render(frame: &mut Frame, area: Rect, block: Block<'static>, state: &AppStateRest) { + let block = overlay_block(block, " Git Diff Preview ", Theme::PRIMARY); + let inner = block.inner(area); + + // Render the block background + frame.render_widget(block.clone(), area); + + let diff_content = &state.misc.diff_content; + let diff_scroll = state.misc.diff_scroll; + + // Parse lines with ANSI-style coloring + let lines: Vec = diff_content + .lines() + .skip(diff_scroll) + .map(|line| { + let trimmed = line.trim_end(); + if trimmed.starts_with('+') && !trimmed.starts_with("+++") { + // Addition — green + Line::from(Span::styled( + format!("{trimmed}\n"), + Style::default().fg(Theme::SUCCESS), + )) + } else if trimmed.starts_with('-') && !trimmed.starts_with("---") { + // Deletion — red + Line::from(Span::styled( + format!("{trimmed}\n"), + Style::default().fg(Theme::ERROR), + )) + } else if trimmed.starts_with("@@") { + // Hunk header — cyan + Line::from(Span::styled( + format!("{trimmed}\n"), + Style::default() + .fg(Theme::INFO) + .add_modifier(Modifier::DIM), + )) + } else if trimmed.starts_with("diff --git") + || trimmed.starts_with("index ") + || trimmed.starts_with("--- ") + || trimmed.starts_with("+++ ") + || trimmed.starts_with("===") + { + // File header — blue/bold + Line::from(Span::styled( + format!("{trimmed}\n"), + Style::default() + .fg(Theme::PRIMARY) + .add_modifier(Modifier::BOLD), + )) + } else { + // Context — dim + Line::from(Span::styled( + format!("{trimmed}\n"), + Style::default().fg(Theme::TEXT_DIM), + )) + } + }) + .collect(); + + let total_lines = diff_content.lines().count(); + let visible_lines = inner.height as usize; + let has_more = diff_scroll + visible_lines < total_lines; + + // Create the content paragraph + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, inner); + + // Render scroll indicator at the bottom + if has_more { + let scroll_pct = if total_lines > 0 { + ((diff_scroll as f64 / total_lines as f64) * 100.0) as u32 + } else { + 0 + }; + let scroll_text = format!( + " Lines {}-{} of {} ({}%) ", + diff_scroll + 1, + (diff_scroll + visible_lines).min(total_lines), + total_lines, + scroll_pct + ); + + let status_line = Line::from(Span::styled( + scroll_text, + Style::default() + .fg(Theme::TEXT_DIM) + .add_modifier(Modifier::ITALIC), + )); + + let status_area = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + frame.render_widget(Paragraph::new(status_line), status_area); + } + + // Render keybinding hint at the top + let hint = Line::from(Span::styled( + " ↑/↓: scroll Esc: close /diff: refresh", + Style::default() + .fg(Theme::TEXT_DIM) + .add_modifier(Modifier::ITALIC), + )); + + let hint_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + frame.render_widget(Paragraph::new(hint), hint_area); +} diff --git a/apps/interfaces/tui/src/view/overlays/mod.rs b/apps/interfaces/tui/src/view/overlays/mod.rs index c10a824..d8e2496 100644 --- a/apps/interfaces/tui/src/view/overlays/mod.rs +++ b/apps/interfaces/tui/src/view/overlays/mod.rs @@ -4,6 +4,7 @@ pub mod bash; pub mod clear_confirm; +pub mod diff; pub mod editor; pub mod effort; pub mod help; @@ -123,5 +124,8 @@ pub fn render_overlay( Overlay::ClearConfirm => { clear_confirm::render(frame, overlay_area, block, state); } + Overlay::Diff => { + diff::render(frame, overlay_area, block, state); + } } } diff --git a/apps/interfaces/ws/src/lib.rs b/apps/interfaces/ws/src/lib.rs index a477e93..c2c2a56 100644 --- a/apps/interfaces/ws/src/lib.rs +++ b/apps/interfaces/ws/src/lib.rs @@ -92,6 +92,8 @@ async fn handle_socket(mut socket: WebSocket, state: Arc) { api_key, model, api_base: None, + edit_count: 0, + consecutive_empty_reviews: 0, }; zesdex_infrastructure::agent::spawn_agent_turn(params);