Files
automaker/libs/utils/src/context-loader.ts
Monoquark 5a3dac1533 feat: Add ideation context settings
- Add settings popover to the ideation view
- Migrate previous context to toggles (memory, context, features, ideas)
- Add app specifications as new context option
2026-01-24 12:30:20 +01:00

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 [];
}
}