diff --git a/refact-agent/engine/src/at_commands/at_knowledge.rs b/refact-agent/engine/src/at_commands/at_knowledge.rs index f7001f844..b4255f7b8 100644 --- a/refact-agent/engine/src/at_commands/at_knowledge.rs +++ b/refact-agent/engine/src/at_commands/at_knowledge.rs @@ -38,7 +38,7 @@ impl AtCommand for AtLoadKnowledge { let search_key = args.iter().map(|x| x.text.clone()).join(" "); let gcx = ccx.lock().await.global_context.clone(); - let memories = memories_search(gcx, &search_key, 5).await?; + let memories = memories_search(gcx, &search_key, 5, 0).await?; let mut seen_memids = HashSet::new(); let unique_memories: Vec<_> = memories.into_iter() .filter(|m| seen_memids.insert(m.memid.clone())) diff --git a/refact-agent/engine/src/background_tasks.rs b/refact-agent/engine/src/background_tasks.rs index a537614e7..52c287bf9 100644 --- a/refact-agent/engine/src/background_tasks.rs +++ b/refact-agent/engine/src/background_tasks.rs @@ -48,6 +48,7 @@ pub async fn start_background_tasks(gcx: Arc>, _config_di tokio::spawn(crate::integrations::sessions::remove_expired_sessions_background_task(gcx.clone())), tokio::spawn(crate::git::cleanup::git_shadow_cleanup_background_task(gcx.clone())), tokio::spawn(crate::knowledge_graph::knowledge_cleanup_background_task(gcx.clone())), + tokio::spawn(crate::trajectory_memos::trajectory_memos_background_task(gcx.clone())), ]); let ast = gcx.clone().read().await.ast_service.clone(); if let Some(ast_service) = ast { diff --git a/refact-agent/engine/src/file_filter.rs b/refact-agent/engine/src/file_filter.rs index 46539bd07..40dd4555e 100644 --- a/refact-agent/engine/src/file_filter.rs +++ b/refact-agent/engine/src/file_filter.rs @@ -6,9 +6,9 @@ use std::path::PathBuf; const LARGE_FILE_SIZE_THRESHOLD: u64 = 4096*1024; // 4Mb files const SMALL_FILE_SIZE_THRESHOLD: u64 = 5; // 5 Bytes -pub const KNOWLEDGE_FOLDER_NAME: &str = ".refact_knowledge"; +pub const KNOWLEDGE_FOLDER_NAME: &str = ".refact/knowledge"; -const ALLOWED_HIDDEN_FOLDERS: &[&str] = &[KNOWLEDGE_FOLDER_NAME]; +const ALLOWED_HIDDEN_FOLDERS: &[&str] = &[".refact"]; pub const SOURCE_FILE_EXTENSIONS: &[&str] = &[ "c", "cpp", "cc", "h", "hpp", "cs", "java", "py", "rb", "go", "rs", "swift", diff --git a/refact-agent/engine/src/http/routers/v1.rs b/refact-agent/engine/src/http/routers/v1.rs index af1df48e6..c21123165 100644 --- a/refact-agent/engine/src/http/routers/v1.rs +++ b/refact-agent/engine/src/http/routers/v1.rs @@ -75,6 +75,7 @@ mod v1_integrations; pub mod vecdb; mod workspace; mod knowledge_graph; +mod knowledge_enrichment; pub mod trajectories; pub fn make_v1_router() -> Router { diff --git a/refact-agent/engine/src/http/routers/v1/chat.rs b/refact-agent/engine/src/http/routers/v1/chat.rs index 9378b351e..05faf7860 100644 --- a/refact-agent/engine/src/http/routers/v1/chat.rs +++ b/refact-agent/engine/src/http/routers/v1/chat.rs @@ -6,7 +6,7 @@ use axum::Extension; use axum::response::Result; use hyper::{Body, Response, StatusCode}; -use crate::call_validation::{ChatContent, ChatMessage, ChatPost}; +use crate::call_validation::{ChatContent, ChatMessage, ChatPost, ChatMode}; use crate::caps::resolve_chat_model; use crate::custom_error::ScratchError; use crate::at_commands::at_commands::AtCommandsContext; @@ -17,6 +17,8 @@ use crate::integrations::docker::docker_container_manager::docker_container_chec use crate::tools::tools_description::ToolDesc; use crate::tools::tools_list::get_available_tools_by_chat_mode; +use super::knowledge_enrichment::enrich_messages_with_knowledge; + pub const CHAT_TOP_N: usize = 12; pub async fn handle_v1_chat_completions( @@ -198,10 +200,11 @@ async fn _chat( } } - // SYSTEM PROMPT WAS HERE - - - // chat_post.stream = Some(false); // for debugging 400 errors that are hard to debug with streaming (because "data: " is not present and the error message is ignored by the library) + let mut pre_stream_messages: Option> = None; + let last_is_user = messages.last().map(|m| m.role == "user").unwrap_or(false); + if chat_post.meta.chat_mode == ChatMode::AGENT && last_is_user { + pre_stream_messages = enrich_messages_with_knowledge(gcx.clone(), &mut messages).await; + } let mut scratchpad = crate::scratchpads::create_chat_scratchpad( gcx.clone(), &mut chat_post, @@ -213,19 +216,6 @@ async fn _chat( ).await.map_err(|e| ScratchError::new(StatusCode::BAD_REQUEST, e) )?; - // if !chat_post.chat_id.is_empty() { - // let cache_dir = { - // let gcx_locked = gcx.read().await; - // gcx_locked.cache_dir.clone() - // }; - // let notes_dir_path = cache_dir.join("chats"); - // let _ = std::fs::create_dir_all(¬es_dir_path); - // let notes_path = notes_dir_path.join(format!("chat{}_{}.json", - // chrono::Local::now().format("%Y%m%d"), - // chat_post.chat_id, - // )); - // let _ = std::fs::write(¬es_path, serde_json::to_string_pretty(&chat_post.messages).unwrap()); - // } let mut ccx = AtCommandsContext::new( gcx.clone(), effective_n_ctx, @@ -258,7 +248,8 @@ async fn _chat( model_rec.base.clone(), chat_post.parameters.clone(), chat_post.only_deterministic_messages, - meta + meta, + pre_stream_messages, ).await } } diff --git a/refact-agent/engine/src/http/routers/v1/code_completion.rs b/refact-agent/engine/src/http/routers/v1/code_completion.rs index af6aace0f..2a5e5b336 100644 --- a/refact-agent/engine/src/http/routers/v1/code_completion.rs +++ b/refact-agent/engine/src/http/routers/v1/code_completion.rs @@ -79,7 +79,7 @@ pub async fn handle_v1_code_completion( if !code_completion_post.stream { crate::restream::scratchpad_interaction_not_stream(ccx.clone(), &mut scratchpad, "completion".to_string(), &model_rec.base, &mut code_completion_post.parameters, false, None).await } else { - crate::restream::scratchpad_interaction_stream(ccx.clone(), scratchpad, "completion-stream".to_string(), model_rec.base.clone(), code_completion_post.parameters.clone(), false, None).await + crate::restream::scratchpad_interaction_stream(ccx.clone(), scratchpad, "completion-stream".to_string(), model_rec.base.clone(), code_completion_post.parameters.clone(), false, None, None).await } } diff --git a/refact-agent/engine/src/http/routers/v1/knowledge_enrichment.rs b/refact-agent/engine/src/http/routers/v1/knowledge_enrichment.rs new file mode 100644 index 000000000..4885bbc92 --- /dev/null +++ b/refact-agent/engine/src/http/routers/v1/knowledge_enrichment.rs @@ -0,0 +1,266 @@ +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock as ARwLock; +use regex::Regex; + +use crate::call_validation::{ChatContent, ChatMessage, ContextFile}; +use crate::global_context::GlobalContext; +use crate::memories::memories_search; + +const KNOWLEDGE_TOP_N: usize = 3; +const TRAJECTORY_TOP_N: usize = 2; +const KNOWLEDGE_SCORE_THRESHOLD: f32 = 0.75; +const KNOWLEDGE_ENRICHMENT_MARKER: &str = "knowledge_enrichment"; +const MAX_QUERY_LENGTH: usize = 2000; + +pub async fn enrich_messages_with_knowledge( + gcx: Arc>, + messages: &mut Vec, +) -> Option> { + let last_user_idx = messages.iter().rposition(|m| m.role == "user")?; + let query_raw = messages[last_user_idx].content.content_text_only(); + + if has_knowledge_enrichment_near(messages, last_user_idx) { + return None; + } + + let query_normalized = normalize_query(&query_raw); + + if !should_enrich(messages, &query_raw, &query_normalized) { + return None; + } + + let existing_paths = get_existing_context_file_paths(messages); + + if let Some((knowledge_context, ui_context)) = create_knowledge_context(gcx, &query_normalized, &existing_paths).await { + messages.insert(last_user_idx, knowledge_context); + tracing::info!("Injected knowledge context before user message at position {}", last_user_idx); + return Some(vec![ui_context]); + } + + None +} + +fn normalize_query(query: &str) -> String { + let code_fence_re = Regex::new(r"```[\s\S]*?```").unwrap(); + let normalized = code_fence_re.replace_all(query, " [code] ").to_string(); + let normalized = normalized.trim(); + if normalized.len() > MAX_QUERY_LENGTH { + normalized.chars().take(MAX_QUERY_LENGTH).collect() + } else { + normalized.to_string() + } +} + +fn should_enrich(messages: &[ChatMessage], query_raw: &str, query_normalized: &str) -> bool { + let trimmed = query_raw.trim(); + + // Guardrail: empty query + if trimmed.is_empty() { + return false; + } + + // Guardrail: command-like messages + if trimmed.starts_with('@') || trimmed.starts_with('/') { + return false; + } + + // Rule 1: Always enrich first user message + let user_message_count = messages.iter().filter(|m| m.role == "user").count(); + if user_message_count == 1 { + tracing::info!("Knowledge enrichment: first user message"); + return true; + } + + // Rule 2: Signal-based for subsequent messages + let strong = count_strong_signals(query_raw); + let weak = count_weak_signals(query_raw, query_normalized); + + if strong >= 1 { + tracing::info!("Knowledge enrichment: {} strong signal(s)", strong); + return true; + } + + if weak >= 2 && query_normalized.len() >= 20 { + tracing::info!("Knowledge enrichment: {} weak signal(s)", weak); + return true; + } + + false +} + +fn count_strong_signals(query: &str) -> usize { + let query_lower = query.to_lowercase(); + let mut count = 0; + + // Error/debug keywords + let error_keywords = [ + "error", "panic", "exception", "traceback", "stack trace", + "segfault", "failed", "unable to", "cannot", "doesn't work", + "does not work", "broken", "bug", "crash" + ]; + if error_keywords.iter().any(|kw| query_lower.contains(kw)) { + count += 1; + } + + // File references + let file_extensions = [".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java", ".cpp", ".c", ".h"]; + let config_files = ["cargo.toml", "package.json", "tsconfig", "pyproject", ".yaml", ".yml", ".toml"]; + if file_extensions.iter().any(|ext| query_lower.contains(ext)) + || config_files.iter().any(|f| query_lower.contains(f)) { + count += 1; + } + + // Path-like pattern + let path_re = Regex::new(r"\b[\w-]+/[\w-]+(?:/[\w.-]+)*\b").unwrap(); + if path_re.is_match(query) { + count += 1; + } + + // Code symbols + if query.contains("::") || query.contains("->") || query.contains("`") { + count += 1; + } + + // Explicit retrieval intent + let retrieval_phrases = [ + "search", "find", "where is", "which file", "look up", + "in this repo", "in the codebase", "in the project" + ]; + if retrieval_phrases.iter().any(|p| query_lower.contains(p)) { + count += 1; + } + + count +} + +fn count_weak_signals(query_raw: &str, query_normalized: &str) -> usize { + let mut count = 0; + + // Has question mark + if query_raw.contains('?') { + count += 1; + } + + // Starts with question word + let query_lower = query_raw.trim().to_lowercase(); + let question_starters = ["how", "why", "what", "where", "when", "can", "should", "could", "would", "is there", "are there"]; + if question_starters.iter().any(|s| query_lower.starts_with(s)) { + count += 1; + } + + // Long enough natural language (after stripping code) + if query_normalized.len() >= 80 { + count += 1; + } + + count +} + +async fn create_knowledge_context( + gcx: Arc>, + query_text: &str, + existing_paths: &HashSet, +) -> Option<(ChatMessage, serde_json::Value)> { + + let memories = memories_search(gcx.clone(), &query_text, KNOWLEDGE_TOP_N, TRAJECTORY_TOP_N).await.ok()?; + + let high_score_memories: Vec<_> = memories + .into_iter() + .filter(|m| m.score.unwrap_or(0.0) >= KNOWLEDGE_SCORE_THRESHOLD) + .filter(|m| { + if let Some(path) = &m.file_path { + !existing_paths.contains(&path.to_string_lossy().to_string()) + } else { + true + } + }) + .collect(); + + if high_score_memories.is_empty() { + return None; + } + + tracing::info!("Knowledge enrichment: {} memories passed threshold {}", high_score_memories.len(), KNOWLEDGE_SCORE_THRESHOLD); + + let context_files_for_llm: Vec = high_score_memories + .iter() + .filter_map(|memo| { + let file_path = memo.file_path.as_ref()?; + let (line1, line2) = memo.line_range.unwrap_or((1, 50)); + Some(ContextFile { + file_name: file_path.to_string_lossy().to_string(), + file_content: String::new(), + line1: line1 as usize, + line2: line2 as usize, + symbols: vec![], + gradient_type: -1, + usefulness: 80.0 + (memo.score.unwrap_or(0.75) * 20.0), + skip_pp: false, + }) + }) + .collect(); + + if context_files_for_llm.is_empty() { + return None; + } + + let context_files_for_ui: Vec = high_score_memories + .iter() + .filter_map(|memo| { + let file_path = memo.file_path.as_ref()?; + let (line1, line2) = memo.line_range.unwrap_or((1, 50)); + Some(serde_json::json!({ + "file_name": file_path.to_string_lossy().to_string(), + "file_content": memo.content.clone(), + "line1": line1, + "line2": line2, + })) + }) + .collect(); + + let content = serde_json::to_string(&context_files_for_llm).ok()?; + let chat_message = ChatMessage { + role: "context_file".to_string(), + content: ChatContent::SimpleText(content), + tool_call_id: KNOWLEDGE_ENRICHMENT_MARKER.to_string(), + ..Default::default() + }; + + let ui_content_str = serde_json::to_string(&context_files_for_ui).unwrap_or_default(); + let ui_message = serde_json::json!({ + "role": "context_file", + "content": ui_content_str, + "tool_call_id": KNOWLEDGE_ENRICHMENT_MARKER, + }); + + Some((chat_message, ui_message)) +} + +fn has_knowledge_enrichment_near(messages: &[ChatMessage], user_idx: usize) -> bool { + let search_start = user_idx.saturating_sub(2); + let search_end = (user_idx + 2).min(messages.len()); + + for i in search_start..search_end { + if messages[i].role == "context_file" && messages[i].tool_call_id == KNOWLEDGE_ENRICHMENT_MARKER { + tracing::info!("Skipping enrichment - already enriched at position {}", i); + return true; + } + } + false +} + +fn get_existing_context_file_paths(messages: &[ChatMessage]) -> HashSet { + let mut paths = HashSet::new(); + for msg in messages { + if msg.role == "context_file" { + let content = msg.content.content_text_only(); + if let Ok(files) = serde_json::from_str::>(&content) { + for file in files { + paths.insert(file.file_name.clone()); + } + } + } + } + paths +} diff --git a/refact-agent/engine/src/knowledge_graph/kg_builder.rs b/refact-agent/engine/src/knowledge_graph/kg_builder.rs index 2b6b8758b..b91a974e0 100644 --- a/refact-agent/engine/src/knowledge_graph/kg_builder.rs +++ b/refact-agent/engine/src/knowledge_graph/kg_builder.rs @@ -37,7 +37,7 @@ pub async fn build_knowledge_graph(gcx: Arc>) -> Knowledg .collect(); if knowledge_dirs.is_empty() { - info!("knowledge_graph: no .refact_knowledge directories found"); + info!("knowledge_graph: no .refact/knowledge directories found"); return graph; } diff --git a/refact-agent/engine/src/main.rs b/refact-agent/engine/src/main.rs index 70fdab5d4..33cf98dda 100644 --- a/refact-agent/engine/src/main.rs +++ b/refact-agent/engine/src/main.rs @@ -66,6 +66,7 @@ mod agentic; mod memories; mod files_correction_cache; mod knowledge_graph; +mod trajectory_memos; pub mod constants; #[tokio::main] diff --git a/refact-agent/engine/src/memories.rs b/refact-agent/engine/src/memories.rs index d440f9bfe..e8cde9426 100644 --- a/refact-agent/engine/src/memories.rs +++ b/refact-agent/engine/src/memories.rs @@ -30,6 +30,7 @@ pub struct MemoRecord { pub title: Option, pub created: Option, pub kind: Option, + pub score: Option, // VecDB similarity score (lower distance = higher relevance) } fn generate_slug(content: &str) -> String { @@ -129,65 +130,183 @@ pub async fn memories_add( pub async fn memories_search( gcx: Arc>, query: &str, - top_n: usize, + top_n_memories: usize, + top_n_trajectories: usize, ) -> Result, String> { let knowledge_dir = get_knowledge_dir(gcx.clone()).await?; - let has_vecdb = gcx.read().await.vec_db.lock().await.is_some(); - if has_vecdb { - let vecdb_lock = gcx.read().await.vec_db.clone(); - let vecdb_guard = vecdb_lock.lock().await; - let vecdb = vecdb_guard.as_ref().unwrap(); - let search_result = vecdb.vecdb_search(query.to_string(), top_n * 3, None).await - .map_err(|e| format!("VecDB search failed: {}", e))?; - - let mut records = Vec::new(); - for rec in search_result.results { - let path_str = rec.file_path.to_string_lossy().to_string(); - if !path_str.contains(KNOWLEDGE_FOLDER_NAME) { - continue; - } + let vecdb_arc = { + let gcx_read = gcx.read().await; + gcx_read.vec_db.clone() + }; - let text = match get_file_text_from_memory_or_disk(gcx.clone(), &rec.file_path).await { - Ok(t) => t, - Err(_) => continue, - }; + let vecdb_guard = vecdb_arc.lock().await; + if vecdb_guard.is_none() { + drop(vecdb_guard); + return memories_search_fallback(gcx, query, top_n_memories, &knowledge_dir).await; + } - let (frontmatter, _content_start) = KnowledgeFrontmatter::parse(&text); + let vecdb = vecdb_guard.as_ref().unwrap(); + let search_result = vecdb.vecdb_search(query.to_string(), (top_n_memories + top_n_trajectories) * 5, None).await + .map_err(|e| format!("VecDB search failed: {}", e))?; + drop(vecdb_guard); - if frontmatter.is_archived() { - continue; - } + use std::collections::HashMap; + + struct KnowledgeMatch { best_score: f32 } + struct TrajectoryMatch { + best_score: f32, + matched_ranges: Vec<(u64, u64)>, + } + + let mut knowledge_matches: HashMap = HashMap::new(); + let mut trajectory_matches: HashMap = HashMap::new(); + + for rec in search_result.results.iter() { + let path_str = rec.file_path.to_string_lossy().to_string(); + let score = 1.0 - (rec.distance / 2.0).min(1.0); + + if path_str.contains(KNOWLEDGE_FOLDER_NAME) { + knowledge_matches + .entry(rec.file_path.clone()) + .and_modify(|m| { if score > m.best_score { m.best_score = score; } }) + .or_insert(KnowledgeMatch { best_score: score }); + } else if path_str.contains(".refact/trajectories/") && path_str.ends_with(".json") { + trajectory_matches + .entry(rec.file_path.clone()) + .and_modify(|m| { + if score > m.best_score { m.best_score = score; } + m.matched_ranges.push((rec.start_line, rec.end_line)); + }) + .or_insert(TrajectoryMatch { + best_score: score, + matched_ranges: vec![(rec.start_line, rec.end_line)], + }); + } + } + + let mut records = Vec::new(); + + // Process knowledge files (whole content) + let mut sorted_knowledge: Vec<_> = knowledge_matches.into_iter().collect(); + sorted_knowledge.sort_by(|a, b| b.1.best_score.partial_cmp(&a.1.best_score).unwrap_or(std::cmp::Ordering::Equal)); + + for (file_path, file_match) in sorted_knowledge.into_iter().take(top_n_memories) { + let text = match get_file_text_from_memory_or_disk(gcx.clone(), &file_path).await { + Ok(t) => t, + Err(_) => continue, + }; + + let (frontmatter, content_start) = KnowledgeFrontmatter::parse(&text); + if frontmatter.is_archived() { + continue; + } + + let content = text[content_start..].trim().to_string(); + let line_count = content.lines().count(); + let id = frontmatter.id.clone().unwrap_or_else(|| file_path.to_string_lossy().to_string()); + + records.push(MemoRecord { + memid: id, + tags: frontmatter.tags, + content, + file_path: Some(file_path), + line_range: Some((1, line_count as u64)), + title: frontmatter.title, + created: frontmatter.created, + kind: frontmatter.kind, + score: Some(file_match.best_score), + }); + } + + // Process trajectories (matched parts only) + let mut sorted_trajectories: Vec<_> = trajectory_matches.into_iter().collect(); + sorted_trajectories.sort_by(|a, b| b.1.best_score.partial_cmp(&a.1.best_score).unwrap_or(std::cmp::Ordering::Equal)); + + for (file_path, traj_match) in sorted_trajectories.into_iter().take(top_n_trajectories) { + let text = match get_file_text_from_memory_or_disk(gcx.clone(), &file_path).await { + Ok(t) => t, + Err(_) => continue, + }; + + let traj_json: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + let traj_id = file_path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let traj_title = traj_json.get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled") + .to_string(); + + let messages = match traj_json.get("messages").and_then(|v| v.as_array()) { + Some(m) => m, + None => continue, + }; - let lines: Vec<&str> = text.lines().collect(); - let start = (rec.start_line as usize).min(lines.len().saturating_sub(1)); - let end = (rec.end_line as usize).min(lines.len().saturating_sub(1)); - let snippet = lines[start..=end].join("\n"); - - let id = frontmatter.id.clone().unwrap_or_else(|| path_str.clone()); - - records.push(MemoRecord { - memid: format!("{}:{}-{}", id, rec.start_line, rec.end_line), - tags: frontmatter.tags, - content: snippet, - file_path: Some(rec.file_path.clone()), - line_range: Some((rec.start_line, rec.end_line)), - title: frontmatter.title, - created: frontmatter.created, - kind: frontmatter.kind, - }); - - if records.len() >= top_n { - break; + // Extract matched message content + let mut matched_content = Vec::new(); + for (start, end) in &traj_match.matched_ranges { + let start_idx = *start as usize; + let end_idx = (*end as usize).min(messages.len().saturating_sub(1)); + + for idx in start_idx..=end_idx { + if let Some(msg) = messages.get(idx) { + let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown"); + let content = msg.get("content") + .map(|v| { + if let Some(s) = v.as_str() { + s.chars().take(500).collect::() + } else { + v.to_string().chars().take(500).collect() + } + }) + .unwrap_or_default(); + + if !content.is_empty() && role != "system" && role != "context_file" { + matched_content.push(format!("[msg {}] {}: {}", idx, role, content)); + } + } } } - if !records.is_empty() { - return Ok(records); + if matched_content.is_empty() { + continue; } + + let content = format!( + "Trajectory: {} ({})\n\n{}", + traj_title, + traj_id, + matched_content.join("\n\n") + ); + + records.push(MemoRecord { + memid: traj_id.clone(), + tags: vec!["trajectory".to_string()], + content, + file_path: Some(file_path), + line_range: None, + title: Some(traj_title), + created: None, + kind: Some("trajectory".to_string()), + score: Some(traj_match.best_score), + }); + } + + tracing::info!("memories_search: found {} knowledge + {} trajectories", + records.iter().filter(|r| r.kind.as_deref() != Some("trajectory")).count(), + records.iter().filter(|r| r.kind.as_deref() == Some("trajectory")).count() + ); + + if !records.is_empty() { + return Ok(records); } - memories_search_fallback(gcx, query, top_n, &knowledge_dir).await + memories_search_fallback(gcx, query, top_n_memories, &knowledge_dir).await } async fn memories_search_fallback( @@ -236,6 +355,9 @@ async fn memories_search_fallback( let id = frontmatter.id.clone().unwrap_or_else(|| path.to_string_lossy().to_string()); let content_preview: String = text[content_start..].chars().take(500).collect(); + // Normalize keyword score to 0-1 range (assuming max ~10 word matches) + let normalized_score = (score as f32 / 10.0).min(1.0); + scored_results.push((score, MemoRecord { memid: id, tags: frontmatter.tags, @@ -245,6 +367,7 @@ async fn memories_search_fallback( title: frontmatter.title, created: frontmatter.created, kind: frontmatter.kind, + score: Some(normalized_score), })); } diff --git a/refact-agent/engine/src/restream.rs b/refact-agent/engine/src/restream.rs index 3b31c0caf..d8cda4c24 100644 --- a/refact-agent/engine/src/restream.rs +++ b/refact-agent/engine/src/restream.rs @@ -231,7 +231,8 @@ pub async fn scratchpad_interaction_stream( mut model_rec: BaseModelRecord, parameters: SamplingParameters, only_deterministic_messages: bool, - meta: Option + meta: Option, + pre_stream_messages: Option>, ) -> Result, ScratchError> { let t1: std::time::SystemTime = std::time::SystemTime::now(); let evstream = stream! { @@ -300,6 +301,15 @@ pub async fn scratchpad_interaction_stream( } info!("scratchpad_interaction_stream prompt {:?}", t0.elapsed()); + if let Some(ref messages) = pre_stream_messages { + for msg in messages { + let mut msg_with_compression = msg.clone(); + msg_with_compression["compression_strength"] = crate::forward_to_openai_endpoint::try_get_compression_from_prompt(&prompt); + let value_str = format!("data: {}\n\n", serde_json::to_string(&msg_with_compression).unwrap()); + yield Result::<_, String>::Ok(value_str); + } + } + let _ = slowdown_arc.acquire().await; loop { let value_maybe = my_scratchpad.response_spontaneous(); diff --git a/refact-agent/engine/src/tools/mod.rs b/refact-agent/engine/src/tools/mod.rs index 99df1e096..e93387901 100644 --- a/refact-agent/engine/src/tools/mod.rs +++ b/refact-agent/engine/src/tools/mod.rs @@ -16,6 +16,7 @@ mod tool_deep_research; mod tool_subagent; mod tool_search; mod tool_knowledge; +mod tool_trajectory_context; mod tool_create_knowledge; mod tool_create_memory_bank; diff --git a/refact-agent/engine/src/tools/tool_create_knowledge.rs b/refact-agent/engine/src/tools/tool_create_knowledge.rs index ad4085f0b..8ed58b39f 100644 --- a/refact-agent/engine/src/tools/tool_create_knowledge.rs +++ b/refact-agent/engine/src/tools/tool_create_knowledge.rs @@ -28,7 +28,7 @@ impl Tool for ToolCreateKnowledge { }, agentic: true, experimental: false, - description: "Creates a new knowledge entry. Uses AI to enrich metadata and check for outdated documents.".to_string(), + description: "Creates a new knowledge entry. Uses AI to enrich metadata and check for outdated documents. Use it if you need to remember something.".to_string(), parameters: vec![ ToolParam { name: "content".to_string(), diff --git a/refact-agent/engine/src/tools/tool_deep_research.rs b/refact-agent/engine/src/tools/tool_deep_research.rs index 0a12842d0..ec719b906 100644 --- a/refact-agent/engine/src/tools/tool_deep_research.rs +++ b/refact-agent/engine/src/tools/tool_deep_research.rs @@ -189,7 +189,7 @@ impl Tool for ToolDeepResearch { &log_prefix, ).await?; - let final_message = format!("# Deep Research Report\n\n{}", research_result.content.content_text_only()); + let research_content = format!("# Deep Research Report\n\n{}", research_result.content.content_text_only()); tracing::info!("Deep research completed"); let title = if research_query.len() > 80 { @@ -203,11 +203,17 @@ impl Tool for ToolDeepResearch { base_kind: "research".to_string(), base_title: Some(title), }; - if let Err(e) = memories_add_enriched(ccx.clone(), &final_message, enrichment_params).await { - tracing::warn!("Failed to create enriched memory from deep research: {}", e); - } else { - tracing::info!("Created enriched memory from deep research"); - } + let memory_note = match memories_add_enriched(ccx.clone(), &research_content, enrichment_params).await { + Ok(path) => { + tracing::info!("Created enriched memory from deep research: {:?}", path); + format!("\n\n---\nπŸ“ **This report has been saved to the knowledge base:** `{}`", path.display()) + }, + Err(e) => { + tracing::warn!("Failed to create enriched memory from deep research: {}", e); + String::new() + } + }; + let final_message = format!("{}{}", research_content, memory_note); let mut results = vec![]; results.push(ContextEnum::ChatMessage(ChatMessage { diff --git a/refact-agent/engine/src/tools/tool_knowledge.rs b/refact-agent/engine/src/tools/tool_knowledge.rs index badeb0f06..0abdc3c0c 100644 --- a/refact-agent/engine/src/tools/tool_knowledge.rs +++ b/refact-agent/engine/src/tools/tool_knowledge.rs @@ -30,7 +30,7 @@ impl Tool for ToolGetKnowledge { }, agentic: true, experimental: false, - description: "Searches project knowledge base for relevant information. Uses semantic search and knowledge graph expansion.".to_string(), + description: "Searches project knowledge base for relevant information. Uses semantic search and knowledge graph expansion. Also searches past chat trajectories for relevant patterns and solutions.".to_string(), parameters: vec![ ToolParam { name: "search_key".to_string(), @@ -58,7 +58,7 @@ impl Tool for ToolGetKnowledge { None => return Err("argument `search_key` is missing".to_string()), }; - let memories = memories_search(gcx.clone(), &search_key, 5).await?; + let memories = memories_search(gcx.clone(), &search_key, 5, 0).await?; let mut seen_memids = HashSet::new(); let mut unique_memories: Vec<_> = memories.into_iter() @@ -90,6 +90,7 @@ impl Tool for ToolGetKnowledge { title: doc.frontmatter.title.clone(), created: doc.frontmatter.created.clone(), kind: doc.frontmatter.kind.clone(), + score: None, // KG expansion doesn't have scores }); } } diff --git a/refact-agent/engine/src/tools/tool_strategic_planning.rs b/refact-agent/engine/src/tools/tool_strategic_planning.rs index c1aa39b19..9806a9f4c 100644 --- a/refact-agent/engine/src/tools/tool_strategic_planning.rs +++ b/refact-agent/engine/src/tools/tool_strategic_planning.rs @@ -317,8 +317,8 @@ impl Tool for ToolStrategicPlanning { cancel_token.cancel(); let (_, initial_solution) = result?; - let final_message = format!("# Solution\n{}", initial_solution.content.content_text_only()); - tracing::info!("strategic planning response (combined):\n{}", final_message); + let solution_content = format!("# Solution\n{}", initial_solution.content.content_text_only()); + tracing::info!("strategic planning response (combined):\n{}", solution_content); let filenames: Vec = important_paths.iter() .map(|p| p.to_string_lossy().to_string()) @@ -329,11 +329,17 @@ impl Tool for ToolStrategicPlanning { base_kind: "decision".to_string(), base_title: Some("Strategic Plan".to_string()), }; - if let Err(e) = memories_add_enriched(ccx.clone(), &final_message, enrichment_params).await { - tracing::warn!("Failed to create enriched memory from strategic planning: {}", e); - } else { - tracing::info!("Created enriched memory from strategic planning"); - } + let memory_note = match memories_add_enriched(ccx.clone(), &solution_content, enrichment_params).await { + Ok(path) => { + tracing::info!("Created enriched memory from strategic planning: {:?}", path); + format!("\n\n---\nπŸ“ **This plan has been saved to the knowledge base:** `{}`", path.display()) + }, + Err(e) => { + tracing::warn!("Failed to create enriched memory from strategic planning: {}", e); + String::new() + } + }; + let final_message = format!("{}{}", solution_content, memory_note); let mut results = vec![]; results.push(ContextEnum::ChatMessage(ChatMessage { diff --git a/refact-agent/engine/src/tools/tool_subagent.rs b/refact-agent/engine/src/tools/tool_subagent.rs index 71c88c790..493824db3 100644 --- a/refact-agent/engine/src/tools/tool_subagent.rs +++ b/refact-agent/engine/src/tools/tool_subagent.rs @@ -8,6 +8,7 @@ use crate::subchat::subchat; use crate::tools::tools_description::{Tool, ToolDesc, ToolParam, ToolSource, ToolSourceType}; use crate::call_validation::{ChatMessage, ChatContent, ChatUsage, ContextEnum, SubchatParameters}; use crate::at_commands::at_commands::AtCommandsContext; +use crate::memories::{memories_add_enriched, EnrichmentParams}; pub struct ToolSubagent { pub config_path: String, @@ -208,7 +209,7 @@ impl Tool for ToolSubagent { &log_prefix, ).await?; - let final_message = format!( + let report_content = format!( "# Subagent Report\n\n**Task:** {}\n\n**Expected Result:** {}\n\n## Result\n{}", task, expected_result, @@ -216,6 +217,29 @@ impl Tool for ToolSubagent { ); tracing::info!("Subagent completed task"); + let title = if task.len() > 80 { + format!("{}...", &task[..80]) + } else { + task.clone() + }; + let enrichment_params = EnrichmentParams { + base_tags: vec!["subagent".to_string(), "delegation".to_string()], + base_filenames: vec![], + base_kind: "subagent".to_string(), + base_title: Some(title), + }; + let memory_note = match memories_add_enriched(ccx.clone(), &report_content, enrichment_params).await { + Ok(path) => { + tracing::info!("Created enriched memory from subagent: {:?}", path); + format!("\n\n---\nπŸ“ **This report has been saved to the knowledge base:** `{}`", path.display()) + }, + Err(e) => { + tracing::warn!("Failed to create enriched memory from subagent: {}", e); + String::new() + } + }; + let final_message = format!("{}{}", report_content, memory_note); + let mut results = vec![]; results.push(ContextEnum::ChatMessage(ChatMessage { role: "tool".to_string(), diff --git a/refact-agent/engine/src/tools/tool_trajectory_context.rs b/refact-agent/engine/src/tools/tool_trajectory_context.rs new file mode 100644 index 000000000..dcfb180fe --- /dev/null +++ b/refact-agent/engine/src/tools/tool_trajectory_context.rs @@ -0,0 +1,170 @@ +use std::collections::HashMap; +use std::sync::Arc; +use async_trait::async_trait; +use serde_json::Value; +use tokio::sync::Mutex as AMutex; +use tokio::fs; + +use crate::at_commands::at_commands::AtCommandsContext; +use crate::call_validation::{ChatMessage, ChatContent, ContextEnum}; +use crate::tools::tools_description::{Tool, ToolDesc, ToolParam, ToolSource, ToolSourceType}; +use crate::files_correction::get_project_dirs; + +pub struct ToolTrajectoryContext { + pub config_path: String, +} + +#[async_trait] +impl Tool for ToolTrajectoryContext { + fn as_any(&self) -> &dyn std::any::Any { self } + + fn tool_description(&self) -> ToolDesc { + ToolDesc { + name: "get_trajectory_context".to_string(), + display_name: "Get Trajectory Context".to_string(), + source: ToolSource { + source_type: ToolSourceType::Builtin, + config_path: self.config_path.clone(), + }, + agentic: false, + experimental: false, + description: "Get more context from a specific trajectory around given message indices.".to_string(), + parameters: vec![ + ToolParam { + name: "trajectory_id".to_string(), + param_type: "string".to_string(), + description: "The trajectory ID to retrieve context from.".to_string(), + }, + ToolParam { + name: "message_start".to_string(), + param_type: "string".to_string(), + description: "Starting message index.".to_string(), + }, + ToolParam { + name: "message_end".to_string(), + param_type: "string".to_string(), + description: "Ending message index.".to_string(), + }, + ToolParam { + name: "expand_by".to_string(), + param_type: "string".to_string(), + description: "Number of messages to include before/after (default: 3).".to_string(), + }, + ], + parameters_required: vec!["trajectory_id".to_string(), "message_start".to_string(), "message_end".to_string()], + } + } + + async fn tool_execute( + &mut self, + ccx: Arc>, + tool_call_id: &String, + args: &HashMap, + ) -> Result<(bool, Vec), String> { + let trajectory_id = match args.get("trajectory_id") { + Some(Value::String(s)) => s.clone(), + _ => return Err("Missing argument `trajectory_id`".to_string()) + }; + + let msg_start: usize = match args.get("message_start") { + Some(Value::String(s)) => s.parse().map_err(|_| "Invalid message_start")?, + Some(Value::Number(n)) => n.as_u64().ok_or("Invalid message_start")? as usize, + _ => return Err("Missing argument `message_start`".to_string()) + }; + + let msg_end: usize = match args.get("message_end") { + Some(Value::String(s)) => s.parse().map_err(|_| "Invalid message_end")?, + Some(Value::Number(n)) => n.as_u64().ok_or("Invalid message_end")? as usize, + _ => return Err("Missing argument `message_end`".to_string()) + }; + + let expand_by: usize = match args.get("expand_by") { + Some(Value::String(s)) => s.parse().unwrap_or(3), + Some(Value::Number(n)) => n.as_u64().unwrap_or(3) as usize, + _ => 3, + }; + + let gcx = ccx.lock().await.global_context.clone(); + let project_dirs = get_project_dirs(gcx.clone()).await; + let workspace_root = project_dirs.first().ok_or("No workspace folder")?; + let traj_path = workspace_root.join(".refact/trajectories").join(format!("{}.json", trajectory_id)); + + if !traj_path.exists() { + return Err(format!("Trajectory not found: {}", trajectory_id)); + } + + let content = fs::read_to_string(&traj_path).await + .map_err(|e| format!("Failed to read trajectory: {}", e))?; + + let trajectory: Value = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse trajectory: {}", e))?; + + let messages = trajectory.get("messages") + .and_then(|v| v.as_array()) + .ok_or("No messages in trajectory")?; + + let title = trajectory.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled"); + let actual_start = msg_start.saturating_sub(expand_by); + let actual_end = (msg_end + expand_by).min(messages.len().saturating_sub(1)); + + let mut output = format!("Trajectory: {} ({})\nMessages {}-{} (expanded from {}-{}):\n\n", + trajectory_id, title, actual_start, actual_end, msg_start, msg_end); + + for (i, msg) in messages.iter().enumerate() { + if i < actual_start || i > actual_end { + continue; + } + + let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown"); + if role == "context_file" || role == "cd_instruction" || role == "system" { + continue; + } + + let content_text = extract_content(msg); + if content_text.trim().is_empty() { + continue; + } + + let marker = if i >= msg_start && i <= msg_end { ">>>" } else { " " }; + output.push_str(&format!("{} [{}] {}:\n{}\n\n", marker, i, role.to_uppercase(), content_text)); + } + + Ok((false, vec![ContextEnum::ChatMessage(ChatMessage { + role: "tool".to_string(), + content: ChatContent::SimpleText(output), + tool_calls: None, + tool_call_id: tool_call_id.clone(), + ..Default::default() + })])) + } + + fn tool_depends_on(&self) -> Vec { + vec![] + } +} + +fn extract_content(msg: &Value) -> String { + if let Some(content) = msg.get("content").and_then(|c| c.as_str()) { + return content.to_string(); + } + + if let Some(content_arr) = msg.get("content").and_then(|c| c.as_array()) { + return content_arr.iter() + .filter_map(|item| { + item.get("text").and_then(|t| t.as_str()) + .or_else(|| item.get("m_content").and_then(|t| t.as_str())) + }) + .collect::>() + .join("\n"); + } + + if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) { + return tool_calls.iter() + .filter_map(|tc| tc.get("function").and_then(|f| f.get("name")).and_then(|n| n.as_str())) + .map(|s| format!("[tool: {}]", s)) + .collect::>() + .join(" "); + } + + String::new() +} diff --git a/refact-agent/engine/src/tools/tools_list.rs b/refact-agent/engine/src/tools/tools_list.rs index 40ffa18aa..3854eaa1c 100644 --- a/refact-agent/engine/src/tools/tools_list.rs +++ b/refact-agent/engine/src/tools/tools_list.rs @@ -112,6 +112,7 @@ async fn get_builtin_tools( Box::new(crate::tools::tool_knowledge::ToolGetKnowledge{config_path: config_path.clone()}), Box::new(crate::tools::tool_create_knowledge::ToolCreateKnowledge{config_path: config_path.clone()}), Box::new(crate::tools::tool_create_memory_bank::ToolCreateMemoryBank{config_path: config_path.clone()}), + Box::new(crate::tools::tool_trajectory_context::ToolTrajectoryContext{config_path: config_path.clone()}), ]; let mut tool_groups = vec![ diff --git a/refact-agent/engine/src/trajectory_memos.rs b/refact-agent/engine/src/trajectory_memos.rs new file mode 100644 index 000000000..1b230aa55 --- /dev/null +++ b/refact-agent/engine/src/trajectory_memos.rs @@ -0,0 +1,323 @@ +use std::sync::Arc; +use chrono::{DateTime, Utc, Duration}; +use serde_json::Value; +use tokio::sync::RwLock as ARwLock; +use tokio::sync::Mutex as AMutex; +use tokio::fs; +use tracing::{info, warn}; +use walkdir::WalkDir; + +use crate::at_commands::at_commands::AtCommandsContext; +use crate::call_validation::{ChatContent, ChatMessage}; +use crate::files_correction::get_project_dirs; +use crate::global_context::{GlobalContext, try_load_caps_quickly_if_not_present}; +use crate::memories::{memories_add, create_frontmatter}; +use crate::subchat::subchat_single; + +const ABANDONED_THRESHOLD_HOURS: i64 = 2; +const CHECK_INTERVAL_SECS: u64 = 300; +const TRAJECTORIES_FOLDER: &str = ".refact/trajectories"; + +const EXTRACTION_PROMPT: &str = r#"Analyze this conversation and provide: + +1. FIRST LINE: A JSON with overview and title: +{"overview": "<2-3 sentence summary of what was accomplished>", "title": "<2-4 word descriptive title>"} + +2. FOLLOWING LINES: Extract separate, useful memory items (3-10 max): +{"type": "", "content": ""} + +Types for memory items: +- pattern: Reusable code patterns or approaches discovered +- preference: User preferences about coding style, communication, tools +- lesson: What went wrong and how it was fixed +- decision: Important architectural or design decisions made +- insight: General useful observations about the codebase or project + +Rules: +- Overview should capture the main goal and outcome +- Title should be descriptive and specific (e.g., "Fix Auth Middleware" not "Bug Fix") +- Each memory item should be self-contained and actionable +- Keep content concise (1-3 sentences max) +- Only extract genuinely useful, reusable knowledge +- Skip trivial details or conversation noise + +Example output: +{"overview": "Implemented a custom VecDB splitter for trajectory files to enable semantic search over past conversations. Added two new tools for searching and retrieving trajectory context.", "title": "Trajectory Search Tools"} +{"type": "pattern", "content": "When implementing async file operations in this project, use tokio::fs instead of std::fs to avoid blocking."} +{"type": "preference", "content": "User prefers concise code without excessive comments."} +"#; + +pub async fn trajectory_memos_background_task(gcx: Arc>) { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(CHECK_INTERVAL_SECS)).await; + + if let Err(e) = process_abandoned_trajectories(gcx.clone()).await { + warn!("trajectory_memos: error processing trajectories: {}", e); + } + } +} + +async fn process_abandoned_trajectories(gcx: Arc>) -> Result<(), String> { + let project_dirs = get_project_dirs(gcx.clone()).await; + let workspace_root = match project_dirs.first() { + Some(root) => root.clone(), + None => return Ok(()), + }; + + let trajectories_dir = workspace_root.join(TRAJECTORIES_FOLDER); + if !trajectories_dir.exists() { + return Ok(()); + } + + let now = Utc::now(); + let threshold = now - Duration::hours(ABANDONED_THRESHOLD_HOURS); + + for entry in WalkDir::new(&trajectories_dir).max_depth(1).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() || path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + + match process_single_trajectory(gcx.clone(), path.to_path_buf(), &threshold).await { + Ok(true) => info!("trajectory_memos: extracted memos from {}", path.display()), + Ok(false) => {}, + Err(e) => warn!("trajectory_memos: failed to process {}: {}", path.display(), e), + } + } + + Ok(()) +} + +async fn process_single_trajectory( + gcx: Arc>, + path: std::path::PathBuf, + threshold: &DateTime, +) -> Result { + let content = fs::read_to_string(&path).await.map_err(|e| e.to_string())?; + let mut trajectory: Value = serde_json::from_str(&content).map_err(|e| e.to_string())?; + + if trajectory.get("memo_extracted").and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(false); + } + + let updated_at = trajectory.get("updated_at") + .and_then(|v| v.as_str()) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + + let is_abandoned = match updated_at { + Some(dt) => dt < *threshold, + None => false, + }; + + if !is_abandoned { + return Ok(false); + } + + let messages = trajectory.get("messages") + .and_then(|v| v.as_array()) + .ok_or("No messages")?; + + if messages.len() < 10 { + return Ok(false); + } + + let trajectory_id = trajectory.get("id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let current_title = trajectory.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string(); + + let is_title_generated = trajectory.get("extra") + .and_then(|e| e.get("isTitleGenerated")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let chat_messages = build_chat_messages(messages); + + let extraction = extract_memos_and_meta(gcx.clone(), chat_messages, ¤t_title, is_title_generated).await?; + + let traj_obj = trajectory.as_object_mut().ok_or("Invalid trajectory")?; + + if let Some(ref meta) = extraction.meta { + traj_obj.insert("overview".to_string(), Value::String(meta.overview.clone())); + if is_title_generated && !meta.title.is_empty() { + traj_obj.insert("title".to_string(), Value::String(meta.title.clone())); + info!("trajectory_memos: updated title '{}' -> '{}' for {}", current_title, meta.title, trajectory_id); + } + } + + let memo_title = extraction.meta.as_ref() + .filter(|_| is_title_generated) + .map(|m| m.title.clone()) + .unwrap_or(current_title); + + for memo in extraction.memos { + let frontmatter = create_frontmatter( + Some(&format!("[{}] {}", memo.memo_type, memo_title)), + &[memo.memo_type.clone(), "trajectory".to_string()], + &[], + &[], + "trajectory", + ); + + let content_with_source = format!( + "{}\n\n---\nSource: trajectory `{}`", + memo.content, + trajectory_id + ); + + if let Err(e) = memories_add(gcx.clone(), &frontmatter, &content_with_source).await { + warn!("trajectory_memos: failed to save memo: {}", e); + } + } + + traj_obj.insert("memo_extracted".to_string(), Value::Bool(true)); + + let tmp_path = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(&trajectory).map_err(|e| e.to_string())?; + fs::write(&tmp_path, &json).await.map_err(|e| e.to_string())?; + fs::rename(&tmp_path, &path).await.map_err(|e| e.to_string())?; + + Ok(true) +} + +fn build_chat_messages(messages: &[Value]) -> Vec { + messages.iter() + .filter_map(|msg| { + let role = msg.get("role").and_then(|v| v.as_str())?; + if role == "context_file" || role == "cd_instruction" { + return None; + } + + let content = if let Some(c) = msg.get("content").and_then(|v| v.as_str()) { + c.to_string() + } else if let Some(arr) = msg.get("content").and_then(|v| v.as_array()) { + arr.iter() + .filter_map(|item| item.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + } else { + return None; + }; + + if content.trim().is_empty() { + return None; + } + + Some(ChatMessage { + role: role.to_string(), + content: ChatContent::SimpleText(content.chars().take(3000).collect()), + ..Default::default() + }) + }) + .collect() +} + +struct ExtractedMemo { + memo_type: String, + content: String, +} + +struct TrajectoryMeta { + overview: String, + title: String, +} + +struct ExtractionResult { + meta: Option, + memos: Vec, +} + +async fn extract_memos_and_meta( + gcx: Arc>, + mut messages: Vec, + current_title: &str, + is_title_generated: bool, +) -> Result { + let caps = try_load_caps_quickly_if_not_present(gcx.clone(), 0).await + .map_err(|e| e.message)?; + + let model_id = if caps.defaults.chat_light_model.is_empty() { + caps.defaults.chat_default_model.clone() + } else { + caps.defaults.chat_light_model.clone() + }; + + let n_ctx = caps.chat_models.get(&model_id) + .map(|m| m.base.n_ctx) + .unwrap_or(4096); + + let title_hint = if is_title_generated { + format!("\n\nNote: The current title \"{}\" was auto-generated. Please provide a better descriptive title.", current_title) + } else { + String::new() + }; + + messages.push(ChatMessage { + role: "user".to_string(), + content: ChatContent::SimpleText(format!("{}{}", EXTRACTION_PROMPT, title_hint)), + ..Default::default() + }); + + let ccx = Arc::new(AMutex::new(AtCommandsContext::new( + gcx.clone(), + n_ctx, + 1, + false, + messages.clone(), + "".to_string(), + false, + model_id.clone(), + ).await)); + + let response = subchat_single( + ccx, &model_id, messages, None, None, false, Some(0.0), None, 1, None, false, None, None, None, + ).await.map_err(|e| e.to_string())?; + + let response_text = response.into_iter() + .flatten() + .last() + .and_then(|m| match m.content { + ChatContent::SimpleText(t) => Some(t), + _ => None, + }) + .unwrap_or_default(); + + let mut meta: Option = None; + let mut memos: Vec = Vec::new(); + + for line in response_text.lines() { + let line = line.trim(); + if !line.starts_with('{') { + continue; + } + + let parsed: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + + if let (Some(overview), Some(title)) = ( + parsed.get("overview").and_then(|v| v.as_str()), + parsed.get("title").and_then(|v| v.as_str()), + ) { + meta = Some(TrajectoryMeta { + overview: overview.to_string(), + title: title.to_string(), + }); + continue; + } + + if let (Some(memo_type), Some(content)) = ( + parsed.get("type").and_then(|v| v.as_str()), + parsed.get("content").and_then(|v| v.as_str()), + ) { + if memos.len() < 10 { + memos.push(ExtractedMemo { + memo_type: memo_type.to_string(), + content: content.to_string(), + }); + } + } + } + + Ok(ExtractionResult { meta, memos }) +} diff --git a/refact-agent/engine/src/vecdb/mod.rs b/refact-agent/engine/src/vecdb/mod.rs index e297b1fac..1e7c1ad15 100644 --- a/refact-agent/engine/src/vecdb/mod.rs +++ b/refact-agent/engine/src/vecdb/mod.rs @@ -1,6 +1,7 @@ pub mod vdb_highlev; pub mod vdb_file_splitter; pub mod vdb_markdown_splitter; +pub mod vdb_trajectory_splitter; pub mod vdb_structs; pub mod vdb_remote; pub mod vdb_sqlite; diff --git a/refact-agent/engine/src/vecdb/vdb_thread.rs b/refact-agent/engine/src/vecdb/vdb_thread.rs index 54ef2a245..42ec6c20d 100644 --- a/refact-agent/engine/src/vecdb/vdb_thread.rs +++ b/refact-agent/engine/src/vecdb/vdb_thread.rs @@ -321,9 +321,12 @@ async fn vectorize_thread( continue; } - if let Err(err) = doc.does_text_look_good() { - info!("embeddings {} doesn't look good: {}", last_30_chars, err); - continue; + let is_trajectory = crate::vecdb::vdb_trajectory_splitter::is_trajectory_file(&doc.doc_path); + if !is_trajectory { + if let Err(err) = doc.does_text_look_good() { + info!("embeddings {} doesn't look good: {}", last_30_chars, err); + continue; + } } let is_markdown = doc.doc_path.extension() @@ -331,7 +334,13 @@ async fn vectorize_thread( .map(|e| e == "md" || e == "mdx") .unwrap_or(false); - let mut splits = if is_markdown { + let mut splits = if is_trajectory { + let traj_splitter = crate::vecdb::vdb_trajectory_splitter::TrajectoryFileSplitter::new(constants.splitter_window_size); + traj_splitter.split(&doc, gcx.clone()).await.unwrap_or_else(|err| { + info!("{}", err); + vec![] + }) + } else if is_markdown { let md_splitter = MarkdownFileSplitter::new(constants.embedding_model.base.n_ctx); md_splitter.split(&doc, gcx.clone()).await.unwrap_or_else(|err| { info!("{}", err); diff --git a/refact-agent/engine/src/vecdb/vdb_trajectory_splitter.rs b/refact-agent/engine/src/vecdb/vdb_trajectory_splitter.rs new file mode 100644 index 000000000..1d2ecf11e --- /dev/null +++ b/refact-agent/engine/src/vecdb/vdb_trajectory_splitter.rs @@ -0,0 +1,191 @@ +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock as ARwLock; +use serde_json::Value; + +use crate::files_in_workspace::Document; +use crate::global_context::GlobalContext; +use crate::vecdb::vdb_structs::SplitResult; +use crate::ast::chunk_utils::official_text_hashing_function; + +const MESSAGES_PER_CHUNK: usize = 4; +const MAX_CONTENT_PER_MESSAGE: usize = 2000; +const OVERLAP_MESSAGES: usize = 1; + +pub struct TrajectoryFileSplitter { + max_tokens: usize, +} + +#[derive(Debug, Clone)] +struct ExtractedMessage { + index: usize, + role: String, + content: String, +} + +struct MessageChunk { + text: String, + start_msg: usize, + end_msg: usize, +} + +impl TrajectoryFileSplitter { + pub fn new(max_tokens: usize) -> Self { + Self { max_tokens } + } + + pub async fn split( + &self, + doc: &Document, + gcx: Arc>, + ) -> Result, String> { + let text = doc.clone().get_text_or_read_from_disk(gcx).await.map_err(|e| e.to_string())?; + let path = doc.doc_path.clone(); + + let trajectory: Value = serde_json::from_str(&text) + .map_err(|e| format!("Failed to parse trajectory JSON: {}", e))?; + + let trajectory_id = trajectory.get("id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let title = trajectory.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string(); + let messages = trajectory.get("messages").and_then(|v| v.as_array()).ok_or("No messages array")?; + + let extracted = self.extract_messages(messages); + if extracted.is_empty() { + return Ok(vec![]); + } + + let mut results = Vec::new(); + + let metadata_text = format!("Trajectory: {}\nTitle: {}\nMessages: {}", trajectory_id, title, extracted.len()); + results.push(SplitResult { + file_path: path.clone(), + window_text: metadata_text.clone(), + window_text_hash: official_text_hashing_function(&metadata_text), + start_line: 0, + end_line: 0, + symbol_path: format!("traj:{}:meta", trajectory_id), + }); + + for chunk in self.chunk_messages(&extracted) { + results.push(SplitResult { + file_path: path.clone(), + window_text: chunk.text.clone(), + window_text_hash: official_text_hashing_function(&chunk.text), + start_line: chunk.start_msg as u64, + end_line: chunk.end_msg as u64, + symbol_path: format!("traj:{}:msg:{}-{}", trajectory_id, chunk.start_msg, chunk.end_msg), + }); + } + + Ok(results) + } + + fn extract_messages(&self, messages: &[Value]) -> Vec { + messages.iter().enumerate() + .filter_map(|(idx, msg)| { + let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + if role == "context_file" || role == "cd_instruction" || role == "system" { + return None; + } + + let content = self.extract_content(msg); + if content.trim().is_empty() { + return None; + } + + let truncated = if content.len() > MAX_CONTENT_PER_MESSAGE { + format!("{}...", &content[..MAX_CONTENT_PER_MESSAGE]) + } else { + content + }; + + Some(ExtractedMessage { index: idx, role, content: truncated }) + }) + .collect() + } + + fn extract_content(&self, msg: &Value) -> String { + if let Some(content) = msg.get("content").and_then(|c| c.as_str()) { + return content.to_string(); + } + + if let Some(content_arr) = msg.get("content").and_then(|c| c.as_array()) { + return content_arr.iter() + .filter_map(|item| { + item.get("text").and_then(|t| t.as_str()) + .or_else(|| item.get("m_content").and_then(|t| t.as_str())) + }) + .collect::>() + .join("\n"); + } + + if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) { + let names: Vec<_> = tool_calls.iter() + .filter_map(|tc| tc.get("function").and_then(|f| f.get("name")).and_then(|n| n.as_str())) + .map(|s| format!("[tool: {}]", s)) + .collect(); + if !names.is_empty() { + return names.join(" "); + } + } + + String::new() + } + + fn chunk_messages(&self, messages: &[ExtractedMessage]) -> Vec { + if messages.is_empty() { + return vec![]; + } + + let mut chunks = Vec::new(); + let mut i = 0; + + while i < messages.len() { + let end_idx = (i + MESSAGES_PER_CHUNK).min(messages.len()); + let chunk_messages = &messages[i..end_idx]; + let text = self.format_chunk(chunk_messages); + + let estimated_tokens = text.len() / 4; + if estimated_tokens > self.max_tokens && chunk_messages.len() > 1 { + for msg in chunk_messages { + chunks.push(MessageChunk { + text: self.format_chunk(&[msg.clone()]), + start_msg: msg.index, + end_msg: msg.index, + }); + } + } else { + chunks.push(MessageChunk { + text, + start_msg: chunk_messages.first().map(|m| m.index).unwrap_or(0), + end_msg: chunk_messages.last().map(|m| m.index).unwrap_or(0), + }); + } + + i += MESSAGES_PER_CHUNK.saturating_sub(OVERLAP_MESSAGES).max(1); + } + + chunks + } + + fn format_chunk(&self, messages: &[ExtractedMessage]) -> String { + messages.iter() + .flat_map(|msg| { + let role = match msg.role.as_str() { + "user" => "USER", + "assistant" => "ASSISTANT", + "tool" => "TOOL_RESULT", + "system" => "SYSTEM", + _ => &msg.role, + }; + vec![format!("[{}]:", role), msg.content.clone(), String::new()] + }) + .collect::>() + .join("\n") + } +} + +pub fn is_trajectory_file(path: &PathBuf) -> bool { + path.to_string_lossy().contains(".refact/trajectories/") + && path.extension().map(|e| e == "json").unwrap_or(false) +} diff --git a/refact-agent/engine/src/yaml_configs/customization_compiled_in.yaml b/refact-agent/engine/src/yaml_configs/customization_compiled_in.yaml index 4559e2727..ff76a06a5 100644 --- a/refact-agent/engine/src/yaml_configs/customization_compiled_in.yaml +++ b/refact-agent/engine/src/yaml_configs/customization_compiled_in.yaml @@ -52,26 +52,7 @@ SHELL_INSTRUCTIONS: | Here is another example: 🧩SETTINGS:service_hypercorn - - -KNOWLEDGE_INSTRUCTIONS_META: | - **KNOWLEDGE MANAGEMENT** - Use the knowledge tools alongside other search tools to build and leverage project-specific knowledge: - - **Reading Knowledge** - Use `knowledge(search_key)` to: - - Search for existing documentation, patterns, and decisions before starting work - - Find previous solutions to similar problems - - Understand project conventions and architectural decisions - - Retrieve saved trajectories and insights from past sessions - - **Writing Knowledge** - Use `create_knowledge(tags, content)` to save: - - Important coding patterns discovered during work - - Key architectural decisions and their rationale - - Effective strategies that worked well - - Insights about the project's structure and dependencies - - Solutions to tricky problems for future reference - - Build knowledge continuously - don't wait for explicit instructions to save useful insights. + PROMPT_EXPLORATION_TOOLS: | [mode2] You are Refact Chat, a coding assistant. @@ -100,18 +81,16 @@ PROMPT_EXPLORATION_TOOLS: | %GIT_INFO% - %KNOWLEDGE_INSTRUCTIONS% - - %PROJECT_TREE% - AGENT_EXPLORATION_INSTRUCTIONS: | 2. **Delegate exploration to subagent()**: - - "Find all usages of symbol X" β†’ subagent with search_symbol_usages, cat - - "Understand how module Y works" β†’ subagent with cat, tree, search_pattern + - "Find all usages of symbol X" β†’ subagent with search_symbol_usages, cat, knowledge + - "Understand how module Y works" β†’ subagent with cat, tree, search_pattern, knowledge - "Find files matching pattern Z" β†’ subagent with search_pattern, tree - - "Trace data flow from A to B" β†’ subagent with search_symbol_definition, cat - - "Find the usage of a lib in the web" β†’ subagent with web + - "Trace data flow from A to B" β†’ subagent with search_symbol_definition, cat, knowledge + - "Find the usage of a lib in the web" β†’ subagent with web, knowledge + - "Find similar past work" β†’ subagent with search_trajectories, trajectory_context + - "Check project knowledge" β†’ subagent with knowledge **Tools available for subagents**: - `tree()` - project structure; add `use_ast=true` for symbols @@ -120,6 +99,9 @@ AGENT_EXPLORATION_INSTRUCTIONS: | - `search_pattern()` - regex search across file names and contents - `search_semantic()` - conceptual/similarity matches - `web()`, `web_search()` - external documentation + - `knowledge()` - search project knowledge base + - `search_trajectories()` - find relevant past conversations + - `trajectory_context()` - retrieve messages from a trajectory **For complex analysis**: delegate to `strategic_planning()` with relevant file paths @@ -167,10 +149,19 @@ PROMPT_AGENTIC_TOOLS: | - Edits, any write operations - Trivial one-shot operations (single `cat()`) + ## Automatic Context Enrichment + User messages are automatically enriched with relevant context: + - **Memories**: Project knowledge, patterns, and insights from the knowledge base + - **Past trajectories**: Relevant excerpts from previous conversations that match the current query + + This injected context appears as context files before the user message. Pay attention to it - it may contain + useful patterns, lessons learned, or relevant prior work that can inform your approach. + ## Workflow ### 1. Understand the Task - Read the user's request carefully + - Review any automatically injected context (memories, trajectories) for relevant insights - If ambiguous, ask clarifying questions on any stage - Break complex tasks into independent subtasks @@ -211,8 +202,6 @@ PROMPT_AGENTIC_TOOLS: | %GIT_INFO% - %KNOWLEDGE_INSTRUCTIONS% - %PROJECT_TREE% diff --git a/refact-agent/gui/src/components/Select/select.module.css b/refact-agent/gui/src/components/Select/select.module.css index d6201adb1..9216d9593 100644 --- a/refact-agent/gui/src/components/Select/select.module.css +++ b/refact-agent/gui/src/components/Select/select.module.css @@ -38,12 +38,13 @@ position: relative; } -/* Fix checkmark indicator positioning */ +/* Fix checkmark indicator positioning - vertically centered */ :global(.rt-SelectItem .rt-SelectItemIndicator), :global(.rt-SelectItem [data-state]) { position: absolute; left: 8px; - top: 8px; + top: 50%; + transform: translateY(-50%); } :global(.rt-SelectViewport) {