Files
puaros/packages/ipuaro/src/infrastructure/llm/prompts.ts
imfozilbek 2e84472e49 feat(ipuaro): display transitive counts in High Impact Files table
- Change table header to include Direct and Transitive columns
- Sort by transitive count first, then by impact score
- Update tests for new table format
2025-12-05 16:16:22 +05:00

791 lines
23 KiB
TypeScript

import type { FileAST } from "../../domain/value-objects/FileAST.js"
import type { FileMeta } from "../../domain/value-objects/FileMeta.js"
/**
* Project structure for context building.
*/
export interface ProjectStructure {
name: string
rootPath: string
files: string[]
directories: string[]
}
/**
* Options for building initial context.
*/
export interface BuildContextOptions {
includeSignatures?: boolean
includeDepsGraph?: boolean
includeCircularDeps?: boolean
includeHighImpactFiles?: boolean
circularDeps?: string[][]
}
/**
* System prompt for the ipuaro AI agent.
*/
export const SYSTEM_PROMPT = `You are ipuaro, a local AI code assistant specialized in helping developers understand and modify their codebase. You operate within a single project directory and have access to powerful tools for reading, searching, analyzing, and editing code.
## Core Principles
1. **Lazy Loading**: You don't have the full code in context. Use tools to fetch exactly what you need.
2. **Precision**: Always verify file paths and line numbers before making changes.
3. **Safety**: Confirm destructive operations. Never execute dangerous commands.
4. **Efficiency**: Minimize context usage. Request only necessary code sections.
## Tool Calling Format
When you need to use a tool, format your call as XML:
<tool_call name="tool_name">
<param_name>value</param_name>
<another_param>value</another_param>
</tool_call>
You can call multiple tools in one response. Always wait for tool results before making conclusions.
**Examples:**
<tool_call name="get_lines">
<path>src/index.ts</path>
<start>1</start>
<end>50</end>
</tool_call>
<tool_call name="edit_lines">
<path>src/utils.ts</path>
<start>10</start>
<end>15</end>
<content>const newCode = "hello";</content>
</tool_call>
<tool_call name="find_references">
<symbol>getUserById</symbol>
</tool_call>
## Available Tools
### Reading Tools
- \`get_lines(path, start?, end?)\`: Get specific lines from a file
- \`get_function(path, name)\`: Get a function by name
- \`get_class(path, name)\`: Get a class by name
- \`get_structure(path?, depth?)\`: Get project directory structure
### Editing Tools (require confirmation)
- \`edit_lines(path, start, end, content)\`: Replace specific lines in a file
- \`create_file(path, content)\`: Create a new file
- \`delete_file(path)\`: Delete a file
### Search Tools
- \`find_references(symbol, path?)\`: Find all usages of a symbol
- \`find_definition(symbol)\`: Find where a symbol is defined
### Analysis Tools
- \`get_dependencies(path)\`: Get files this file imports
- \`get_dependents(path)\`: Get files that import this file
- \`get_complexity(path?, limit?)\`: Get complexity metrics
- \`get_todos(path?, type?)\`: Find TODO/FIXME comments
### Git Tools
- \`git_status()\`: Get repository status
- \`git_diff(path?, staged?)\`: Get uncommitted changes
- \`git_commit(message, files?)\`: Create a commit (requires confirmation)
### Run Tools
- \`run_command(command, timeout?)\`: Execute a shell command (security checked)
- \`run_tests(path?, filter?, watch?)\`: Run the test suite
## Response Guidelines
1. **Be concise**: Don't repeat information already in context.
2. **Show your work**: Explain what tools you're using and why.
3. **Verify before editing**: Always read the target code before modifying it.
4. **Handle errors gracefully**: If a tool fails, explain what went wrong and suggest alternatives.
## Code Editing Rules
1. Always use \`get_lines\` or \`get_function\` before \`edit_lines\`.
2. Provide exact line numbers for edits.
3. For large changes, break into multiple small edits.
4. After editing, suggest running tests if available.
## Safety Rules
1. Never execute commands that could harm the system.
2. Never expose sensitive data (API keys, passwords).
3. Always confirm file deletions and destructive git operations.
4. Stay within the project directory.
When you need to perform an action, use the appropriate tool. Think step by step about what information you need and which tools will provide it most efficiently.`
/**
* Build initial context from project structure and AST metadata.
* Returns a compact representation without actual code.
*/
export function buildInitialContext(
structure: ProjectStructure,
asts: Map<string, FileAST>,
metas?: Map<string, FileMeta>,
options?: BuildContextOptions,
): string {
const sections: string[] = []
const includeSignatures = options?.includeSignatures ?? true
const includeDepsGraph = options?.includeDepsGraph ?? true
const includeCircularDeps = options?.includeCircularDeps ?? true
const includeHighImpactFiles = options?.includeHighImpactFiles ?? true
sections.push(formatProjectHeader(structure))
sections.push(formatDirectoryTree(structure))
sections.push(formatFileOverview(asts, metas, includeSignatures))
if (includeDepsGraph && metas && metas.size > 0) {
const depsGraph = formatDependencyGraph(metas)
if (depsGraph) {
sections.push(depsGraph)
}
}
if (includeHighImpactFiles && metas && metas.size > 0) {
const highImpactSection = formatHighImpactFiles(metas)
if (highImpactSection) {
sections.push(highImpactSection)
}
}
if (includeCircularDeps && options?.circularDeps && options.circularDeps.length > 0) {
const circularDepsSection = formatCircularDeps(options.circularDeps)
if (circularDepsSection) {
sections.push(circularDepsSection)
}
}
return sections.join("\n\n")
}
/**
* Format project header section.
*/
function formatProjectHeader(structure: ProjectStructure): string {
const fileCount = String(structure.files.length)
const dirCount = String(structure.directories.length)
return `# Project: ${structure.name}
Root: ${structure.rootPath}
Files: ${fileCount} | Directories: ${dirCount}`
}
/**
* Format directory tree.
*/
function formatDirectoryTree(structure: ProjectStructure): string {
const lines: string[] = ["## Structure", ""]
const sortedDirs = [...structure.directories].sort()
for (const dir of sortedDirs) {
const depth = dir.split("/").length - 1
const indent = " ".repeat(depth)
const name = dir.split("/").pop() ?? dir
lines.push(`${indent}${name}/`)
}
return lines.join("\n")
}
/**
* Format file overview with AST summaries.
*/
function formatFileOverview(
asts: Map<string, FileAST>,
metas?: Map<string, FileMeta>,
includeSignatures = true,
): string {
const lines: string[] = ["## Files", ""]
const sortedPaths = [...asts.keys()].sort()
for (const path of sortedPaths) {
const ast = asts.get(path)
if (!ast) {
continue
}
const meta = metas?.get(path)
lines.push(formatFileSummary(path, ast, meta, includeSignatures))
}
return lines.join("\n")
}
/**
* Format decorators as a prefix string.
* Example: "@Get(':id') @Auth() "
*/
function formatDecoratorsPrefix(decorators: string[] | undefined): string {
if (!decorators || decorators.length === 0) {
return ""
}
return `${decorators.join(" ")} `
}
/**
* Format a function signature.
*/
function formatFunctionSignature(fn: FileAST["functions"][0]): string {
const decoratorsPrefix = formatDecoratorsPrefix(fn.decorators)
const asyncPrefix = fn.isAsync ? "async " : ""
const params = fn.params
.map((p) => {
const optional = p.optional ? "?" : ""
const type = p.type ? `: ${p.type}` : ""
return `${p.name}${optional}${type}`
})
.join(", ")
const returnType = fn.returnType ? `: ${fn.returnType}` : ""
return `${decoratorsPrefix}${asyncPrefix}${fn.name}(${params})${returnType}`
}
/**
* Format an interface signature with fields.
* Example: "interface User extends Base { id: string, name: string, email?: string }"
*/
function formatInterfaceSignature(iface: FileAST["interfaces"][0]): string {
const extList = iface.extends ?? []
const ext = extList.length > 0 ? ` extends ${extList.join(", ")}` : ""
if (iface.properties.length === 0) {
return `interface ${iface.name}${ext}`
}
const fields = iface.properties
.map((p) => {
const readonly = p.isReadonly ? "readonly " : ""
const optional = p.name.endsWith("?") ? "" : ""
const type = p.type ? `: ${p.type}` : ""
return `${readonly}${p.name}${optional}${type}`
})
.join(", ")
return `interface ${iface.name}${ext} { ${fields} }`
}
/**
* Format a type alias signature with definition.
* Example: "type UserId = string" or "type Handler = (event: Event) => void"
*/
function formatTypeAliasSignature(type: FileAST["typeAliases"][0]): string {
if (!type.definition) {
return `type ${type.name}`
}
const definition = truncateDefinition(type.definition, 80)
return `type ${type.name} = ${definition}`
}
/**
* Format an enum signature with members and values.
* Example: "enum Status { Active=1, Inactive=0, Pending=2 }"
* Example: "const enum Role { Admin="admin", User="user" }"
*/
function formatEnumSignature(enumInfo: FileAST["enums"][0]): string {
const constPrefix = enumInfo.isConst ? "const " : ""
if (enumInfo.members.length === 0) {
return `${constPrefix}enum ${enumInfo.name}`
}
const membersStr = enumInfo.members
.map((m) => {
if (m.value === undefined) {
return m.name
}
const valueStr = typeof m.value === "string" ? `"${m.value}"` : String(m.value)
return `${m.name}=${valueStr}`
})
.join(", ")
const result = `${constPrefix}enum ${enumInfo.name} { ${membersStr} }`
if (result.length > 100) {
return truncateDefinition(result, 100)
}
return result
}
/**
* Truncate long type definitions for display.
*/
function truncateDefinition(definition: string, maxLength: number): string {
const normalized = definition.replace(/\s+/g, " ").trim()
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, maxLength - 3)}...`
}
/**
* Format a single file's AST summary.
* When includeSignatures is true, shows full function signatures.
* When false, shows compact format with just names.
*/
function formatFileSummary(
path: string,
ast: FileAST,
meta?: FileMeta,
includeSignatures = true,
): string {
const flags = formatFileFlags(meta)
if (!includeSignatures) {
return formatFileSummaryCompact(path, ast, flags)
}
const lines: string[] = []
lines.push(`### ${path}${flags}`)
if (ast.functions.length > 0) {
for (const fn of ast.functions) {
lines.push(`- ${formatFunctionSignature(fn)}`)
}
}
if (ast.classes.length > 0) {
for (const cls of ast.classes) {
const decoratorsPrefix = formatDecoratorsPrefix(cls.decorators)
const ext = cls.extends ? ` extends ${cls.extends}` : ""
const impl = cls.implements.length > 0 ? ` implements ${cls.implements.join(", ")}` : ""
lines.push(`- ${decoratorsPrefix}class ${cls.name}${ext}${impl}`)
}
}
if (ast.interfaces.length > 0) {
for (const iface of ast.interfaces) {
lines.push(`- ${formatInterfaceSignature(iface)}`)
}
}
if (ast.typeAliases.length > 0) {
for (const type of ast.typeAliases) {
lines.push(`- ${formatTypeAliasSignature(type)}`)
}
}
if (ast.enums && ast.enums.length > 0) {
for (const enumInfo of ast.enums) {
lines.push(`- ${formatEnumSignature(enumInfo)}`)
}
}
if (lines.length === 1) {
return `- ${path}${flags}`
}
return lines.join("\n")
}
/**
* Format file summary in compact mode (just names, no signatures).
*/
function formatFileSummaryCompact(path: string, ast: FileAST, flags: string): string {
const parts: string[] = []
if (ast.functions.length > 0) {
const names = ast.functions.map((f) => f.name).join(", ")
parts.push(`fn: ${names}`)
}
if (ast.classes.length > 0) {
const names = ast.classes.map((c) => c.name).join(", ")
parts.push(`class: ${names}`)
}
if (ast.interfaces.length > 0) {
const names = ast.interfaces.map((i) => i.name).join(", ")
parts.push(`interface: ${names}`)
}
if (ast.typeAliases.length > 0) {
const names = ast.typeAliases.map((t) => t.name).join(", ")
parts.push(`type: ${names}`)
}
if (ast.enums && ast.enums.length > 0) {
const names = ast.enums.map((e) => e.name).join(", ")
parts.push(`enum: ${names}`)
}
const summary = parts.length > 0 ? ` [${parts.join(" | ")}]` : ""
return `- ${path}${summary}${flags}`
}
/**
* Format file metadata flags.
*/
function formatFileFlags(meta?: FileMeta): string {
if (!meta) {
return ""
}
const flags: string[] = []
if (meta.isHub) {
flags.push("hub")
}
if (meta.isEntryPoint) {
flags.push("entry")
}
if (meta.complexity.score > 70) {
flags.push("complex")
}
return flags.length > 0 ? ` (${flags.join(", ")})` : ""
}
/**
* Shorten a file path for display in dependency graph.
* Removes common prefixes like "src/" and file extensions.
*/
function shortenPath(path: string): string {
let short = path
if (short.startsWith("src/")) {
short = short.slice(4)
}
// Remove common extensions
short = short.replace(/\.(ts|tsx|js|jsx)$/, "")
// Remove /index suffix
short = short.replace(/\/index$/, "")
return short
}
/**
* Format a single dependency graph entry.
* Format: "path: → dep1, dep2 ← dependent1, dependent2"
*/
function formatDepsEntry(path: string, dependencies: string[], dependents: string[]): string {
const parts: string[] = []
const shortPath = shortenPath(path)
if (dependencies.length > 0) {
const deps = dependencies.map(shortenPath).join(", ")
parts.push(`${deps}`)
}
if (dependents.length > 0) {
const deps = dependents.map(shortenPath).join(", ")
parts.push(`${deps}`)
}
if (parts.length === 0) {
return ""
}
return `${shortPath}: ${parts.join(" ")}`
}
/**
* Format dependency graph for all files.
* Shows hub files first, then files with dependencies/dependents.
*
* Format:
* ## Dependency Graph
* services/user: → types/user, utils/validation ← controllers/user
* services/auth: → services/user, utils/jwt ← controllers/auth
*/
export function formatDependencyGraph(metas: Map<string, FileMeta>): string | null {
if (metas.size === 0) {
return null
}
const entries: { path: string; deps: string[]; dependents: string[]; isHub: boolean }[] = []
for (const [path, meta] of metas) {
// Only include files that have connections
if (meta.dependencies.length > 0 || meta.dependents.length > 0) {
entries.push({
path,
deps: meta.dependencies,
dependents: meta.dependents,
isHub: meta.isHub,
})
}
}
if (entries.length === 0) {
return null
}
// Sort: hubs first, then by total connections (desc), then by path
entries.sort((a, b) => {
if (a.isHub !== b.isHub) {
return a.isHub ? -1 : 1
}
const aTotal = a.deps.length + a.dependents.length
const bTotal = b.deps.length + b.dependents.length
if (aTotal !== bTotal) {
return bTotal - aTotal
}
return a.path.localeCompare(b.path)
})
const lines: string[] = ["## Dependency Graph", ""]
for (const entry of entries) {
const line = formatDepsEntry(entry.path, entry.deps, entry.dependents)
if (line) {
lines.push(line)
}
}
// Return null if only header (no actual entries)
if (lines.length <= 2) {
return null
}
return lines.join("\n")
}
/**
* Format circular dependencies for display in context.
* Shows warning section with cycle chains.
*
* Format:
* ## ⚠️ Circular Dependencies
* - services/user → services/auth → services/user
* - utils/a → utils/b → utils/c → utils/a
*/
export function formatCircularDeps(cycles: string[][]): string | null {
if (!cycles || cycles.length === 0) {
return null
}
const lines: string[] = ["## ⚠️ Circular Dependencies", ""]
for (const cycle of cycles) {
if (cycle.length === 0) {
continue
}
const formattedCycle = cycle.map(shortenPath).join(" → ")
lines.push(`- ${formattedCycle}`)
}
// Return null if only header (no actual cycles)
if (lines.length <= 2) {
return null
}
return lines.join("\n")
}
/**
* Format high impact files table for display in context.
* Shows files with highest impact scores (most dependents).
* Includes both direct and transitive dependent counts.
*
* Format:
* ## High Impact Files
* | File | Impact | Direct | Transitive |
* |------|--------|--------|------------|
* | src/utils/validation.ts | 67% | 12 | 24 |
*
* @param metas - Map of file paths to their metadata
* @param limit - Maximum number of files to show (default: 10)
* @param minImpact - Minimum impact score to include (default: 5)
*/
export function formatHighImpactFiles(
metas: Map<string, FileMeta>,
limit = 10,
minImpact = 5,
): string | null {
if (metas.size === 0) {
return null
}
// Collect files with impact score >= minImpact
const impactFiles: {
path: string
impact: number
dependents: number
transitive: number
}[] = []
for (const [path, meta] of metas) {
if (meta.impactScore >= minImpact) {
impactFiles.push({
path,
impact: meta.impactScore,
dependents: meta.dependents.length,
transitive: meta.transitiveDepCount,
})
}
}
if (impactFiles.length === 0) {
return null
}
// Sort by transitive count descending, then by impact, then by path
impactFiles.sort((a, b) => {
if (a.transitive !== b.transitive) {
return b.transitive - a.transitive
}
if (a.impact !== b.impact) {
return b.impact - a.impact
}
return a.path.localeCompare(b.path)
})
// Take top N files
const topFiles = impactFiles.slice(0, limit)
const lines: string[] = [
"## High Impact Files",
"",
"| File | Impact | Direct | Transitive |",
"|------|--------|--------|------------|",
]
for (const file of topFiles) {
const shortPath = shortenPath(file.path)
const impact = `${String(file.impact)}%`
const direct = String(file.dependents)
const transitive = String(file.transitive)
lines.push(`| ${shortPath} | ${impact} | ${direct} | ${transitive} |`)
}
return lines.join("\n")
}
/**
* Format line range for display.
*/
function formatLineRange(start: number, end: number): string {
return `[${String(start)}-${String(end)}]`
}
/**
* Format imports section.
*/
function formatImportsSection(ast: FileAST): string[] {
if (ast.imports.length === 0) {
return []
}
const lines = ["### Imports"]
for (const imp of ast.imports) {
lines.push(`- ${imp.name} from "${imp.from}" (${imp.type})`)
}
lines.push("")
return lines
}
/**
* Format exports section.
*/
function formatExportsSection(ast: FileAST): string[] {
if (ast.exports.length === 0) {
return []
}
const lines = ["### Exports"]
for (const exp of ast.exports) {
const defaultMark = exp.isDefault ? " (default)" : ""
lines.push(`- ${exp.kind} ${exp.name}${defaultMark}`)
}
lines.push("")
return lines
}
/**
* Format functions section.
*/
function formatFunctionsSection(ast: FileAST): string[] {
if (ast.functions.length === 0) {
return []
}
const lines = ["### Functions"]
for (const fn of ast.functions) {
const params = fn.params.map((p) => p.name).join(", ")
const asyncMark = fn.isAsync ? "async " : ""
const range = formatLineRange(fn.lineStart, fn.lineEnd)
lines.push(`- ${asyncMark}${fn.name}(${params}) ${range}`)
}
lines.push("")
return lines
}
/**
* Format classes section.
*/
function formatClassesSection(ast: FileAST): string[] {
if (ast.classes.length === 0) {
return []
}
const lines = ["### Classes"]
for (const cls of ast.classes) {
const ext = cls.extends ? ` extends ${cls.extends}` : ""
const impl = cls.implements.length > 0 ? ` implements ${cls.implements.join(", ")}` : ""
const range = formatLineRange(cls.lineStart, cls.lineEnd)
lines.push(`- ${cls.name}${ext}${impl} ${range}`)
for (const method of cls.methods) {
const vis = method.visibility === "public" ? "" : `${method.visibility} `
const methodRange = formatLineRange(method.lineStart, method.lineEnd)
lines.push(` - ${vis}${method.name}() ${methodRange}`)
}
}
lines.push("")
return lines
}
/**
* Format metadata section.
*/
function formatMetadataSection(meta: FileMeta): string[] {
const loc = String(meta.complexity.loc)
const score = String(meta.complexity.score)
const deps = String(meta.dependencies.length)
const dependents = String(meta.dependents.length)
return [
"### Metadata",
`- LOC: ${loc}`,
`- Complexity: ${score}/100`,
`- Dependencies: ${deps}`,
`- Dependents: ${dependents}`,
]
}
/**
* Build context for a specific file request.
*/
export function buildFileContext(path: string, ast: FileAST, meta?: FileMeta): string {
const lines: string[] = [`## ${path}`, ""]
lines.push(...formatImportsSection(ast))
lines.push(...formatExportsSection(ast))
lines.push(...formatFunctionsSection(ast))
lines.push(...formatClassesSection(ast))
if (meta) {
lines.push(...formatMetadataSection(meta))
}
return lines.join("\n")
}
/**
* Truncate context to fit within token budget.
*/
export function truncateContext(context: string, maxTokens: number): string {
const charsPerToken = 4
const maxChars = maxTokens * charsPerToken
if (context.length <= maxChars) {
return context
}
const truncated = context.slice(0, maxChars - 100)
const lastNewline = truncated.lastIndexOf("\n")
const remaining = String(context.length - lastNewline)
return `${truncated.slice(0, lastNewline)}\n\n... (truncated, ${remaining} chars remaining)`
}