1use std::fmt::Write as _;
2use std::future::Future;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use serde::Serialize;
7
8use crate::instructions::{
9 InstructionBundle, InstructionDiscoveryOptions, InstructionSegment, extract_instruction_highlights,
10 read_instruction_bundle, render_instruction_markdown,
11};
12use crate::persistent_memory::{
13 MEMORY_FILENAME, MEMORY_SUMMARY_FILENAME, PersistentMemoryExcerpt, extract_memory_highlights,
14 read_persistent_memory_excerpt,
15};
16use crate::skills::model::SkillMetadata;
17use crate::utils::file_utils::canonicalize_with_context;
18use vtcode_config::core::AgentConfig;
19
20pub const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
21pub const PERSISTENT_MEMORY_SEPARATOR: &str = "\n\n--- persistent-memory ---\n\n";
22const PROJECT_DOC_SUMMARY_TITLE: &str = "PROJECT DOCUMENTATION";
23const PROJECT_DOC_TRUNCATION_NOTE: &str = "Some instruction files exceeded the configured prompt budget (agent.instruction_max_bytes) and were truncated; raise the budget to inline them in full.";
24const PERSISTENT_MEMORY_TRUNCATION_NOTE: &str =
25 "Persistent memory was truncated to the configured startup excerpt budget.";
26const PERSISTENT_MEMORY_HIGHLIGHT_LIMIT: usize = 3;
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ProjectDocBundle {
30 pub contents: String,
31 pub sources: Vec<PathBuf>,
32 pub segments: Vec<InstructionSegment>,
33 pub truncated: bool,
34 pub bytes_read: usize,
35}
36
37impl ProjectDocBundle {
38 pub fn highlights(&self, limit: usize) -> Vec<String> {
39 extract_instruction_highlights(&self.segments, limit)
40 }
41}
42
43pub struct ProjectDocOptions<'a> {
44 pub current_dir: &'a Path,
45 pub project_root: &'a Path,
46 pub home_dir: Option<&'a Path>,
47 pub extra_instruction_files: &'a [String],
48 pub fallback_filenames: &'a [String],
49 pub exclude_patterns: &'a [String],
50 pub match_paths: &'a [PathBuf],
51 pub import_max_depth: usize,
52 pub max_bytes: usize,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct InstructionAppendixBundle {
57 pub contents: String,
58 pub project_doc: Option<ProjectDocBundle>,
59 pub persistent_memory: Option<PersistentMemoryExcerpt>,
60 pub project_root: PathBuf,
61 pub home_dir: Option<PathBuf>,
62}
63
64pub async fn read_project_doc_with_options(options: &ProjectDocOptions<'_>) -> Result<Option<ProjectDocBundle>> {
65 if options.max_bytes == 0 {
66 return Ok(None);
67 }
68
69 match read_instruction_bundle(
70 &InstructionDiscoveryOptions {
71 current_dir: options.current_dir,
72 project_root: options.project_root,
73 home_dir: options.home_dir,
74 extra_patterns: options.extra_instruction_files,
75 fallback_filenames: options.fallback_filenames,
76 exclude_patterns: options.exclude_patterns,
77 match_paths: options.match_paths,
78 import_max_depth: options.import_max_depth,
79 },
80 options.max_bytes,
81 )
82 .await?
83 {
84 Some(bundle) => Ok(Some(convert_bundle(bundle))),
85 None => Ok(None),
86 }
87}
88
89pub async fn read_project_doc(cwd: &Path, max_bytes: usize) -> Result<Option<ProjectDocBundle>> {
90 if max_bytes == 0 {
91 return Ok(None);
92 }
93
94 let project_root = resolve_project_root(cwd).await.unwrap_or_else(|_| cwd.to_path_buf());
95 let home_dir = dirs::home_dir();
96
97 read_project_doc_with_options(&ProjectDocOptions {
98 current_dir: cwd,
99 project_root: &project_root,
100 home_dir: home_dir.as_deref(),
101 extra_instruction_files: &[],
102 fallback_filenames: &[],
103 exclude_patterns: &[],
104 match_paths: &[],
105 import_max_depth: 5,
106 max_bytes,
107 })
108 .await
109}
110
111pub fn get_user_instructions<'a>(
112 config: &'a AgentConfig,
113 active_dir: &'a Path,
114 _skills: Option<&'a [SkillMetadata]>,
115) -> impl Future<Output = Option<String>> + 'a {
116 build_instruction_appendix(config, active_dir)
117}
118
119pub fn build_instruction_appendix<'a>(
120 config: &'a AgentConfig,
121 active_dir: &'a Path,
122) -> impl Future<Output = Option<String>> + 'a {
123 build_instruction_appendix_with_context(config, active_dir, &[])
124}
125
126pub async fn build_instruction_appendix_with_context(
127 config: &AgentConfig,
128 active_dir: &Path,
129 match_paths: &[PathBuf],
130) -> Option<String> {
131 load_instruction_appendix(config, active_dir, match_paths)
132 .await
133 .map(|bundle| bundle.contents)
134}
135
136pub async fn load_instruction_appendix(
137 config: &AgentConfig,
138 active_dir: &Path,
139 match_paths: &[PathBuf],
140) -> Option<InstructionAppendixBundle> {
141 let project_root = resolve_project_root(active_dir)
142 .await
143 .unwrap_or_else(|_| active_dir.to_path_buf());
144 let home_dir = dirs::home_dir();
145 let bundle = read_project_doc_with_options(&ProjectDocOptions {
146 current_dir: active_dir,
147 project_root: &project_root,
148 home_dir: home_dir.as_deref(),
149 extra_instruction_files: &config.instruction_files,
150 fallback_filenames: &config.project_doc_fallback_filenames,
151 exclude_patterns: &config.instruction_excludes,
152 match_paths,
153 import_max_depth: config.instruction_import_max_depth,
154 max_bytes: config.instruction_max_bytes,
155 })
156 .await
157 .ok()
158 .flatten();
159 let persistent_memory = read_persistent_memory_excerpt(&config.persistent_memory, &project_root)
160 .await
161 .ok()
162 .flatten();
163
164 let contents = render_instruction_appendix(
165 config.user_instructions.as_deref(),
166 bundle.as_ref(),
167 persistent_memory.as_ref(),
168 &project_root,
169 home_dir.as_deref(),
170 )?;
171
172 Some(InstructionAppendixBundle {
173 contents,
174 project_doc: bundle,
175 persistent_memory,
176 project_root,
177 home_dir,
178 })
179}
180
181pub fn render_instruction_appendix(
182 user_instructions: Option<&str>,
183 bundle: Option<&ProjectDocBundle>,
184 persistent_memory: Option<&PersistentMemoryExcerpt>,
185 project_root: &Path,
186 home_dir: Option<&Path>,
187) -> Option<String> {
188 let mut section = String::with_capacity(1024);
189
190 if let Some(user_inst) = user_instructions.map(str::trim)
191 && !user_inst.is_empty()
192 {
193 section.push_str(user_inst);
194 }
195
196 if let Some(bundle) = bundle
197 && !bundle.segments.is_empty()
198 {
199 if !section.is_empty() {
200 section.push_str(PROJECT_DOC_SEPARATOR);
201 }
202
203 section.push_str(
204 render_instruction_markdown(
205 PROJECT_DOC_SUMMARY_TITLE,
206 &bundle.segments,
207 bundle.truncated,
208 project_root,
209 home_dir,
210 PROJECT_DOC_TRUNCATION_NOTE,
211 )
212 .trim_end(),
213 );
214 }
215
216 if let Some(memory_section) = persistent_memory.and_then(render_persistent_memory_summary_markdown) {
217 if !section.is_empty() {
218 section.push_str(PERSISTENT_MEMORY_SEPARATOR);
219 }
220
221 section.push_str(memory_section.trim_end());
222 }
223
224 if section.is_empty() { None } else { Some(section) }
225}
226
227fn render_persistent_memory_summary_markdown(memory: &PersistentMemoryExcerpt) -> Option<String> {
228 let highlights = extract_memory_highlights(&memory.contents, PERSISTENT_MEMORY_HIGHLIGHT_LIMIT);
229 if highlights.is_empty() && memory.contents.trim().is_empty() {
230 return None;
231 }
232
233 let mut section = String::with_capacity(512);
234 section.push_str("## PERSISTENT MEMORY\n\n");
235 section.push_str("### Files\n");
236 let _ = writeln!(section, "- `{MEMORY_SUMMARY_FILENAME}`: startup summary");
237 let _ = writeln!(section, "- `{MEMORY_FILENAME}`: durable registry");
238
239 if !highlights.is_empty() {
240 section.push_str("\n### Key points\n");
241 for highlight in highlights {
242 let _ = writeln!(section, "- {highlight}");
243 }
244 }
245
246 section
247 .push_str("\n### On-demand loading\n- Open `memory_summary.md` or `MEMORY.md` when exact wording matters.\n");
248
249 if memory.truncated {
250 let _ = writeln!(section, "\n_{PERSISTENT_MEMORY_TRUNCATION_NOTE}_");
251 }
252
253 section.push('\n');
254 Some(section)
255}
256
257pub fn merge_project_docs_with_skills(project_doc: Option<String>, skills_section: Option<String>) -> Option<String> {
258 match (project_doc, skills_section) {
259 (Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")),
260 (Some(doc), None) => Some(doc),
261 (None, Some(skills)) => Some(skills),
262 (None, None) => None,
263 }
264}
265
266fn convert_bundle(bundle: InstructionBundle) -> ProjectDocBundle {
267 let contents = bundle.combined_text();
268 let segments = bundle.segments;
269 let sources = segments.iter().map(|segment| segment.source.path.clone()).collect::<Vec<_>>();
270
271 ProjectDocBundle {
272 contents,
273 sources,
274 segments,
275 truncated: bundle.truncated,
276 bytes_read: bundle.bytes_read,
277 }
278}
279
280async fn resolve_project_root(cwd: &Path) -> Result<PathBuf> {
281 let mut cursor = canonicalize_with_context(cwd, "working directory")?;
282
283 loop {
284 let git_marker = cursor.join(".git");
285 match tokio::fs::metadata(&git_marker).await {
286 Ok(_) => return Ok(cursor),
287 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
288 Err(err) => {
289 return Err(err)
290 .with_context(|| format!("Failed to inspect potential git root {}", git_marker.display()));
291 }
292 }
293
294 match cursor.parent() {
295 Some(parent) => {
296 cursor = parent.to_path_buf();
297 }
298 None => return Ok(cursor),
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::instructions::{InstructionScope, InstructionSource, InstructionSourceKind};
307 use tempfile::tempdir;
308
309 fn write_doc(dir: &Path, content: &str) -> Result<()> {
310 std::fs::write(dir.join("AGENTS.md"), content).context("write AGENTS.md")?;
311 Ok(())
312 }
313
314 #[tokio::test]
315 async fn returns_none_when_no_docs_present() {
316 let tmp = tempdir().expect("failed to unwrap");
317 let result = read_project_doc(tmp.path(), 4096).await.expect("failed to unwrap");
318 assert!(result.is_none());
319 }
320
321 #[tokio::test]
322 async fn reads_doc_within_limit() {
323 let tmp = tempdir().expect("failed to unwrap");
324 write_doc(tmp.path(), "hello world").expect("write doc");
325
326 let result = read_project_doc(tmp.path(), 4096)
327 .await
328 .expect("failed to unwrap")
329 .expect("failed to unwrap");
330 assert_eq!(result.contents, "hello world");
331 assert_eq!(result.bytes_read, "hello world".len());
332 }
333
334 #[tokio::test]
335 async fn truncates_when_limit_exceeded() {
336 let tmp = tempdir().expect("failed to unwrap");
337 let content = "A".repeat(64);
338 write_doc(tmp.path(), &content).expect("write doc");
339
340 let result = read_project_doc(tmp.path(), 16)
341 .await
342 .expect("failed to unwrap")
343 .expect("failed to unwrap");
344 assert!(result.truncated);
345 assert_eq!(result.contents.len(), 16);
346 }
347
348 #[tokio::test]
349 async fn reads_docs_from_repo_root_downwards() {
350 let repo = tempdir().expect("failed to unwrap");
351 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("failed to unwrap");
352 write_doc(repo.path(), "root doc").expect("write doc");
353
354 let nested = repo.path().join("nested/sub");
355 std::fs::create_dir_all(&nested).expect("failed to unwrap");
356 write_doc(&nested, "nested doc").expect("write doc");
357
358 let bundle = read_project_doc_with_options(&ProjectDocOptions {
359 current_dir: &nested,
360 project_root: repo.path(),
361 home_dir: None,
362 extra_instruction_files: &[],
363 fallback_filenames: &[],
364 exclude_patterns: &[],
365 match_paths: &[],
366 import_max_depth: 5,
367 max_bytes: 4096,
368 })
369 .await
370 .expect("failed to unwrap")
371 .expect("failed to unwrap");
372 assert!(bundle.contents.contains("root doc"));
373 assert!(bundle.contents.contains("nested doc"));
374 assert_eq!(bundle.sources.len(), 2);
375 }
376
377 #[tokio::test]
378 async fn instruction_appendix_uses_instruction_hierarchy_scope_and_budget() {
379 let repo = tempdir().expect("repo");
380 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("write git");
381 write_doc(repo.path(), "root doc").expect("write root doc");
382
383 let nested = repo.path().join("nested/sub");
384 std::fs::create_dir_all(&nested).expect("create nested");
385 write_doc(&nested, "nested doc").expect("write nested doc");
386
387 let extra_dir = repo.path().join("docs");
388 std::fs::create_dir_all(&extra_dir).expect("create docs");
389 std::fs::write(extra_dir.join("guidelines.md"), "extra doc").expect("write extra doc");
390
391 let config = AgentConfig {
392 user_instructions: Some("user note".to_string()),
393 instruction_files: vec!["docs/*.md".to_string()],
394 instruction_max_bytes: 4096,
395 project_doc_max_bytes: 1,
396 ..Default::default()
397 };
398
399 let appendix =
400 build_instruction_appendix_with_context(&config, &nested, &[repo.path().join("nested/sub/file.rs")])
401 .await
402 .expect("instruction appendix");
403
404 assert!(appendix.starts_with("user note"));
405 assert!(appendix.contains("--- project-doc ---"));
406 assert!(appendix.contains("### Instruction map"));
407 assert!(appendix.contains("AGENTS.md (workspace AGENTS)"));
408 assert!(appendix.contains("docs/guidelines.md (custom extra instructions)"));
409 assert!(appendix.contains("nested/sub/AGENTS.md (workspace AGENTS)"));
410 assert!(appendix.contains("root doc"));
411 assert!(appendix.contains("extra doc"));
412 assert!(appendix.contains("nested doc"));
413 }
414
415 #[tokio::test]
416 async fn instruction_appendix_returns_none_when_empty() {
417 let tmp = tempdir().expect("tmp");
418 let appendix = build_instruction_appendix(&AgentConfig::default(), tmp.path()).await;
419 assert!(appendix.is_none());
420 }
421
422 #[tokio::test]
423 async fn instruction_appendix_marks_truncation() {
424 let repo = tempdir().expect("repo");
425 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("write git");
426 write_doc(repo.path(), "- Root summary\n\nThis detail should stay out of the prompt appendix.\n")
427 .expect("write doc");
428
429 let config = AgentConfig { instruction_max_bytes: 16, ..Default::default() };
430
431 let appendix = build_instruction_appendix(&config, repo.path())
432 .await
433 .expect("instruction appendix");
434
435 assert!(appendix.contains("## PROJECT DOCUMENTATION"));
436 assert!(!appendix.contains("### Instruction map"));
438 assert!(appendix.contains("- Root summary"));
439 assert!(!appendix.contains("This detail should stay out"));
440 assert!(appendix.contains("Some instruction files exceeded the configured prompt budget"));
441 }
442
443 #[tokio::test]
444 async fn includes_extra_instruction_files() {
445 let repo = tempdir().expect("failed to unwrap");
446 write_doc(repo.path(), "root doc").expect("write doc");
447 let docs = repo.path().join("docs");
448 std::fs::create_dir_all(&docs).expect("failed to unwrap");
449 let extra = docs.join("guidelines.md");
450 std::fs::write(&extra, "extra doc").expect("failed to unwrap");
451
452 let bundle = read_project_doc_with_options(&ProjectDocOptions {
453 current_dir: repo.path(),
454 project_root: repo.path(),
455 home_dir: None,
456 extra_instruction_files: &["docs/*.md".to_owned()],
457 fallback_filenames: &[],
458 exclude_patterns: &[],
459 match_paths: &[],
460 import_max_depth: 5,
461 max_bytes: 4096,
462 })
463 .await
464 .expect("failed to unwrap")
465 .expect("failed to unwrap");
466
467 assert!(bundle.contents.contains("root doc"));
468 assert!(bundle.contents.contains("extra doc"));
469 assert_eq!(bundle.sources.len(), 2);
470 }
471
472 #[test]
473 fn highlights_extract_bullets() {
474 let bundle = ProjectDocBundle {
475 contents: "- First\n- Second\n".to_owned(),
476 sources: Vec::new(),
477 segments: vec![InstructionSegment {
478 source: InstructionSource {
479 path: PathBuf::from("AGENTS.md"),
480 scope: InstructionScope::Workspace,
481 kind: InstructionSourceKind::Agents,
482 matched: false,
483 },
484 contents: "- First\n- Second\n".to_owned(),
485 }],
486 truncated: false,
487 bytes_read: 0,
488 };
489 let highlights = bundle.highlights(1);
490 assert_eq!(highlights, vec!["First".to_owned()]);
491 }
492
493 #[tokio::test]
494 async fn renders_instruction_appendix_inlining_full_content() {
495 let repo = tempdir().expect("failed to unwrap");
496 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("failed to unwrap");
497 write_doc(repo.path(), "- Root summary\n\nFollow the repository-level guidance first.\n").expect("write doc");
498
499 let nested = repo.path().join("nested/sub");
500 std::fs::create_dir_all(&nested).expect("failed to unwrap");
501 write_doc(&nested, "- Nested summary\n\nFollow the nested guidance last.\n").expect("write doc");
502
503 let instructions = get_user_instructions(&AgentConfig::default(), &nested, None)
504 .await
505 .expect("expected instructions");
506
507 assert!(instructions.contains("### Instruction map"));
508 assert!(instructions.contains("AGENTS.md (workspace AGENTS)"));
509 assert!(instructions.contains("nested/sub/AGENTS.md (workspace AGENTS)"));
510 assert!(!instructions.contains("### Key points"));
512 assert!(instructions.contains("Root summary"));
513 assert!(instructions.contains("Nested summary"));
514 assert!(instructions.contains("Follow the repository-level guidance first."));
515 assert!(instructions.contains("Follow the nested guidance last."));
516 }
517
518 #[tokio::test]
519 async fn instruction_appendix_includes_persistent_memory_after_authored_guidance() {
520 let repo = tempdir().expect("repo");
521 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("git marker");
522 std::fs::write(repo.path().join(".vtcode-project"), "repo").expect("project name");
523 write_doc(repo.path(), "root doc").expect("write root doc");
524
525 let memory_dir = repo.path().join(".memory-root");
526 let config = AgentConfig {
527 persistent_memory: vtcode_config::core::PersistentMemoryConfig {
528 enabled: true,
529 directory_override: Some(memory_dir.display().to_string()),
530 ..Default::default()
531 },
532 ..Default::default()
533 };
534
535 let project_memory_dir = memory_dir.join("projects").join("repo").join("memory");
536 std::fs::create_dir_all(&project_memory_dir).expect("memory dir");
537 std::fs::write(
538 project_memory_dir.join("memory_summary.md"),
539 "# VT Code Memory Summary\n\n- remembered detail\n",
540 )
541 .expect("write memory summary");
542
543 let appendix = build_instruction_appendix(&config, repo.path())
544 .await
545 .expect("instruction appendix");
546
547 let project_doc_idx = appendix.find("root doc").expect("project doc");
548 let memory_idx = appendix.find("remembered detail").expect("memory detail");
549 assert!(project_doc_idx < memory_idx);
550 assert!(appendix.contains("--- persistent-memory ---"));
551 assert!(appendix.contains("### Files"));
552 assert!(appendix.contains("### On-demand loading"));
553 assert!(appendix.contains("memory_summary.md"));
554 assert!(appendix.contains("MEMORY.md"));
555 assert!(!appendix.contains("# VT Code Memory Summary"));
556 }
557
558 #[tokio::test]
559 async fn instruction_appendix_keeps_persistent_memory_compact() {
560 let repo = tempdir().expect("repo");
561 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("git marker");
562 std::fs::write(repo.path().join(".vtcode-project"), "repo").expect("project name");
563
564 let memory_dir = repo.path().join(".memory-root");
565 let config = AgentConfig {
566 persistent_memory: vtcode_config::core::PersistentMemoryConfig {
567 enabled: true,
568 directory_override: Some(memory_dir.display().to_string()),
569 ..Default::default()
570 },
571 ..Default::default()
572 };
573
574 let project_memory_dir = memory_dir.join("projects").join("repo").join("memory");
575 std::fs::create_dir_all(&project_memory_dir).expect("memory dir");
576 std::fs::write(
577 project_memory_dir.join("memory_summary.md"),
578 "# VT Code Memory Summary\n\n- keep changes surgical\n- run ./scripts/check.sh\n- use cargo nextest for targeted tests\n- prefer docs/ARCHITECTURE.md for orientation\n- extra detail that should stay out of the prompt body\n",
579 )
580 .expect("write memory summary");
581
582 let appendix = build_instruction_appendix(&config, repo.path())
583 .await
584 .expect("instruction appendix");
585 let approx_tokens = appendix.len() / 4;
586
587 assert!(appendix.contains("### Key points"));
588 assert!(appendix.contains("Open `memory_summary.md` or `MEMORY.md`"));
589 assert!(approx_tokens < 120, "got ~{approx_tokens} tokens");
590 }
591
592 #[tokio::test]
593 async fn instruction_appendix_inlines_full_guidance() {
594 let repo = tempdir().expect("repo");
595 std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("git marker");
596 write_doc(
597 repo.path(),
598 "- run ./scripts/check.sh\n- avoid adding to vtcode-core\n- use Conventional Commits\n- start with docs/ARCHITECTURE.md\n",
599 )
600 .expect("write root doc");
601
602 let appendix = build_instruction_appendix(&AgentConfig::default(), repo.path())
603 .await
604 .expect("instruction appendix");
605 let approx_tokens = appendix.len() / 4;
606
607 assert!(!appendix.contains("### Instruction map"));
608 assert!(!appendix.contains("### Key points"));
609 assert!(appendix.contains("avoid adding to vtcode-core"));
610 assert!(appendix.contains("start with docs/ARCHITECTURE.md"));
611 assert!(approx_tokens < 250, "got ~{approx_tokens} tokens");
612 }
613}