mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Add settings popover to the ideation view - Migrate previous context to toggles (memory, context, features, ideas) - Add app specifications as new context option
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
/**
|
|
* 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<string, { description: string }>;
|
|
}
|
|
|
|
/**
|
|
* 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<void>;
|
|
readdir: (path: string) => Promise<string[]>;
|
|
readFile: (path: string, encoding?: BufferEncoding) => Promise<string | Buffer>;
|
|
// Write methods needed for memory operations
|
|
writeFile: (path: string, content: string) => Promise<void>;
|
|
mkdir: (path: string, options?: { recursive?: boolean }) => Promise<string | undefined>;
|
|
appendFile: (path: string, content: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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<ContextMetadata> {
|
|
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<ContextFilesResult> {
|
|
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<string>();
|
|
|
|
// 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<Array<{ name: string; path: string; description?: string }>> {
|
|
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 [];
|
|
}
|
|
}
|