aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
397 lines (339 loc) • 12.3 kB
JavaScript
/**
* Epic Context Accumulator
*
* Story 12.4: Progressive summarization with token control
* Builds accumulated context for Story N validation against current state
* within token limits.
*
* @module core/orchestration/epic-context-accumulator
* @version 1.0.0
*/
;
// Constants
const TOKEN_LIMIT = 8000;
const HARD_CAP_PER_STORY = 600;
const CHARS_PER_TOKEN = 3.5;
/**
* Compression levels for story context
* @enum {string}
*/
const CompressionLevel = {
FULL_DETAIL: 'full_detail',
METADATA_PLUS_FILES: 'metadata_plus_files',
METADATA_ONLY: 'metadata_only',
};
/**
* Fields included per compression level
*/
const COMPRESSION_FIELDS = {
[CompressionLevel.FULL_DETAIL]: [
'id', 'title', 'executor', 'quality_gate', 'status',
'acceptance_criteria', 'files_modified', 'dev_notes',
],
[CompressionLevel.METADATA_PLUS_FILES]: [
'id', 'title', 'executor', 'status', 'files_modified',
],
[CompressionLevel.METADATA_ONLY]: [
'id', 'executor', 'status',
],
};
/**
* Estimates token count for a text string
* @param {string} text - Text to estimate
* @returns {number} Estimated token count
*/
function estimateTokens(text) {
if (!text || typeof text !== 'string') return 0;
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
/**
* Determines compression level based on story distance from current
* @param {number} storyIndex - 0-based index of the story in the epic
* @param {number} currentN - 0-based index of the current story (Story N)
* @returns {string} CompressionLevel value
*/
function getCompressionLevel(storyIndex, currentN) {
const distance = currentN - storyIndex;
if (distance >= 1 && distance <= 3) {
return CompressionLevel.FULL_DETAIL;
}
if (distance >= 4 && distance <= 6) {
return CompressionLevel.METADATA_PLUS_FILES;
}
return CompressionLevel.METADATA_ONLY;
}
/**
* Builds an inverted file index from completed stories
* @param {Array<Object>} stories - Array of story objects with files_modified
* @returns {Map<string, Set<string>>} Map of file_path → Set of story_ids
*/
function buildFileIndex(stories) {
const index = new Map();
for (const story of stories) {
if (!story.files_modified || !Array.isArray(story.files_modified)) continue;
for (const filePath of story.files_modified) {
if (!index.has(filePath)) {
index.set(filePath, new Set());
}
index.get(filePath).add(story.id);
}
}
return index;
}
/**
* Checks if there is file overlap between a story and a set of target files
* @param {string[]} storyFiles - Files modified by the story
* @param {Map<string, Set<string>>|string[]} targetFiles - Target files to check against
* @returns {boolean} True if there is overlap
*/
function hasFileOverlap(storyFiles, targetFiles) {
if (!storyFiles || !Array.isArray(storyFiles) || storyFiles.length === 0) return false;
if (targetFiles instanceof Map || targetFiles instanceof Set) {
return storyFiles.some(file => targetFiles.has(file));
}
if (Array.isArray(targetFiles)) {
const targetSet = new Set(targetFiles);
return storyFiles.some(file => targetSet.has(file));
}
return false;
}
/**
* Truncates text to fit within a token limit
* @param {string} text - Text to truncate
* @param {number} maxTokens - Maximum tokens allowed
* @returns {string} Truncated text
*/
function truncateToTokens(text, maxTokens) {
if (!text || typeof text !== 'string') return '';
const maxChars = Math.floor(maxTokens * CHARS_PER_TOKEN);
if (text.length <= maxChars) return text;
return text.substring(0, maxChars) + '...';
}
/**
* Formats a single story entry based on compression level
* @param {Object} story - Story data
* @param {string} level - CompressionLevel
* @returns {string} Formatted story string
*/
function formatStoryEntry(story, level) {
const fields = COMPRESSION_FIELDS[level];
if (!fields) return '';
const parts = [];
for (const field of fields) {
const value = story[field];
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
parts.push(`${field}: [${value.join(', ')}]`);
} else {
parts.push(`${field}: ${value}`);
}
}
const entry = parts.join(' | ');
// Apply hard cap per story
const tokens = estimateTokens(entry);
if (tokens > HARD_CAP_PER_STORY) {
return truncateToTokens(entry, HARD_CAP_PER_STORY);
}
return entry;
}
/**
* Epic Context Accumulator class
* Builds progressive context summaries for story validation
*/
class EpicContextAccumulator {
/**
* @param {import('./session-state').SessionState} sessionState - SessionState instance
*/
constructor(sessionState) {
this.sessionState = sessionState;
this.fileIndex = null;
}
/**
* Builds accumulated context for a story within an epic
* @param {string} epicId - Epic ID
* @param {number} storyN - 0-based index of the current story
* @param {Object} options - Additional options
* @param {string[]} options.filesToModify - Files that Story N will modify (for overlap detection)
* @param {string} options.executor - Executor of Story N (for executor match)
* @returns {string} Optimized context string within token limits
*/
buildAccumulatedContext(epicId, storyN, options = {}) {
const { filesToModify = [], executor = null } = options;
const state = this.sessionState.state;
if (!state || !state.session_state) {
return '';
}
const { progress, context_snapshot } = state.session_state;
const storiesDone = progress.stories_done || [];
// No completed stories — return empty context
if (storiesDone.length === 0) {
return '';
}
// Build file index for O(1) lookups
const stories = this._getStoriesData(storiesDone);
this.fileIndex = buildFileIndex(stories);
// Create target files set for overlap detection
const targetFilesSet = new Set(filesToModify);
// Assign compression levels with exceptions
const entries = [];
for (let i = 0; i < stories.length; i++) {
const story = stories[i];
let level = getCompressionLevel(i, storyN);
// Apply exceptions (upgrade only, never downgrade, never to full_detail)
level = this._applyExceptions(story, level, targetFilesSet, executor);
entries.push({ story, level, index: i });
}
// Format entries
let formattedEntries = entries.map(({ story, level }) => formatStoryEntry(story, level));
// Apply token limit with compression cascade
formattedEntries = this._applyCompressionCascade(entries, formattedEntries, storyN);
// Build final context
const header = `Epic ${epicId} Context (${storiesDone.length} stories completed):`;
const executorDist = context_snapshot.executor_distribution || {};
const executorSummary = Object.entries(executorDist)
.map(([agent, count]) => `${agent}: ${count}`)
.join(', ');
const sections = [header];
if (executorSummary) {
sections.push(`Executors: ${executorSummary}`);
}
sections.push('---');
sections.push(...formattedEntries.filter(e => e.length > 0));
return sections.join('\n');
}
/**
* Applies exception rules to compression level
* @param {Object} story - Story data
* @param {string} currentLevel - Current compression level
* @param {Set<string>} targetFiles - Files Story N will modify
* @param {string|null} currentExecutor - Executor of Story N
* @returns {string} Adjusted compression level
* @private
*/
_applyExceptions(story, currentLevel, targetFiles, currentExecutor) {
// Exceptions only upgrade, never downgrade
// Exceptions never promote to full_detail
if (currentLevel === CompressionLevel.FULL_DETAIL) {
return currentLevel;
}
const targetLevel = CompressionLevel.METADATA_PLUS_FILES;
// Exception: file overlap
if (currentLevel === CompressionLevel.METADATA_ONLY) {
if (story.files_modified && hasFileOverlap(story.files_modified, targetFiles)) {
return targetLevel;
}
}
// Exception: executor match
if (currentLevel === CompressionLevel.METADATA_ONLY) {
if (currentExecutor && story.executor === currentExecutor) {
return targetLevel;
}
}
return currentLevel;
}
/**
* Applies compression cascade to fit within token limit
* @param {Array<Object>} entries - Story entries with levels
* @param {string[]} formattedEntries - Formatted entry strings
* @param {number} storyN - Current story index
* @returns {string[]} Adjusted entries within token limit
* @private
*/
_applyCompressionCascade(entries, formattedEntries, storyN) {
const totalText = formattedEntries.join('\n');
let totalTokens = estimateTokens(totalText);
if (totalTokens <= TOKEN_LIMIT) {
return formattedEntries;
}
// Working copy
const result = [...formattedEntries];
const workingEntries = entries.map((e, i) => ({ ...e, formatted: result[i] }));
// Cascade 1: metadata_only on oldest stories (N-7+)
for (let i = 0; i < workingEntries.length; i++) {
const distance = storyN - workingEntries[i].index;
if (distance >= 7 && workingEntries[i].level !== CompressionLevel.METADATA_ONLY) {
workingEntries[i].level = CompressionLevel.METADATA_ONLY;
result[i] = formatStoryEntry(workingEntries[i].story, CompressionLevel.METADATA_ONLY);
}
}
totalTokens = estimateTokens(result.join('\n'));
if (totalTokens <= TOKEN_LIMIT) return result;
// Cascade 2: Remove files_modified from medium stories (N-6 to N-4)
for (let i = 0; i < workingEntries.length; i++) {
const distance = storyN - workingEntries[i].index;
if (distance >= 4 && distance <= 6) {
workingEntries[i].level = CompressionLevel.METADATA_ONLY;
result[i] = formatStoryEntry(workingEntries[i].story, CompressionLevel.METADATA_ONLY);
}
}
totalTokens = estimateTokens(result.join('\n'));
if (totalTokens <= TOKEN_LIMIT) return result;
// Cascade 3: Truncate dev_notes from recent stories (N-3 to N-1)
for (let i = 0; i < workingEntries.length; i++) {
const distance = storyN - workingEntries[i].index;
if (distance >= 1 && distance <= 3) {
const story = { ...workingEntries[i].story };
story.dev_notes = truncateToTokens(story.dev_notes || '', 50);
result[i] = formatStoryEntry(story, CompressionLevel.FULL_DETAIL);
}
}
totalTokens = estimateTokens(result.join('\n'));
if (totalTokens <= TOKEN_LIMIT) return result;
// Cascade 4: Remove acceptance_criteria from recent stories
for (let i = 0; i < workingEntries.length; i++) {
const distance = storyN - workingEntries[i].index;
if (distance >= 1 && distance <= 3) {
const story = { ...workingEntries[i].story };
delete story.acceptance_criteria;
delete story.dev_notes;
result[i] = formatStoryEntry(story, CompressionLevel.FULL_DETAIL);
}
}
return result;
}
/**
* Extracts story data from stories_done array
* Stories in session-state may be stored as IDs or objects
* @param {Array<string|Object>} storiesDone - Completed stories
* @returns {Array<Object>} Story data objects
* @private
*/
_getStoriesData(storiesDone) {
return storiesDone.map((story, index) => {
if (typeof story === 'string') {
return { id: story, index };
}
return { ...story, index };
});
}
/**
* Gets the current file index
* @returns {Map<string, Set<string>>|null} File index or null if not built
*/
getFileIndex() {
return this.fileIndex;
}
}
/**
* Creates a new EpicContextAccumulator instance
* @param {import('./session-state').SessionState} sessionState
* @returns {EpicContextAccumulator}
*/
function createEpicContextAccumulator(sessionState) {
return new EpicContextAccumulator(sessionState);
}
module.exports = {
EpicContextAccumulator,
createEpicContextAccumulator,
CompressionLevel,
COMPRESSION_FIELDS,
estimateTokens,
getCompressionLevel,
buildFileIndex,
hasFileOverlap,
truncateToTokens,
formatStoryEntry,
TOKEN_LIMIT,
HARD_CAP_PER_STORY,
CHARS_PER_TOKEN,
};