From 1583812610373edb0812e4ffff37203fa352dffd Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 28 Jan 2026 15:28:15 +0100 Subject: [PATCH] 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. --- .../prompts/system/custom-system-prompt.md | 6 + .../src/prompts/system/system-prompt.md | 16 +- packages/coding-agent/src/system-prompt.ts | 171 ++++++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/prompts/system/custom-system-prompt.md b/packages/coding-agent/src/prompts/system/custom-system-prompt.md index 6f7a13efb..79a8cfeb1 100644 --- a/packages/coding-agent/src/prompts/system/custom-system-prompt.md +++ b/packages/coding-agent/src/prompts/system/custom-system-prompt.md @@ -8,6 +8,12 @@ {{#if contextFiles.length}} # Project Context +{{#if projectTree}} + +{{projectTree}} + +{{/if}} + {{#list contextFiles join="\n"}} diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 8550d7065..a363ad45c 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -221,17 +221,25 @@ It lies. The code that runs is not the code that works. - Resolve blockers before yielding. - +# Project + +## Files +{{#if projectTree}} + +{{projectTree}} + +{{/if}} + +## Context {{#if contextFiles.length}} - + {{#list contextFiles join="\n"}} {{content}} {{/list}} - + {{/if}} - {{#if git.isRepo}} diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 7e7ec2a0b..73fe32e78 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -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, "'"); +} + 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; + truncated: boolean; + truncatedDirs: Set; +}; + +async function scanProjectTree(root: string): Promise { + const children = new Map(); + let entryCount = 0; + let truncated = false; + const truncatedDirs = new Set(); + + 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}`); + 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}`); + }; + + renderDir(root, ""); + + if (scan.truncated) { + lines.push("..."); + } + + return lines.join("\n"); +} + +async function buildProjectTreeSnapshot(root: string): Promise { + 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,