feat: add project-scoped agent memory system (#351)

* memory

* feat: add smart memory selection with task context

- Add taskContext parameter to loadContextFiles for intelligent file selection
- Memory files are scored based on tag matching with task keywords
- Category name matching (e.g., "terminals" matches terminals.md) with 4x weight
- Usage statistics influence scoring (files that helped before rank higher)
- Limit to top 5 files + always include gotchas.md
- Auto-mode passes feature title/description as context
- Chat sessions pass user message as context

This prevents loading 40+ memory files and killing context limits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: enhance auto-mode service and context loader

- Improved context loading by adding task context for better memory selection.
- Updated JSON parsing logic to handle various formats and ensure robust error handling.
- Introduced file locking mechanisms to prevent race conditions during memory file updates.
- Enhanced metadata handling in memory files, including validation and sanitization.
- Refactored scoring logic for context files to improve selection accuracy based on task relevance.

These changes optimize memory file management and enhance the overall performance of the auto-mode service.

* refactor: enhance learning extraction and formatting in auto-mode service

- Improved the learning extraction process by refining the user prompt to focus on meaningful insights and structured JSON output.
- Updated the LearningEntry interface to include additional context fields for better documentation of decisions and patterns.
- Enhanced the formatLearning function to adopt an Architecture Decision Record (ADR) style, providing richer context for recorded learnings.
- Added detailed logging for better traceability during the learning extraction and appending processes.

These changes aim to improve the quality and clarity of learnings captured during the auto-mode service's operation.

* feat: integrate stripProviderPrefix utility for model ID handling

- Added stripProviderPrefix utility to various routes to ensure providers receive bare model IDs.
- Updated model references in executeQuery calls across multiple files, enhancing consistency in model ID handling.
- Introduced memoryExtractionModel in settings for improved learning extraction tasks.

These changes streamline the model ID processing and enhance the overall functionality of the provider interactions.

* feat: enhance error handling and server offline management in board actions

- Improved error handling in the handleRunFeature and handleStartImplementation functions to throw errors for better caller management.
- Integrated connection error detection and server offline handling, redirecting users to the login page when the server is unreachable.
- Updated follow-up feature logic to include rollback mechanisms and improved user feedback for error scenarios.

These changes enhance the robustness of the board actions by ensuring proper error management and user experience during server connectivity issues.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: webdevcody <webdevcody@gmail.com>
This commit is contained in:
SuperComboGamer
2026-01-09 15:11:59 -05:00
committed by GitHub
parent 7e768b6290
commit b2cf17b53b
20 changed files with 1535 additions and 160 deletions

View File

@@ -2,15 +2,30 @@
* Context Loader - Loads project context files for agent prompts
*
* Provides a shared utility to load context files from .automaker/context/
* and format them as system prompt content. Used by both auto-mode-service
* and agent-service to ensure all agents are aware of project 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
@@ -30,22 +45,48 @@ export interface ContextFileInfo {
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;
}
/**
@@ -56,6 +97,14 @@ export interface LoadContextFilesOptions {
projectPath: string;
/** Optional custom secure fs module (for dependency injection) */
fsModule?: ContextFsModule;
/** 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;
}
/**
@@ -130,17 +179,21 @@ ${formattedFiles.join('\n\n---\n\n')}
/**
* 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 and formatted prompt
* @returns Promise resolving to context files, memory files, and formatted prompt
*
* @example
* ```typescript
* const { formattedPrompt, files } = await loadContextFiles({
* const { formattedPrompt, files, memoryFiles } = await loadContextFiles({
* projectPath: '/path/to/project'
* });
*
@@ -154,9 +207,20 @@ ${formattedFiles.join('\n\n---\n\n')}
export async function loadContextFiles(
options: LoadContextFilesOptions
): Promise<ContextFilesResult> {
const { projectPath, fsModule = secureFs } = options;
const {
projectPath,
fsModule = secureFs,
includeMemory = true,
initializeMemory = true,
taskContext,
maxMemoryFiles = 5,
} = options;
const contextDir = path.resolve(getContextDir(projectPath));
const files: ContextFileInfo[] = [];
const memoryFiles: MemoryFileInfo[] = [];
// Load context files
try {
// Check if directory exists
await fsModule.access(contextDir);
@@ -170,41 +234,218 @@ export async function loadContextFiles(
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
if (textFiles.length === 0) {
return { files: [], formattedPrompt: '' };
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 metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule);
// Load memory files if enabled (with smart selection)
if (includeMemory) {
const memoryDir = getMemoryDir(projectPath);
// Load each file with its content and metadata
const files: ContextFileInfo[] = [];
for (const fileName of textFiles) {
const filePath = path.join(contextDir, fileName);
// Initialize memory folder if needed
if (initializeMemory) {
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);
await initializeMemoryFolder(projectPath, fsModule as MemoryFsModule);
} catch {
// Initialization failed, continue without memory
}
}
const formattedPrompt = buildContextPrompt(files);
try {
await fsModule.access(memoryDir);
const allMemoryFiles = await fsModule.readdir(memoryDir);
console.log(
`[ContextLoader] Loaded ${files.length} context file(s): ${files.map((f) => f.name).join(', ')}`
);
// 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';
});
return { files, formattedPrompt };
} catch {
// Context directory doesn't exist or is inaccessible - this is fine
return { files: [], formattedPrompt: '' };
// 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')}
---
`;
}
/**