diff --git a/tests/core/test_system_prompt.py b/tests/core/test_system_prompt.py index 709f4792f..8844861c4 100644 --- a/tests/core/test_system_prompt.py +++ b/tests/core/test_system_prompt.py @@ -2,6 +2,7 @@ import os from pathlib import Path +import subprocess import pytest @@ -25,3 +26,77 @@ def test_run_git_survives_non_utf8_output(tmp_path: Path, monkeypatch) -> None: # The bad bytes are replaced with U+FFFD instead of crashing assert "\ufffd" in result.stdout + + +def test_project_context_includes_git_file_overview( + tmp_path: Path, monkeypatch +) -> None: + def fake_run_git( + self: ProjectContextProvider, args: list[str], timeout: float + ) -> subprocess.CompletedProcess[str]: + stdout = "" + if args == ["ls-files"]: + stdout = "src/main.py\nREADME.md\nsrc/main.py\n\n" + elif args == ["branch", "--show-current"]: + stdout = "main\n" + elif args == ["branch", "-r"]: + stdout = "origin/main\n" + elif args[:2] == ["log", "--oneline"]: + stdout = "abc123 init\n" + elif args != ["status", "--porcelain"]: + raise AssertionError(args) + return subprocess.CompletedProcess(args, 0, stdout=stdout) + + monkeypatch.setattr(ProjectContextProvider, "_run_git", fake_run_git) + + provider = ProjectContextProvider(ProjectContextConfig(), root_path=tmp_path) + + context = provider.get_full_context() + + assert "Project file overview (snapshot at conversation start):" in context + assert "- README.md" in context + assert "- src/main.py" in context + assert context.count("- src/main.py") == 1 + + +def test_project_context_file_overview_is_bounded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def fake_run_git( + self: ProjectContextProvider, args: list[str], timeout: float + ) -> subprocess.CompletedProcess[str]: + stdout = "" + if args == ["ls-files"]: + stdout = "\n".join(f"file_{index}.py" for index in range(5)) + return subprocess.CompletedProcess(args, 0, stdout=stdout) + + monkeypatch.setattr(ProjectContextProvider, "_run_git", fake_run_git) + monkeypatch.setattr("vibe.core.system_prompt._MAX_FILE_OVERVIEW_ENTRIES", 2) + provider = ProjectContextProvider(ProjectContextConfig(), root_path=tmp_path) + + overview = provider.get_file_overview() + + assert "- file_0.py" in overview + assert "- file_1.py" in overview + assert "- file_2.py" not in overview + assert "... 3 more files omitted" in overview + + +def test_project_context_file_overview_falls_back_to_directory_scan( + tmp_path: Path, monkeypatch +) -> None: + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "module.py").write_text("", encoding="utf-8") + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "ignored.js").write_text("", encoding="utf-8") + (tmp_path / ".hidden").write_text("", encoding="utf-8") + + monkeypatch.setattr(ProjectContextProvider, "_list_git_files", lambda self: []) + + provider = ProjectContextProvider(ProjectContextConfig(), root_path=tmp_path) + + overview = provider.get_file_overview() + + assert "- pkg/module.py" in overview + assert "ignored.js" not in overview + assert ".hidden" not in overview diff --git a/vibe/core/prompts/project_context.md b/vibe/core/prompts/project_context.md index dc10bb43c..19f1fcfc3 100644 --- a/vibe/core/prompts/project_context.md +++ b/vibe/core/prompts/project_context.md @@ -2,3 +2,5 @@ Absolute path: $abs_path gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation. $git_status + +$file_overview diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 82a89569f..283a2072f 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -147,7 +147,7 @@ # System prompt composition include_model_info = true # Include model name in system prompt -include_project_context = true # Include project context (git info, cwd) in system prompt +include_project_context = true # Include project context (git info, cwd, file overview) in system prompt include_prompt_detail = true # Include OS info, tool prompts, skills, and agents in system prompt # Voice features diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 063b99581..885923770 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from datetime import date import html @@ -31,6 +32,17 @@ from vibe.core.tools.manager import ToolManager _git_status_cache: dict[Path, str] = {} +_MAX_FILE_OVERVIEW_ENTRIES = 200 + + +def _normalize_overview_entries(entries: Sequence[str]) -> list[str]: + clean_entries = [] + for entry in entries: + clean = entry.strip() + if not clean: + continue + clean_entries.append(clean) + return sorted(dict.fromkeys(clean_entries)) class ProjectContextProvider: @@ -40,6 +52,63 @@ def __init__( self.root_path = Path(root_path).resolve() self.config = config + def get_file_overview(self) -> str: + entries = self._list_project_files() + if not entries: + return "No project file overview available." + + limit = _MAX_FILE_OVERVIEW_ENTRIES + visible = entries[:limit] + lines = ["Project file overview (snapshot at conversation start):"] + lines.extend(f"- {entry}" for entry in visible) + if len(entries) > limit: + lines.append(f"... {len(entries) - limit} more files omitted") + return "\n".join(lines) + + def _list_project_files(self) -> list[str]: + if from_git := self._list_git_files(): + return from_git + return self._list_directory_files() + + def _list_git_files(self) -> list[str]: + try: + result = self._run_git(["ls-files"], min(self.config.timeout_seconds, 10.0)) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + return [] + return _normalize_overview_entries(result.stdout.splitlines()) + + def _list_directory_files(self) -> list[str]: + max_entries = _MAX_FILE_OVERVIEW_ENTRIES + 1 + entries: list[str] = [] + ignored_dirs = { + ".git", + ".hg", + ".svn", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "node_modules", + } + try: + for current_root, dirs, files in os.walk(self.root_path): + dirs[:] = [ + d for d in dirs if d not in ignored_dirs and not d.startswith(".") + ] + dirs.sort() + for file_name in sorted(files): + if file_name.startswith("."): + continue + path = Path(current_root, file_name) + entries.append(path.relative_to(self.root_path).as_posix()) + if len(entries) >= max_entries: + return entries + except OSError: + return [] + return entries + def get_git_status(self) -> str: if self.root_path in _git_status_cache: return _git_status_cache[self.root_path] @@ -147,8 +216,12 @@ def get_full_context(self) -> str: git_status = self.get_git_status() template = UtilityPrompt.PROJECT_CONTEXT.read() + file_overview = self.get_file_overview() + return Template(template).safe_substitute( - abs_path=str(self.root_path), git_status=git_status + abs_path=str(self.root_path), + git_status=git_status, + file_overview=file_overview, )