hyp / libs /utils /src /memory-loader.ts
Leon4gr45's picture
Upload folder using huggingface_hub
1dbc34b verified
/**
* Memory Loader - Smart loading of agent memory files
*
* Loads relevant memory files from .automaker/memory/ based on:
* - Tag matching with feature keywords
* - Historical usefulness (usage stats)
* - File importance
*
* Memory files use YAML frontmatter for metadata.
*/
import path from 'path';
/**
* File system module interface (compatible with secureFs)
*/
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>;
}
/**
* Usage statistics for learning which files are helpful
*/
export interface UsageStats {
loaded: number;
referenced: number;
successfulFeatures: number;
}
/**
* Metadata stored in YAML frontmatter of memory files
*/
export interface MemoryMetadata {
tags: string[];
summary: string;
relevantTo: string[];
importance: number;
relatedFiles: string[];
usageStats: UsageStats;
}
/**
* A loaded memory file with content and metadata
*/
export interface MemoryFile {
name: string;
content: string;
metadata: MemoryMetadata;
}
/**
* Result of loading memory files
*/
export interface MemoryLoadResult {
files: MemoryFile[];
formattedPrompt: string;
}
/**
* Learning entry to be recorded
* Based on Architecture Decision Record (ADR) format for rich context
*/
export interface LearningEntry {
category: string;
type: 'decision' | 'learning' | 'pattern' | 'gotcha';
content: string;
context?: string; // Problem being solved or situation faced
why?: string; // Reasoning behind the approach
rejected?: string; // Alternative considered and why rejected
tradeoffs?: string; // What became easier/harder
breaking?: string; // What breaks if changed/removed
}
/**
* Create default metadata for new memory files
* Returns a new object each time to avoid shared mutable state
*/
function createDefaultMetadata(): MemoryMetadata {
return {
tags: [],
summary: '',
relevantTo: [],
importance: 0.5,
relatedFiles: [],
usageStats: {
loaded: 0,
referenced: 0,
successfulFeatures: 0,
},
};
}
/**
* In-memory locks to prevent race conditions when updating files
*/
const fileLocks = new Map<string, Promise<void>>();
/**
* Acquire a lock for a file path, execute the operation, then release
*/
async function withFileLock<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
// Wait for any existing lock on this file
const existingLock = fileLocks.get(filePath);
if (existingLock) {
await existingLock;
}
// Create a new lock
let releaseLock: () => void;
const lockPromise = new Promise<void>((resolve) => {
releaseLock = resolve;
});
fileLocks.set(filePath, lockPromise);
try {
return await operation();
} finally {
releaseLock!();
fileLocks.delete(filePath);
}
}
/**
* Get the memory directory path for a project
*/
export function getMemoryDir(projectPath: string): string {
return path.join(projectPath, '.automaker', 'memory');
}
/**
* Parse YAML frontmatter from markdown content
* Returns the metadata and the content without frontmatter
*/
export function parseFrontmatter(content: string): {
metadata: MemoryMetadata;
body: string;
} {
// Handle both Unix (\n) and Windows (\r\n) line endings
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 {
// Simple YAML parsing for our specific format
const metadata: MemoryMetadata = createDefaultMetadata();
// Parse tags: [tag1, tag2, tag3]
const tagsMatch = frontmatterStr.match(/tags:\s*\[(.*?)\]/);
if (tagsMatch) {
metadata.tags = tagsMatch[1]
.split(',')
.map((t) => t.trim().replace(/['"]/g, ''))
.filter((t) => t.length > 0); // Filter out empty strings
}
// Parse summary
const summaryMatch = frontmatterStr.match(/summary:\s*(.+)/);
if (summaryMatch) {
metadata.summary = summaryMatch[1].trim().replace(/^["']|["']$/g, '');
}
// Parse relevantTo: [term1, term2]
const relevantMatch = frontmatterStr.match(/relevantTo:\s*\[(.*?)\]/);
if (relevantMatch) {
metadata.relevantTo = relevantMatch[1]
.split(',')
.map((t) => t.trim().replace(/['"]/g, ''))
.filter((t) => t.length > 0); // Filter out empty strings
}
// Parse importance (validate range 0-1)
const importanceMatch = frontmatterStr.match(/importance:\s*([\d.]+)/);
if (importanceMatch) {
const value = parseFloat(importanceMatch[1]);
metadata.importance = Math.max(0, Math.min(1, value)); // Clamp to 0-1
}
// Parse relatedFiles: [file1.md, file2.md]
const relatedMatch = frontmatterStr.match(/relatedFiles:\s*\[(.*?)\]/);
if (relatedMatch) {
metadata.relatedFiles = relatedMatch[1]
.split(',')
.map((t) => t.trim().replace(/['"]/g, ''))
.filter((t) => t.length > 0); // Filter out empty strings
}
// Parse usageStats
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 };
}
}
/**
* Escape a string for safe YAML output
* Quotes strings containing special characters
*/
function escapeYamlString(str: string): string {
// If string contains special YAML characters, wrap in quotes
if (/[:\[\]{}#&*!|>'"%@`\n\r]/.test(str) || str.trim() !== str) {
// Escape any existing quotes and wrap in double quotes
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}`);
}
}