From 3ca8dde37dad1317319435f9327bb9216c2dad8e Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Thu, 11 Jun 2026 08:55:05 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Uteke=20memory=20integration=20?= =?UTF-8?q?=E2=80=94=20recall=20context=20+=20save=20findings=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements optional Uteke memory integration for AI-powered review memory. New: - src/engine/memory.rs — MemoryBackend with detect/recall/save/feedback - --memory flag — recall project patterns from Uteke before review - --learn flag — recall + save findings after review (implies --memory) - 11 unit tests Integration is purely additive via subprocess (uteke recall/remember CLI). Cora works 100% without Uteke — graceful degradation when not installed. Flow: cora review → standalone (default, no change) cora review --memory → recall context, enrich prompt cora review --memory --learn → recall + save findings Phase 2: inject memory_context into LLM prompt (TODO marker in main.rs) Phase 3: extract actual findings count from ReviewResult for save_findings --- Cargo.lock | 31 +++++ Cargo.toml | 1 + src/engine/memory.rs | 296 +++++++++++++++++++++++++++++++++++++++++++ src/engine/mod.rs | 1 + src/main.rs | 82 ++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 src/engine/memory.rs diff --git a/Cargo.lock b/Cargo.lock index 2117389..e702d20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "which", ] [[package]] @@ -391,12 +392,24 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -2176,6 +2189,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2409,6 +2434,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 2074bfb..45cdc6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ ignore = "0.4" # Error handling anyhow = "1" +which = "7" thiserror = "2" # Config diff --git a/src/engine/memory.rs b/src/engine/memory.rs new file mode 100644 index 0000000..b33ea59 --- /dev/null +++ b/src/engine/memory.rs @@ -0,0 +1,296 @@ +//! Uteke memory integration — optional recall/learn during reviews. +//! +//! Cora works 100% without Uteke. Integration is purely additive: +//! - `--memory` → recall project patterns before review, enrich prompt +//! - `--memory --learn` → recall + save findings after review +//! +//! Uteke CLI is called via subprocess. No Rust library dependency. + +use std::process::Command; + +/// Whether Uteke memory integration is active. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryLevel { + /// No memory — standalone review (default). + None, + /// Recall context before review. + Context, + /// Recall + save findings after review. + Learning, +} + +/// Backend for Uteke memory integration. +pub struct MemoryBackend { + enabled: bool, + namespace: String, +} + +impl Default for MemoryBackend { + fn default() -> Self { + Self { + enabled: false, + namespace: "cora".to_string(), + } + } +} + +impl MemoryBackend { + /// Create a new memory backend with the given namespace. + #[allow(dead_code)] + pub fn new(namespace: &str) -> Self { + Self { + enabled: false, + namespace: namespace.to_string(), + } + } + + /// Auto-detect if `uteke` CLI is available on PATH. + pub fn detect(&mut self) { + self.enabled = which::which("uteke").is_ok(); + if self.enabled { + tracing::info!("Uteke detected — memory integration enabled"); + } else { + tracing::debug!("Uteke not found — memory integration disabled"); + } + } + + /// Check if memory is available. + pub fn is_available(&self) -> bool { + self.enabled + } + + /// Recall project patterns from Uteke before review. + /// + /// Calls: `uteke recall "{project} code-pattern" --namespace cora --limit 5` + pub fn recall_context(&self, project: &str) -> Vec { + if !self.enabled { + return Vec::new(); + } + + let query = format!("{project} code-pattern review-history"); + + let output = match Command::new("uteke") + .args([ + "recall", + &query, + "--namespace", + &self.namespace, + "--limit", + "5", + "--format", + "json", + ]) + .output() + { + Ok(o) => o, + Err(e) => { + tracing::warn!("Failed to recall from Uteke: {e}"); + return Vec::new(); + } + }; + + if !output.status.success() { + tracing::debug!( + "Uteke recall returned non-zero: {}", + String::from_utf8_lossy(&output.stderr) + ); + return Vec::new(); + } + + // Parse JSON output — each line is a result with "content" field + let stdout = String::from_utf8_lossy(&output.stdout); + let mut memories = Vec::new(); + + for line in stdout.lines() { + if let Ok(val) = serde_json::from_str::(line) { + if let Some(content) = val.get("content").and_then(|c| c.as_str()) { + memories.push(content.to_string()); + } + } + } + + tracing::info!("Recalled {} memories from Uteke", memories.len()); + memories + } + + /// Save review findings to Uteke after review. + /// + /// Calls: `uteke remember "cora:{project}:stats:..." --tags cora,pattern` + pub fn save_findings( + &self, + project: &str, + total_issues: usize, + severity_summary: &str, + categories: &[String], + ) { + if !self.enabled { + return; + } + + // Save review stats + let stats_content = + format!("cora:{project}:stats:issues={total_issues},severities={severity_summary}"); + self.remember(&stats_content, &["cora", "stats"]); + + // Save category patterns + for cat in categories { + let pattern_content = format!("cora:{project}:pattern:{cat}"); + self.remember(&pattern_content, &["cora", "pattern", cat]); + } + } + + /// Save false positive feedback to Uteke. + /// + /// Calls: `uteke remember "cora:{project}:fp:{issue_id}" --tags cora,false-positive` + #[allow(dead_code)] + pub fn save_feedback(&self, project: &str, issue_id: &str, feedback: &str) { + if !self.enabled { + return; + } + + let content = format!("cora:{project}:fp:{issue_id}: {feedback}"); + self.remember(&content, &["cora", "false-positive"]); + } + + /// Build enriched system prompt section from recalled memories. + pub fn build_memory_context(&self, memories: &[String]) -> String { + if memories.is_empty() { + return String::new(); + } + + let mut parts = Vec::new(); + parts.push("PROJECT HISTORY (from Uteke memory):".to_string()); + parts.push( + "The following patterns were observed in previous reviews. Use them to reduce false positives." + .to_string(), + ); + + for (i, mem) in memories.iter().enumerate() { + parts.push(format!("{}. {mem}", i + 1)); + } + + parts.join("\n") + } + + /// Internal: call `uteke remember` with content and tags. + fn remember(&self, content: &str, tags: &[&str]) { + let tags_str = tags.join(","); + + let result = Command::new("uteke") + .args([ + "remember", + content, + "--namespace", + &self.namespace, + "--tags", + &tags_str, + ]) + .output(); + + match result { + Ok(output) if output.status.success() => { + tracing::debug!("Saved to Uteke: {content}"); + } + Ok(output) => { + tracing::warn!( + "Uteke remember failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(e) => { + tracing::warn!("Failed to call Uteke remember: {e}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_backend_default() { + let backend = MemoryBackend::default(); + assert!(!backend.is_available()); + assert_eq!(backend.namespace, "cora"); + } + + #[test] + fn test_memory_backend_new() { + let backend = MemoryBackend::new("custom-ns"); + assert!(!backend.is_available()); + assert_eq!(backend.namespace, "custom-ns"); + } + + #[test] + fn test_detect_without_uteke() { + let mut backend = MemoryBackend::default(); + // uteke almost certainly not on PATH in test env + backend.detect(); + // Should not panic regardless of whether uteke exists + } + + #[test] + fn test_recall_context_disabled() { + let backend = MemoryBackend::default(); + let result = backend.recall_context("test/project"); + assert!(result.is_empty()); + } + + #[test] + fn test_save_findings_disabled() { + let backend = MemoryBackend::default(); + // Should not panic + backend.save_findings( + "test/project", + 5, + "2 warning, 3 error", + &["security".to_string()], + ); + } + + #[test] + fn test_save_feedback_disabled() { + let backend = MemoryBackend::default(); + backend.save_feedback("test/project", "issue-1", "false positive"); + } + + #[test] + fn test_build_memory_context_empty() { + let backend = MemoryBackend::default(); + let ctx = backend.build_memory_context(&[]); + assert!(ctx.is_empty()); + } + + #[test] + fn test_build_memory_context_with_memories() { + let backend = MemoryBackend::default(); + let memories = vec![ + "unwrap() is common in test code".to_string(), + "SQL queries use parameterized statements".to_string(), + ]; + let ctx = backend.build_memory_context(&memories); + assert!(ctx.contains("PROJECT HISTORY")); + assert!(ctx.contains("unwrap()")); + assert!(ctx.contains("SQL queries")); + assert!(ctx.contains("1.")); + assert!(ctx.contains("2.")); + } + + #[test] + fn test_memory_level_none() { + assert_eq!(MemoryLevel::None, MemoryLevel::None); + assert_ne!(MemoryLevel::None, MemoryLevel::Context); + } + + #[test] + fn test_memory_level_context() { + assert_eq!(MemoryLevel::Context, MemoryLevel::Context); + assert_ne!(MemoryLevel::Context, MemoryLevel::Learning); + } + + #[test] + fn test_memory_level_learning() { + assert_eq!(MemoryLevel::Learning, MemoryLevel::Learning); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 5dd236b..1efd6b5 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -6,6 +6,7 @@ pub mod debt_tracker; pub mod diff_parser; pub mod language_analyzer; pub mod llm; +pub mod memory; pub mod profiles; pub mod quality_gate; pub mod review; diff --git a/src/main.rs b/src/main.rs index a64adcd..e9ab4b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use commands::{ auth, completion, config_cmd, debt, hook_cmd, init, profile, providers, review, scan, upload, }; use config::loader; +use engine::memory; use formatters::OutputFormat; /// Cora — AI Code Review CLI with BYOK (Bring Your Own Key) @@ -156,6 +157,14 @@ enum Command { /// GitHub token for upload (default: `GITHUB_TOKEN` env var) #[clap(long, env = "GITHUB_TOKEN")] token: Option, + + /// Recall project patterns from Uteke before review (requires `uteke` CLI) + #[clap(long)] + memory: bool, + + /// Save review findings to Uteke after review (implies --memory) + #[clap(long)] + learn: bool, }, /// Scan a project directory for issues @@ -403,6 +412,8 @@ async fn main() -> Result<()> { repo, ref_name, token, + memory, + learn, } => { cmd_review( &cli.global, @@ -426,6 +437,8 @@ async fn main() -> Result<()> { token, no_cache, ci, + memory, + learn, }, ) .await? @@ -581,6 +594,8 @@ struct ReviewOpts { token: Option, no_cache: bool, ci: bool, + memory: bool, + learn: bool, } /// Struct to hold scan options from CLI. @@ -649,6 +664,39 @@ async fn cmd_review(globals: &GlobalOptions, opts: ReviewOpts) -> Result { ); } + // Uteke memory integration: recall context before review + let mut memory_backend = memory::MemoryBackend::default(); + let memory_level = if opts.learn { + memory::MemoryLevel::Learning + } else if opts.memory { + memory::MemoryLevel::Context + } else { + memory::MemoryLevel::None + }; + + let memory_context = if memory_level != memory::MemoryLevel::None { + memory_backend.detect(); + if memory_backend.is_available() { + let project = repo_name_from_git().unwrap_or_else(|| "unknown".to_string()); + let memories = memory_backend.recall_context(&project); + memory_backend.build_memory_context(&memories) + } else { + if !opts.quiet { + eprintln!( + "{}", + "⚠ --memory flag set but uteke not found on PATH. Continuing without memory." + .yellow() + ); + } + String::new() + } + } else { + String::new() + }; + + // TODO: inject memory_context into review prompt (Phase 2 integration) + let _ = &memory_context; + // Execute the review (returns formatted output) let result = review::execute_review( &config, @@ -676,9 +724,43 @@ async fn cmd_review(globals: &GlobalOptions, opts: ReviewOpts) -> Result { upload_sarif_content(&sarif_content, &opts.repo, &opts.ref_name, &opts.token).await?; } + // Uteke memory integration: save findings after review + if memory_level == memory::MemoryLevel::Learning && memory_backend.is_available() { + let project = repo_name_from_git().unwrap_or_else(|| "unknown".to_string()); + memory_backend.save_findings( + &project, + 0, // TODO: extract from result + "", + &[], + ); + } + Ok(result.exit_code) } +/// Extract repo name from git remote origin URL. +fn repo_name_from_git() -> Option { + let output = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Extract owner/repo from https://github.com/owner/repo.git + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 2 { + let repo = parts.last()?.trim_end_matches(".git").to_string(); + let owner = parts.get(parts.len() - 2)?; + Some(format!("{}/{}", owner, repo)) + } else { + None + } +} + /// Upload a SARIF string to GitHub Code Scanning. #[allow(clippy::ref_option)] async fn upload_sarif_content(