/** * Context Loader - Loads project context files for agent prompts * * Provides a shared utility to load context files from .automaker/context/ * and memory files from .automaker/memory/, formatting them as system prompt * content. Used by both auto-mode-service and agent-service to ensure all * agents are aware of project context and past learnings. * * Context files contain project-specific rules, conventions, and guidelines * that agents must follow when working on the project. * * Memory files contain learnings from past agent work, including decisions, * gotchas, and patterns that should inform future work. */ import path from 'path'; import { secureFs } from '@automaker/platform'; import { getMemoryDir, parseFrontmatter, initializeMemoryFolder, extractTerms, calculateUsageScore, countMatches, incrementUsageStat, type MemoryFsModule, type MemoryMetadata, } from './memory-loader.js'; /** * Metadata structure for context files * Stored in {projectPath}/.automaker/context/context-metadata.json */ export interface ContextMetadata { files: Record; } /** * Individual context file with metadata */ export interface ContextFileInfo { name: string; path: string; content: string; description?: string; } /** * Memory file info (from .automaker/memory/) */ export interface MemoryFileInfo { name: string; path: string; content: string; category: string; } /** * Result of loading context files */ export interface ContextFilesResult { files: ContextFileInfo[]; memoryFiles: MemoryFileInfo[]; formattedPrompt: string; } /** * File system module interface for context loading * Compatible with secureFs from @automaker/platform * Includes write methods needed for memory initialization */ export interface ContextFsModule { access: (path: string) => Promise; readdir: (path: string) => Promise; readFile: (path: string, encoding?: BufferEncoding) => Promise; // Write methods needed for memory operations writeFile: (path: string, content: string) => Promise; mkdir: (path: string, options?: { recursive?: boolean }) => Promise; appendFile: (path: string, content: string) => Promise; } /** * Task context for smart memory selection */ export interface TaskContext { /** Title or name of the current task/feature */ title: string; /** Description of what the task involves */ description?: string; } /** * Options for loading context files */ export interface LoadContextFilesOptions { /** Project path to load context from */ projectPath: string; /** Optional custom secure fs module (for dependency injection) */ fsModule?: ContextFsModule; /** Whether to include context files from .automaker/context/ (default: true) */ includeContextFiles?: boolean; /** Whether to include memory files from .automaker/memory/ (default: true) */ includeMemory?: boolean; /** Whether to initialize memory folder if it doesn't exist (default: true) */ initializeMemory?: boolean; /** Task context for smart memory selection - if not provided, only loads high-importance files */ taskContext?: TaskContext; /** Maximum number of memory files to load (default: 5) */ maxMemoryFiles?: number; } /** * Get the context directory path for a project */ function getContextDir(projectPath: string): string { return path.join(projectPath, '.automaker', 'context'); } /** * Load context metadata from the metadata file */ async function loadContextMetadata( contextDir: string, fsModule: ContextFsModule ): Promise { const metadataPath = path.join(contextDir, 'context-metadata.json'); try { const content = await fsModule.readFile(metadataPath, 'utf-8'); return JSON.parse(content as string); } catch { // Metadata file doesn't exist yet - that's fine return { files: {} }; } } /** * Format a single context file entry for the prompt */ function formatContextFileEntry(file: ContextFileInfo): string { const header = `## ${file.name}`; const pathInfo = `**Path:** \`${file.path}\``; let descriptionInfo = ''; if (file.description) { descriptionInfo = `\n**Purpose:** ${file.description}`; } return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`; } /** * Build the formatted system prompt from context files */ function buildContextPrompt(files: ContextFileInfo[]): string { if (files.length === 0) { return ''; } const formattedFiles = files.map(formatContextFileEntry); return `# Project Context Files The following context files provide project-specific rules, conventions, and guidelines. Each file serves a specific purpose - use the description to understand when to reference it. If you need more details about a context file, you can read the full file at the path provided. **IMPORTANT**: You MUST follow the rules and conventions specified in these files. - Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`) - Follow ALL coding conventions, commit message formats, and architectural patterns specified - Reference these rules before running ANY shell commands or making commits --- ${formattedFiles.join('\n\n---\n\n')} --- **REMINDER**: Before taking any action, verify you are following the conventions specified above. `; } /** * Load context files from a project's .automaker/context/ directory * and optionally memory files from .automaker/memory/ * * This function loads all .md and .txt files from the context directory, * along with their metadata (descriptions), and formats them into a * system prompt that can be prepended to agent prompts. * * By default, it also loads memory files containing learnings from past * agent work, which helps agents make better decisions. * * @param options - Configuration options * @returns Promise resolving to context files, memory files, and formatted prompt * * @example * ```typescript * const { formattedPrompt, files, memoryFiles } = await loadContextFiles({ * projectPath: '/path/to/project' * }); * * // Use as system prompt * const executeOptions = { * prompt: userPrompt, * systemPrompt: formattedPrompt, * }; * ``` */ export async function loadContextFiles( options: LoadContextFilesOptions ): Promise { const { projectPath, fsModule = secureFs, includeContextFiles = true, includeMemory = true, initializeMemory = true, taskContext, maxMemoryFiles = 5, } = options; const contextDir = path.resolve(getContextDir(projectPath)); const files: ContextFileInfo[] = []; const memoryFiles: MemoryFileInfo[] = []; // Load context files if enabled if (includeContextFiles) { try { // Check if directory exists await fsModule.access(contextDir); // Read directory contents const allFiles = await fsModule.readdir(contextDir); // Filter for text-based context files (case-insensitive for cross-platform) const textFiles = allFiles.filter((f) => { const lower = f.toLowerCase(); return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json'; }); if (textFiles.length > 0) { // Load metadata for descriptions const metadata = await loadContextMetadata(contextDir, fsModule); // Load each file with its content and metadata for (const fileName of textFiles) { const filePath = path.join(contextDir, fileName); try { const content = await fsModule.readFile(filePath, 'utf-8'); files.push({ name: fileName, path: filePath, content: content as string, description: metadata.files[fileName]?.description, }); } catch (error) { console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error); } } } } catch { // Context directory doesn't exist or is inaccessible - that's fine } } // Load memory files if enabled (with smart selection) if (includeMemory) { const memoryDir = getMemoryDir(projectPath); // Initialize memory folder if needed if (initializeMemory) { try { await initializeMemoryFolder(projectPath, fsModule as MemoryFsModule); } catch { // Initialization failed, continue without memory } } try { await fsModule.access(memoryDir); const allMemoryFiles = await fsModule.readdir(memoryDir); // Filter for markdown memory files (except _index.md, case-insensitive) const memoryMdFiles = allMemoryFiles.filter((f) => { const lower = f.toLowerCase(); return lower.endsWith('.md') && lower !== '_index.md'; }); // Extract terms from task context for matching const taskTerms = taskContext ? extractTerms(taskContext.title + ' ' + (taskContext.description || '')) : []; // Score and load memory files const scoredFiles: Array<{ fileName: string; filePath: string; body: string; metadata: MemoryMetadata; score: number; }> = []; for (const fileName of memoryMdFiles) { const filePath = path.join(memoryDir, fileName); try { const rawContent = await fsModule.readFile(filePath, 'utf-8'); const { metadata, body } = parseFrontmatter(rawContent as string); // Skip empty files if (!body.trim()) continue; // Calculate relevance score let score = 0; if (taskTerms.length > 0) { // Match task terms against file metadata const tagScore = countMatches(metadata.tags, taskTerms) * 3; const relevantToScore = countMatches(metadata.relevantTo, taskTerms) * 2; const summaryTerms = extractTerms(metadata.summary); const summaryScore = countMatches(summaryTerms, taskTerms); // Split category name on hyphens/underscores for better matching // e.g., "authentication-decisions" matches "authentication" const categoryTerms = fileName .replace('.md', '') .split(/[-_]/) .filter((t) => t.length > 2); const categoryScore = countMatches(categoryTerms, taskTerms) * 4; // Usage-based scoring (files that helped before rank higher) const usageScore = calculateUsageScore(metadata.usageStats); score = (tagScore + relevantToScore + summaryScore + categoryScore) * metadata.importance * usageScore; } else { // No task context - use importance as score score = metadata.importance; } scoredFiles.push({ fileName, filePath, body, metadata, score }); } catch (error) { console.warn(`[ContextLoader] Failed to read memory file ${fileName}:`, error); } } // Sort by score (highest first) scoredFiles.sort((a, b) => b.score - a.score); // Select files to load: // 1. Always include gotchas.md if it exists (unless maxMemoryFiles=0) // 2. Include high-importance files (importance >= 0.9) // 3. Include top scoring files up to maxMemoryFiles const selectedFiles = new Set(); // Skip selection if maxMemoryFiles is 0 if (maxMemoryFiles > 0) { // Always include gotchas.md const gotchasFile = scoredFiles.find((f) => f.fileName === 'gotchas.md'); if (gotchasFile) { selectedFiles.add('gotchas.md'); } // Add high-importance files for (const file of scoredFiles) { if (file.metadata.importance >= 0.9 && selectedFiles.size < maxMemoryFiles) { selectedFiles.add(file.fileName); } } // Add top scoring files (if we have task context and room) if (taskTerms.length > 0) { for (const file of scoredFiles) { if (file.score > 0 && selectedFiles.size < maxMemoryFiles) { selectedFiles.add(file.fileName); } } } } // Load selected files and increment loaded stat for (const file of scoredFiles) { if (selectedFiles.has(file.fileName)) { memoryFiles.push({ name: file.fileName, path: file.filePath, content: file.body, category: file.fileName.replace('.md', ''), }); // Increment the 'loaded' stat for this file (CRITICAL FIX) // This makes calculateUsageScore work correctly try { await incrementUsageStat(file.filePath, 'loaded', fsModule as MemoryFsModule); } catch { // Non-critical - continue even if stat update fails } } } if (memoryFiles.length > 0) { const selectedNames = memoryFiles.map((f) => f.category).join(', '); console.log(`[ContextLoader] Selected memory files: ${selectedNames}`); } } catch { // Memory directory doesn't exist - that's fine } } // Build combined prompt const contextPrompt = buildContextPrompt(files); const memoryPrompt = buildMemoryPrompt(memoryFiles); const formattedPrompt = [contextPrompt, memoryPrompt].filter(Boolean).join('\n\n'); const loadedItems = []; if (files.length > 0) { loadedItems.push(`${files.length} context file(s)`); } if (memoryFiles.length > 0) { loadedItems.push(`${memoryFiles.length} memory file(s)`); } if (loadedItems.length > 0) { console.log(`[ContextLoader] Loaded ${loadedItems.join(' and ')}`); } return { files, memoryFiles, formattedPrompt }; } /** * Build a formatted prompt from memory files */ function buildMemoryPrompt(memoryFiles: MemoryFileInfo[]): string { if (memoryFiles.length === 0) { return ''; } const sections = memoryFiles.map((file) => { return `## ${file.category.toUpperCase()} ${file.content}`; }); return `# Project Memory The following learnings and decisions from previous work are available. **IMPORTANT**: Review these carefully before making changes that could conflict with past decisions. --- ${sections.join('\n\n---\n\n')} --- `; } /** * Get a summary of available context files (names and descriptions only) * Useful for informing the agent about what context is available without * loading full content. */ export async function getContextFilesSummary( options: LoadContextFilesOptions ): Promise> { const { projectPath, fsModule = secureFs } = options; const contextDir = path.resolve(getContextDir(projectPath)); try { await fsModule.access(contextDir); const allFiles = await fsModule.readdir(contextDir); const textFiles = allFiles.filter((f) => { const lower = f.toLowerCase(); return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json'; }); if (textFiles.length === 0) { return []; } const metadata = await loadContextMetadata(contextDir, fsModule); return textFiles.map((fileName) => ({ name: fileName, path: path.join(contextDir, fileName), description: metadata.files[fileName]?.description, })); } catch { return []; } }