mcp-subagents
Version:
Multi-Agent AI Orchestration via Model Context Protocol - Access specialized CLI AI agents (Aider, Qwen, Gemini, Goose, etc.) with intelligent fallback and configuration
298 lines • 10.8 kB
JavaScript
/**
* Chunked Output Manager
*
* Manages task output in chunks for memory efficiency.
* Instead of storing every line individually, output is stored in chunks
* with a circular buffer to prevent unbounded memory growth.
*/
export class ChunkedOutput {
chunks = new Map();
nextChunkId = 0;
totalLines = 0;
maxChunks;
chunkSize;
currentChunk = null;
lastUpdateTime = Date.now();
newLineCallbacks = [];
constructor(options = {}) {
this.maxChunks = options.maxChunks || 100; // Limit to 100 chunks
this.chunkSize = options.chunkSize || 100; // 100 lines per chunk
}
/**
* Add new output lines
*/
addLines(lines) {
if (lines.length === 0)
return;
for (const line of lines) {
// Create new chunk if needed
if (!this.currentChunk || this.currentChunk.lines.length >= this.chunkSize) {
this.createNewChunk();
}
// Add line to current chunk
this.currentChunk.lines.push(line);
this.currentChunk.endLineNumber = this.totalLines;
this.totalLines++;
}
// Update timestamp and notify callbacks
this.lastUpdateTime = Date.now();
const callbacks = [...this.newLineCallbacks];
this.newLineCallbacks = [];
callbacks.forEach(callback => callback());
}
/**
* Get output lines with pagination and search
*/
getLines(options = {}) {
const { offset = 0, limit = 20, fromEnd = true, maxChars, search, context = 0, outputMode = 'content', ignoreCase = false, matchNumbers = false } = options;
// Convert all chunks to flat array for processing
const allLines = this.getAllLines();
if (search) {
return this.searchLines(allLines, search, {
context,
outputMode,
ignoreCase,
matchNumbers,
limit,
...(maxChars !== undefined && { maxChars })
});
}
// Handle pagination
let startIdx;
let endIdx;
if (fromEnd) {
// Count from end
startIdx = Math.max(0, allLines.length - offset - limit);
endIdx = Math.max(0, allLines.length - offset);
}
else {
// Count from beginning
startIdx = Math.min(offset, allLines.length);
endIdx = Math.min(offset + limit, allLines.length);
}
let resultLines = allLines.slice(startIdx, endIdx);
// Apply character limit if specified
if (maxChars && maxChars > 0) {
let totalChars = 0;
const limitedLines = [];
for (const line of resultLines) {
if (totalChars + line.length > maxChars) {
break;
}
limitedLines.push(line);
totalChars += line.length + 1; // +1 for newline
}
resultLines = limitedLines;
}
return {
lines: resultLines,
totalLines: this.totalLines,
hasMore: endIdx < allLines.length || (fromEnd && startIdx > 0)
};
}
/**
* Get total number of output lines
*/
getTotalLines() {
return this.totalLines;
}
/**
* Get memory usage statistics
*/
getStats() {
const chunkIds = Array.from(this.chunks.keys()).sort((a, b) => a - b);
const memoryBytes = Array.from(this.chunks.values())
.reduce((sum, chunk) => sum + chunk.lines.join('').length, 0);
const result = {
totalLines: this.totalLines,
totalChunks: this.chunks.size,
memoryEstimate: this.formatBytes(memoryBytes)
};
if (chunkIds.length > 0) {
result.oldestChunk = chunkIds[0];
result.newestChunk = chunkIds[chunkIds.length - 1];
}
return result;
}
/**
* Get lines with streaming support - can wait for new output
*/
async getLinesStreaming(options = {}) {
const { lastSeenLine = -1, waitForNew = false, maxWaitTime = 5000, includeLineNumbers = false, limit = 20, ...outputOptions } = options;
// First check if we have new lines since lastSeenLine
if (lastSeenLine < this.totalLines - 1) {
// We have new lines, return them immediately
const newLinesCount = lastSeenLine === -1 ? this.totalLines : this.totalLines - lastSeenLine - 1;
const result = this.getLines({
...outputOptions,
offset: lastSeenLine + 1,
limit,
fromEnd: false
});
return {
lines: result.lines.map((line, index) => ({
lineNumber: lastSeenLine + 1 + index,
content: line,
timestamp: this.lastUpdateTime
})),
totalLines: this.totalLines,
hasMore: result.hasMore,
lastSeenLine: Math.min(lastSeenLine + result.lines.length, this.totalLines - 1),
newLinesCount
};
}
// No new lines available
if (!waitForNew) {
return {
lines: [],
totalLines: this.totalLines,
hasMore: false,
lastSeenLine: this.totalLines - 1,
newLinesCount: 0
};
}
// Wait for new lines
const waitPromise = new Promise((resolve) => {
this.newLineCallbacks.push(resolve);
});
const timeoutPromise = new Promise((resolve) => {
setTimeout(resolve, maxWaitTime);
});
// Wait for either new lines or timeout
await Promise.race([waitPromise, timeoutPromise]);
// Check again for new lines
return this.getLinesStreaming({
...options,
waitForNew: false // Don't wait again
});
}
/**
* Get the last update timestamp
*/
getLastUpdateTime() {
return this.lastUpdateTime;
}
/**
* Create a new chunk and manage circular buffer
*/
createNewChunk() {
// Remove oldest chunk if we've hit the limit
if (this.chunks.size >= this.maxChunks) {
const oldestChunkId = Math.min(...this.chunks.keys());
this.chunks.delete(oldestChunkId);
}
// Create new chunk
this.currentChunk = {
id: this.nextChunkId++,
timestamp: Date.now(),
lines: [],
startLineNumber: this.totalLines,
endLineNumber: this.totalLines
};
this.chunks.set(this.currentChunk.id, this.currentChunk);
}
/**
* Convert all chunks to flat array of lines
*/
getAllLines() {
const chunkIds = Array.from(this.chunks.keys()).sort((a, b) => a - b);
const lines = [];
for (const chunkId of chunkIds) {
const chunk = this.chunks.get(chunkId);
if (chunk) {
lines.push(...chunk.lines);
}
}
return lines;
}
/**
* Search through lines with context and formatting options
*/
searchLines(lines, pattern, options) {
const { context, outputMode, ignoreCase, matchNumbers, limit, maxChars } = options;
try {
const regex = new RegExp(pattern, ignoreCase ? 'gi' : 'g');
const matches = [];
// Find all matches
lines.forEach((line, index) => {
const lineMatches = Array.from(line.matchAll(regex));
for (const match of lineMatches) {
matches.push({
lineIndex: index,
line,
match: match[0]
});
}
});
if (outputMode === 'count') {
return {
lines: [`Total matches: ${matches.length}`],
totalLines: this.totalLines,
hasMore: false,
searchMatches: matches.length
};
}
if (outputMode === 'matches_only') {
const matchStrings = matches.map(m => m.match);
return {
lines: matchStrings.slice(0, limit),
totalLines: this.totalLines,
hasMore: matchStrings.length > (limit || 20),
searchMatches: matches.length
};
}
// Content mode with context
const resultLines = [];
const addedLines = new Set();
let totalChars = 0;
for (const match of matches) {
if (limit && resultLines.length >= limit)
break;
if (maxChars && totalChars >= maxChars)
break;
// Add context lines around match
const startLine = Math.max(0, match.lineIndex - context);
const endLine = Math.min(lines.length - 1, match.lineIndex + context);
for (let i = startLine; i <= endLine; i++) {
if (!addedLines.has(i)) {
const line = lines[i];
const linePrefix = matchNumbers ? `${i + 1}: ` : '';
const fullLine = linePrefix + line;
if (maxChars && totalChars + fullLine.length > maxChars)
break;
resultLines.push(fullLine);
addedLines.add(i);
totalChars += fullLine.length + 1;
}
}
}
return {
lines: resultLines,
totalLines: this.totalLines,
hasMore: matches.length > (limit || 20) || resultLines.length < matches.length,
searchMatches: matches.length
};
}
catch (error) {
// Invalid regex pattern
return {
lines: [`Error: Invalid search pattern: ${error instanceof Error ? error.message : 'Unknown error'}`],
totalLines: this.totalLines,
hasMore: false,
searchMatches: 0
};
}
}
/**
* Format bytes as human-readable string
*/
formatBytes(bytes) {
if (bytes === 0)
return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
}
//# sourceMappingURL=chunked-output.js.map