feat(coding-agent): added project tree visualization to system prompts with configurable depth and entry limits
- Added project tree visualization to system prompts with configurable depth and entry limits. - Implemented scanProjectTree function to recursively scan directory structure with intelligent truncation and sorting by modification time. - Added renderProjectTree function to format scanned directory structure as XML with path collapsing for single-child directories. - Integrated projectTree snapshot into both custom and main system prompt templates with conditional rendering. - Added XML escaping utility to safely render file paths and names in prompt context.
This commit is contained in:
@@ -8,6 +8,12 @@
|
||||
{{#if contextFiles.length}}
|
||||
# Project Context
|
||||
|
||||
{{#if projectTree}}
|
||||
<explorer>
|
||||
{{projectTree}}
|
||||
</explorer>
|
||||
{{/if}}
|
||||
|
||||
<project_context_files>
|
||||
{{#list contextFiles join="\n"}}
|
||||
<file path="{{path}}">
|
||||
|
||||
@@ -221,17 +221,25 @@ It lies. The code that runs is not the code that works.
|
||||
- Resolve blockers before yielding.
|
||||
</procedure>
|
||||
|
||||
<context>
|
||||
# Project
|
||||
|
||||
## Files
|
||||
{{#if projectTree}}
|
||||
<explorer>
|
||||
{{projectTree}}
|
||||
</explorer>
|
||||
{{/if}}
|
||||
|
||||
## Context
|
||||
{{#if contextFiles.length}}
|
||||
<project_context_files>
|
||||
<project_context>
|
||||
{{#list contextFiles join="\n"}}
|
||||
<file path="{{path}}">
|
||||
{{content}}
|
||||
</file>
|
||||
{{/list}}
|
||||
</project_context_files>
|
||||
</project_context>
|
||||
{{/if}}
|
||||
</context>
|
||||
|
||||
{{#if git.isRepo}}
|
||||
<vcs>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* System prompt construction and project context loading
|
||||
*/
|
||||
import type * as fsTypes from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $ } from "bun";
|
||||
@@ -129,6 +131,24 @@ function stripQuotes(value: string): string {
|
||||
|
||||
const AGENTS_MD_PATTERN = "**/AGENTS.md";
|
||||
const AGENTS_MD_LIMIT = 200;
|
||||
const PROJECT_TREE_LIMIT = 2000;
|
||||
const PROJECT_TREE_PER_DIR_LIMIT = 10;
|
||||
const PROJECT_TREE_PER_DIR_DEPTH = 2;
|
||||
const PROJECT_TREE_IGNORED = new Set([
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".next",
|
||||
".turbo",
|
||||
".cache",
|
||||
".venv",
|
||||
".idea",
|
||||
".vscode",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"target",
|
||||
]);
|
||||
|
||||
interface AgentsMdSearch {
|
||||
scopePath: string;
|
||||
@@ -141,6 +161,15 @@ function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function escapeXmlValue(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function listAgentsMdFiles(root: string, limit: number): string[] {
|
||||
try {
|
||||
const entries = Array.from(
|
||||
@@ -166,6 +195,145 @@ function buildAgentsMdSearch(cwd: string): AgentsMdSearch {
|
||||
};
|
||||
}
|
||||
|
||||
type ProjectTreeEntry = {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ProjectTreeScan = {
|
||||
children: Map<string, ProjectTreeEntry[]>;
|
||||
truncated: boolean;
|
||||
truncatedDirs: Set<string>;
|
||||
};
|
||||
|
||||
async function scanProjectTree(root: string): Promise<ProjectTreeScan> {
|
||||
const children = new Map<string, ProjectTreeEntry[]>();
|
||||
let entryCount = 0;
|
||||
let truncated = false;
|
||||
const truncatedDirs = new Set<string>();
|
||||
|
||||
const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < queue.length && !truncated) {
|
||||
const { dirPath, depth } = queue[cursor];
|
||||
cursor += 1;
|
||||
let entries: fsTypes.Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filtered = entries.filter(entry => !PROJECT_TREE_IGNORED.has(entry.name));
|
||||
const withStats = await Promise.all(
|
||||
filtered.map(async entry => {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
try {
|
||||
const stats = await fs.stat(entryPath);
|
||||
return { entry, entryPath, mtimeMs: stats.mtimeMs };
|
||||
} catch {
|
||||
return { entry, entryPath, mtimeMs: 0 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
withStats.sort((a, b) => {
|
||||
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
||||
return a.entry.name.localeCompare(b.entry.name);
|
||||
});
|
||||
|
||||
const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
|
||||
const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
|
||||
const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
|
||||
|
||||
const mapped: ProjectTreeEntry[] = [];
|
||||
for (const entryWithStat of limited) {
|
||||
if (entryCount >= PROJECT_TREE_LIMIT) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
mapped.push({
|
||||
name: entryWithStat.entry.name,
|
||||
isDirectory: entryWithStat.entry.isDirectory(),
|
||||
path: entryWithStat.entryPath,
|
||||
});
|
||||
entryCount += 1;
|
||||
|
||||
if (entryWithStat.entry.isDirectory()) {
|
||||
queue.push({ dirPath: entryWithStat.entryPath, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!truncated && hasMoreEntries) {
|
||||
truncatedDirs.add(dirPath);
|
||||
}
|
||||
children.set(dirPath, mapped);
|
||||
}
|
||||
|
||||
return { children, truncated, truncatedDirs };
|
||||
}
|
||||
|
||||
function renderProjectTree(scan: ProjectTreeScan, root: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
const collapseDir = (dirPath: string): { path: string; entries: ProjectTreeEntry[] } | null => {
|
||||
let currentPath = dirPath;
|
||||
while (true) {
|
||||
const entries = scan.children.get(currentPath);
|
||||
if (!entries || entries.length === 0) return null;
|
||||
const files = entries.filter(entry => !entry.isDirectory);
|
||||
const dirs = entries.filter(entry => entry.isDirectory);
|
||||
if (files.length === 0 && dirs.length === 1 && !scan.truncatedDirs.has(currentPath)) {
|
||||
currentPath = dirs[0].path;
|
||||
continue;
|
||||
}
|
||||
return { path: currentPath, entries };
|
||||
}
|
||||
};
|
||||
|
||||
const renderDir = (dirPath: string, indent: string): void => {
|
||||
const collapsed = collapseDir(dirPath);
|
||||
if (!collapsed) return;
|
||||
const { path: collapsedPath, entries } = collapsed;
|
||||
const relative = collapsedPath === root ? "." : path.relative(root, collapsedPath) || ".";
|
||||
lines.push(`${indent}<dir path="${escapeXmlValue(relative)}">`);
|
||||
const contentIndent = `${indent} `;
|
||||
|
||||
const files = entries.filter(entry => !entry.isDirectory);
|
||||
const dirs = entries.filter(entry => entry.isDirectory);
|
||||
|
||||
for (const entry of files) {
|
||||
lines.push(`${contentIndent}- ${escapeXmlValue(entry.name)}`);
|
||||
}
|
||||
|
||||
if (scan.truncatedDirs.has(collapsedPath)) {
|
||||
lines.push(`${contentIndent}- ...`);
|
||||
}
|
||||
|
||||
for (const entry of dirs) {
|
||||
renderDir(entry.path, contentIndent);
|
||||
}
|
||||
|
||||
lines.push(`${indent}</dir>`);
|
||||
};
|
||||
|
||||
renderDir(root, "");
|
||||
|
||||
if (scan.truncated) {
|
||||
lines.push("...");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function buildProjectTreeSnapshot(root: string): Promise<string> {
|
||||
const scan = await scanProjectTree(root);
|
||||
return renderProjectTree(scan, root);
|
||||
}
|
||||
|
||||
function getOsName(): string {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
@@ -707,6 +875,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
// Resolve context files: use provided or discover
|
||||
const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
|
||||
const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
|
||||
const projectTree = await buildProjectTreeSnapshot(resolvedCwd);
|
||||
|
||||
// Build tool descriptions array
|
||||
// Priority: toolNames (explicit list) > tools (Map) > defaults
|
||||
@@ -744,6 +913,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
customPrompt: resolvedCustomPrompt,
|
||||
appendPrompt: resolvedAppendPrompt ?? "",
|
||||
contextFiles,
|
||||
projectTree,
|
||||
agentsMdSearch,
|
||||
git,
|
||||
skills: filteredSkills,
|
||||
@@ -759,6 +929,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
environment: await getEnvironmentInfo(),
|
||||
systemPromptCustomization: systemPromptCustomization ?? "",
|
||||
contextFiles,
|
||||
projectTree,
|
||||
agentsMdSearch,
|
||||
git,
|
||||
skills: filteredSkills,
|
||||
|
||||
Reference in New Issue
Block a user