| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import path from 'path'; |
|
|
| |
| |
| |
| export interface MemoryFsModule { |
| access: (path: string) => Promise<void>; |
| readdir: (path: string) => Promise<string[]>; |
| readFile: (path: string, encoding?: BufferEncoding) => Promise<string | Buffer>; |
| writeFile: (path: string, content: string) => Promise<void>; |
| mkdir: (path: string, options?: { recursive?: boolean }) => Promise<string | undefined>; |
| appendFile: (path: string, content: string) => Promise<void>; |
| } |
|
|
| |
| |
| |
| export interface UsageStats { |
| loaded: number; |
| referenced: number; |
| successfulFeatures: number; |
| } |
|
|
| |
| |
| |
| export interface MemoryMetadata { |
| tags: string[]; |
| summary: string; |
| relevantTo: string[]; |
| importance: number; |
| relatedFiles: string[]; |
| usageStats: UsageStats; |
| } |
|
|
| |
| |
| |
| export interface MemoryFile { |
| name: string; |
| content: string; |
| metadata: MemoryMetadata; |
| } |
|
|
| |
| |
| |
| export interface MemoryLoadResult { |
| files: MemoryFile[]; |
| formattedPrompt: string; |
| } |
|
|
| |
| |
| |
| |
| export interface LearningEntry { |
| category: string; |
| type: 'decision' | 'learning' | 'pattern' | 'gotcha'; |
| content: string; |
| context?: string; |
| why?: string; |
| rejected?: string; |
| tradeoffs?: string; |
| breaking?: string; |
| } |
|
|
| |
| |
| |
| |
| function createDefaultMetadata(): MemoryMetadata { |
| return { |
| tags: [], |
| summary: '', |
| relevantTo: [], |
| importance: 0.5, |
| relatedFiles: [], |
| usageStats: { |
| loaded: 0, |
| referenced: 0, |
| successfulFeatures: 0, |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| const fileLocks = new Map<string, Promise<void>>(); |
|
|
| |
| |
| |
| async function withFileLock<T>(filePath: string, operation: () => Promise<T>): Promise<T> { |
| |
| const existingLock = fileLocks.get(filePath); |
| if (existingLock) { |
| await existingLock; |
| } |
|
|
| |
| let releaseLock: () => void; |
| const lockPromise = new Promise<void>((resolve) => { |
| releaseLock = resolve; |
| }); |
| fileLocks.set(filePath, lockPromise); |
|
|
| try { |
| return await operation(); |
| } finally { |
| releaseLock!(); |
| fileLocks.delete(filePath); |
| } |
| } |
|
|
| |
| |
| |
| export function getMemoryDir(projectPath: string): string { |
| return path.join(projectPath, '.automaker', 'memory'); |
| } |
|
|
| |
| |
| |
| |
| export function parseFrontmatter(content: string): { |
| metadata: MemoryMetadata; |
| body: string; |
| } { |
| |
| const frontmatterRegex = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n/; |
| const match = content.match(frontmatterRegex); |
|
|
| if (!match) { |
| return { metadata: createDefaultMetadata(), body: content }; |
| } |
|
|
| const frontmatterStr = match[1]; |
| const body = content.slice(match[0].length); |
|
|
| try { |
| |
| const metadata: MemoryMetadata = createDefaultMetadata(); |
|
|
| |
| const tagsMatch = frontmatterStr.match(/tags:\s*\[(.*?)\]/); |
| if (tagsMatch) { |
| metadata.tags = tagsMatch[1] |
| .split(',') |
| .map((t) => t.trim().replace(/['"]/g, '')) |
| .filter((t) => t.length > 0); |
| } |
|
|
| |
| const summaryMatch = frontmatterStr.match(/summary:\s*(.+)/); |
| if (summaryMatch) { |
| metadata.summary = summaryMatch[1].trim().replace(/^["']|["']$/g, ''); |
| } |
|
|
| |
| const relevantMatch = frontmatterStr.match(/relevantTo:\s*\[(.*?)\]/); |
| if (relevantMatch) { |
| metadata.relevantTo = relevantMatch[1] |
| .split(',') |
| .map((t) => t.trim().replace(/['"]/g, '')) |
| .filter((t) => t.length > 0); |
| } |
|
|
| |
| const importanceMatch = frontmatterStr.match(/importance:\s*([\d.]+)/); |
| if (importanceMatch) { |
| const value = parseFloat(importanceMatch[1]); |
| metadata.importance = Math.max(0, Math.min(1, value)); |
| } |
|
|
| |
| const relatedMatch = frontmatterStr.match(/relatedFiles:\s*\[(.*?)\]/); |
| if (relatedMatch) { |
| metadata.relatedFiles = relatedMatch[1] |
| .split(',') |
| .map((t) => t.trim().replace(/['"]/g, '')) |
| .filter((t) => t.length > 0); |
| } |
|
|
| |
| const loadedMatch = frontmatterStr.match(/loaded:\s*(\d+)/); |
| const referencedMatch = frontmatterStr.match(/referenced:\s*(\d+)/); |
| const successMatch = frontmatterStr.match(/successfulFeatures:\s*(\d+)/); |
|
|
| if (loadedMatch) metadata.usageStats.loaded = parseInt(loadedMatch[1], 10); |
| if (referencedMatch) metadata.usageStats.referenced = parseInt(referencedMatch[1], 10); |
| if (successMatch) metadata.usageStats.successfulFeatures = parseInt(successMatch[1], 10); |
|
|
| return { metadata, body }; |
| } catch { |
| return { metadata: createDefaultMetadata(), body: content }; |
| } |
| } |
|
|
| |
| |
| |
| |
| function escapeYamlString(str: string): string { |
| |
| if (/[:\[\]{}#&*!|>'"%@`\n\r]/.test(str) || str.trim() !== str) { |
| |
| return `"${str.replace(/"/g, '\\"')}"`; |
| } |
| return str; |
| } |
| |
| /** |
| * Serialize metadata back to YAML frontmatter |
| */ |
| export function serializeFrontmatter(metadata: MemoryMetadata): string { |
| const escapedTags = metadata.tags.map(escapeYamlString); |
| const escapedRelevantTo = metadata.relevantTo.map(escapeYamlString); |
| const escapedRelatedFiles = metadata.relatedFiles.map(escapeYamlString); |
| const escapedSummary = escapeYamlString(metadata.summary); |
| |
| return `--- |
| tags: [${escapedTags.join(', ')}] |
| summary: ${escapedSummary} |
| relevantTo: [${escapedRelevantTo.join(', ')}] |
| importance: ${metadata.importance} |
| relatedFiles: [${escapedRelatedFiles.join(', ')}] |
| usageStats: |
| loaded: ${metadata.usageStats.loaded} |
| referenced: ${metadata.usageStats.referenced} |
| successfulFeatures: ${metadata.usageStats.successfulFeatures} |
| ---`; |
| } |
| |
| /** |
| * Extract terms from text for matching |
| * Splits on spaces, removes common words, lowercases |
| */ |
| export function extractTerms(text: string): string[] { |
| const stopWords = new Set([ |
| 'a', |
| 'an', |
| 'the', |
| 'and', |
| 'or', |
| 'but', |
| 'in', |
| 'on', |
| 'at', |
| 'to', |
| 'for', |
| 'of', |
| 'with', |
| 'by', |
| 'is', |
| 'it', |
| 'this', |
| 'that', |
| 'be', |
| 'as', |
| 'are', |
| 'was', |
| 'were', |
| 'been', |
| 'being', |
| 'have', |
| 'has', |
| 'had', |
| 'do', |
| 'does', |
| 'did', |
| 'will', |
| 'would', |
| 'could', |
| 'should', |
| 'may', |
| 'might', |
| 'must', |
| 'shall', |
| 'can', |
| 'need', |
| 'dare', |
| 'ought', |
| 'used', |
| 'add', |
| 'create', |
| 'implement', |
| 'build', |
| 'make', |
| 'update', |
| 'fix', |
| 'change', |
| 'modify', |
| ]); |
| |
| return text |
| .toLowerCase() |
| .replace(/[^a-z0-9\s]/g, ' ') |
| .split(/\s+/) |
| .filter((word) => word.length > 2 && !stopWords.has(word)); |
| } |
| |
| /** |
| * Count how many terms match between two arrays (case-insensitive) |
| */ |
| export function countMatches(arr1: string[], arr2: string[]): number { |
| const set2 = new Set(arr2.map((t) => t.toLowerCase())); |
| return arr1.filter((t) => set2.has(t.toLowerCase())).length; |
| } |
| |
| /** |
| * Calculate usage-based score for a memory file |
| * Files that are referenced in successful features get higher scores |
| */ |
| export function calculateUsageScore(stats: UsageStats): number { |
| if (stats.loaded === 0) return 1; // New file, neutral score |
| |
| const referenceRate = stats.referenced / stats.loaded; |
| const successRate = stats.referenced > 0 ? stats.successfulFeatures / stats.referenced : 0; |
| |
| // Base 0.5 + up to 0.3 for reference rate + up to 0.2 for success rate |
| return 0.5 + referenceRate * 0.3 + successRate * 0.2; |
| } |
| |
| /** |
| * Load relevant memory files for a feature |
| * |
| * Selects files based on: |
| * - Tag matching with feature keywords (weight: 3) |
| * - RelevantTo matching (weight: 2) |
| * - Summary matching (weight: 1) |
| * - Usage score (multiplier) |
| * - Importance (multiplier) |
| * |
| * Always includes gotchas.md |
| */ |
| export async function loadRelevantMemory( |
| projectPath: string, |
| featureTitle: string, |
| featureDescription: string, |
| fsModule: MemoryFsModule |
| ): Promise<MemoryLoadResult> { |
| const memoryDir = getMemoryDir(projectPath); |
| |
| try { |
| await fsModule.access(memoryDir); |
| } catch { |
| // Memory directory doesn't exist yet |
| return { files: [], formattedPrompt: '' }; |
| } |
| |
| const allFiles = await fsModule.readdir(memoryDir); |
| const featureTerms = extractTerms(featureTitle + ' ' + featureDescription); |
| |
| // Score each file |
| const scored: Array<{ file: string; score: number; content: string; metadata: MemoryMetadata }> = |
| []; |
| |
| for (const file of allFiles) { |
| if (!file.endsWith('.md') || file === '_index.md') continue; |
| |
| const filePath = path.join(memoryDir, file); |
| try { |
| const content = (await fsModule.readFile(filePath, 'utf-8')) as string; |
| const { metadata, body } = parseFrontmatter(content); |
| |
| // Calculate relevance score |
| const tagScore = countMatches(metadata.tags, featureTerms) * 3; |
| const relevantToScore = countMatches(metadata.relevantTo, featureTerms) * 2; |
| const summaryTerms = extractTerms(metadata.summary); |
| const summaryScore = countMatches(summaryTerms, featureTerms); |
| |
| // Usage-based scoring |
| const usageScore = calculateUsageScore(metadata.usageStats); |
| |
| // Combined score |
| const score = (tagScore + relevantToScore + summaryScore) * metadata.importance * usageScore; |
| |
| // Include if score > 0 or high importance |
| if (score > 0 || metadata.importance >= 0.9) { |
| scored.push({ file, score, content: body, metadata }); |
| } |
| } catch { |
| // Skip files that can't be read |
| } |
| } |
| |
| // Sort by score, take top 5 |
| const topFiles = scored.sort((a, b) => b.score - a.score).slice(0, 5); |
| |
| // Always include gotchas.md if it exists |
| const toLoad = new Set(['gotchas.md', ...topFiles.map((f) => f.file)]); |
| |
| const loaded: MemoryFile[] = []; |
| for (const file of toLoad) { |
| const existing = scored.find((s) => s.file === file); |
| if (existing) { |
| loaded.push({ |
| name: file, |
| content: existing.content, |
| metadata: existing.metadata, |
| }); |
| } else if (file === 'gotchas.md') { |
| // Try to load gotchas.md even if it wasn't scored |
| const gotchasPath = path.join(memoryDir, 'gotchas.md'); |
| try { |
| const content = (await fsModule.readFile(gotchasPath, 'utf-8')) as string; |
| const { metadata, body } = parseFrontmatter(content); |
| loaded.push({ name: file, content: body, metadata }); |
| } catch { |
| // gotchas.md doesn't exist yet |
| } |
| } |
| } |
| |
| // Build formatted prompt |
| const formattedPrompt = buildMemoryPrompt(loaded); |
| |
| return { files: loaded, formattedPrompt }; |
| } |
| |
| /** |
| * Build a formatted prompt from loaded memory files |
| */ |
| function buildMemoryPrompt(files: MemoryFile[]): string { |
| if (files.length === 0) return ''; |
| |
| const sections = files.map((file) => { |
| return `## ${file.name.replace('.md', '').toUpperCase()} |
|
|
| ${file.content}`; |
| }); |
| |
| return `# Project Memory |
|
|
| The following learnings and decisions from previous work are relevant to this task. |
| **IMPORTANT**: Review these carefully before making changes that could conflict with past decisions. |
|
|
| --- |
|
|
| ${sections.join('\n\n---\n\n')} |
|
|
| --- |
| `; |
| } |
| |
| /** |
| * Increment a usage stat in a memory file |
| * Uses file locking to prevent race conditions from concurrent updates |
| */ |
| export async function incrementUsageStat( |
| filePath: string, |
| stat: keyof UsageStats, |
| fsModule: MemoryFsModule |
| ): Promise<void> { |
| await withFileLock(filePath, async () => { |
| try { |
| const content = (await fsModule.readFile(filePath, 'utf-8')) as string; |
| const { metadata, body } = parseFrontmatter(content); |
| |
| metadata.usageStats[stat]++; |
| |
| // serializeFrontmatter ends with "---", add newline before body |
| const newContent = serializeFrontmatter(metadata) + '\n' + body; |
| await fsModule.writeFile(filePath, newContent); |
| } catch { |
| // File doesn't exist or can't be updated - that's fine |
| } |
| }); |
| } |
| |
| /** |
| * Simple memory file reference for usage tracking |
| */ |
| export interface SimpleMemoryFile { |
| name: string; |
| content: string; |
| } |
| |
| /** |
| * Record memory usage after feature completion |
| * Updates usage stats based on what was actually referenced |
| */ |
| export async function recordMemoryUsage( |
| projectPath: string, |
| loadedFiles: SimpleMemoryFile[], |
| agentOutput: string, |
| success: boolean, |
| fsModule: MemoryFsModule |
| ): Promise<void> { |
| const memoryDir = getMemoryDir(projectPath); |
| |
| for (const file of loadedFiles) { |
| const filePath = path.join(memoryDir, file.name); |
| |
| // Check if agent actually referenced this file's content |
| // Simple heuristic: check if any significant terms from the file appear in output |
| const fileTerms = extractTerms(file.content); |
| const outputTerms = extractTerms(agentOutput); |
| const wasReferenced = countMatches(fileTerms, outputTerms) >= 3; |
| |
| if (wasReferenced) { |
| await incrementUsageStat(filePath, 'referenced', fsModule); |
| if (success) { |
| await incrementUsageStat(filePath, 'successfulFeatures', fsModule); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Format a learning entry for appending to a memory file |
| * Uses ADR-style format for rich context |
| */ |
| export function formatLearning(learning: LearningEntry): string { |
| const date = new Date().toISOString().split('T')[0]; |
| const lines: string[] = []; |
| |
| if (learning.type === 'decision') { |
| lines.push(`\n### ${learning.content} (${date})`); |
| if (learning.context) lines.push(`- **Context:** ${learning.context}`); |
| if (learning.why) lines.push(`- **Why:** ${learning.why}`); |
| if (learning.rejected) lines.push(`- **Rejected:** ${learning.rejected}`); |
| if (learning.tradeoffs) lines.push(`- **Trade-offs:** ${learning.tradeoffs}`); |
| if (learning.breaking) lines.push(`- **Breaking if changed:** ${learning.breaking}`); |
| return lines.join('\n'); |
| } |
| |
| if (learning.type === 'gotcha') { |
| lines.push(`\n#### [Gotcha] ${learning.content} (${date})`); |
| if (learning.context) lines.push(`- **Situation:** ${learning.context}`); |
| if (learning.why) lines.push(`- **Root cause:** ${learning.why}`); |
| if (learning.tradeoffs) lines.push(`- **How to avoid:** ${learning.tradeoffs}`); |
| return lines.join('\n'); |
| } |
| |
| // Pattern or learning |
| const prefix = learning.type === 'pattern' ? '[Pattern]' : '[Learned]'; |
| lines.push(`\n#### ${prefix} ${learning.content} (${date})`); |
| if (learning.context) lines.push(`- **Problem solved:** ${learning.context}`); |
| if (learning.why) lines.push(`- **Why this works:** ${learning.why}`); |
| if (learning.tradeoffs) lines.push(`- **Trade-offs:** ${learning.tradeoffs}`); |
| return lines.join('\n'); |
| } |
| |
| /** |
| * Append a learning to the appropriate category file |
| * Creates the file with frontmatter if it doesn't exist |
| * Uses file locking to prevent TOCTOU race conditions |
| */ |
| export async function appendLearning( |
| projectPath: string, |
| learning: LearningEntry, |
| fsModule: MemoryFsModule |
| ): Promise<void> { |
| console.log( |
| `[MemoryLoader] appendLearning called: category=${learning.category}, type=${learning.type}` |
| ); |
| const memoryDir = getMemoryDir(projectPath); |
| // Sanitize category name: lowercase, replace spaces with hyphens, remove special chars |
| const sanitizedCategory = learning.category |
| .toLowerCase() |
| .replace(/\s+/g, '-') |
| .replace(/[^a-z0-9-]/g, ''); |
| const fileName = `${sanitizedCategory || 'general'}.md`; |
| const filePath = path.join(memoryDir, fileName); |
| |
| // Use file locking to prevent race conditions when multiple processes |
| // try to create the same file simultaneously |
| await withFileLock(filePath, async () => { |
| try { |
| await fsModule.access(filePath); |
| // File exists, append to it |
| const formatted = formatLearning(learning); |
| await fsModule.appendFile(filePath, '\n' + formatted); |
| console.log(`[MemoryLoader] Appended learning to existing file: ${fileName}`); |
| } catch { |
| // File doesn't exist, create it with frontmatter |
| console.log(`[MemoryLoader] Creating new memory file: ${fileName}`); |
| const metadata: MemoryMetadata = { |
| tags: [sanitizedCategory || 'general'], |
| summary: `${learning.category} implementation decisions and patterns`, |
| relevantTo: [sanitizedCategory || 'general'], |
| importance: 0.7, |
| relatedFiles: [], |
| usageStats: { loaded: 0, referenced: 0, successfulFeatures: 0 }, |
| }; |
| |
| const content = |
| serializeFrontmatter(metadata) + `\n# ${learning.category}\n` + formatLearning(learning); |
| |
| await fsModule.writeFile(filePath, content); |
| } |
| }); |
| } |
| |
| /** |
| * Initialize the memory folder for a project |
| * Creates starter files if the folder doesn't exist |
| */ |
| export async function initializeMemoryFolder( |
| projectPath: string, |
| fsModule: MemoryFsModule |
| ): Promise<void> { |
| const memoryDir = getMemoryDir(projectPath); |
| |
| try { |
| await fsModule.access(memoryDir); |
| // Already exists |
| return; |
| } catch { |
| // Create the directory |
| await fsModule.mkdir(memoryDir, { recursive: true }); |
| |
| // Create _index.md |
| const indexMetadata: MemoryMetadata = { |
| tags: ['index', 'overview'], |
| summary: 'Overview of project memory categories', |
| relevantTo: ['project', 'memory', 'overview'], |
| importance: 0.5, |
| relatedFiles: [], |
| usageStats: { loaded: 0, referenced: 0, successfulFeatures: 0 }, |
| }; |
| |
| const indexContent = |
| serializeFrontmatter(indexMetadata) + |
| ` |
| # Project Memory Index |
|
|
| This folder contains agent learnings organized by category. |
| Categories are created automatically as agents work on features. |
|
|
| ## How This Works |
|
|
| 1. After each successful feature, learnings are extracted and categorized |
| 2. Relevant memory files are loaded into agent context for future features |
| 3. Usage statistics help prioritize which memories are most helpful |
|
|
| ## Categories |
|
|
| - **gotchas.md** - Mistakes and edge cases to avoid |
| - Other categories are created automatically based on feature work |
| `; |
| |
| await fsModule.writeFile(path.join(memoryDir, '_index.md'), indexContent); |
| |
| // Create gotchas.md |
| const gotchasMetadata: MemoryMetadata = { |
| tags: ['gotcha', 'mistake', 'edge-case', 'bug', 'warning'], |
| summary: 'Mistakes and edge cases to avoid', |
| relevantTo: ['error', 'bug', 'fix', 'issue', 'problem'], |
| importance: 0.9, |
| relatedFiles: [], |
| usageStats: { loaded: 0, referenced: 0, successfulFeatures: 0 }, |
| }; |
| |
| const gotchasContent = |
| serializeFrontmatter(gotchasMetadata) + |
| ` |
| # Gotchas |
|
|
| Mistakes and edge cases to avoid. These are lessons learned from past issues. |
|
|
| --- |
|
|
| `; |
| |
| await fsModule.writeFile(path.join(memoryDir, 'gotchas.md'), gotchasContent); |
| |
| console.log(`[MemoryLoader] Initialized memory folder at ${memoryDir}`); |
| } |
| } |
| |