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