@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
401 lines • 14.2 kB
JavaScript
/**
* Compression configuration constants
*/
export const COMPRESSION_CONSTANTS = {
/** Default number of recent messages to keep at full detail */
DEFAULT_KEEP_RECENT_MESSAGES: 2,
/** Character threshold for compressing user messages */
USER_MESSAGE_COMPRESSION_THRESHOLD: 500,
/** Character threshold for compressing assistant messages with tool_calls */
ASSISTANT_WITH_TOOLS_THRESHOLD: 300,
/** Target length for aggressive text truncation */
AGGRESSIVE_TRUNCATION_LIMIT: 100,
/** Target length for default text truncation */
DEFAULT_TRUNCATION_LIMIT: 200,
/** Target length for aggressive assistant message truncation */
AGGRESSIVE_ASSISTANT_LIMIT: 150,
/** Target length for default assistant message truncation */
DEFAULT_ASSISTANT_LIMIT: 300,
/** Minimum valid auto-compact threshold percentage */
MIN_THRESHOLD_PERCENT: 50,
/** Maximum valid auto-compact threshold percentage */
MAX_THRESHOLD_PERCENT: 95,
/** Character threshold for compressing user messages in conservative mode */
CONSERVATIVE_USER_MESSAGE_THRESHOLD: 1000,
/** Target length for conservative mode text truncation */
CONSERVATIVE_TRUNCATION_LIMIT: 500,
};
/**
* Compress message history while preserving essential information
* @param messages - Original messages to compress
* @param tokenizer - Tokenizer for counting tokens
* @param options - Compression options
* @returns Compression result with compressed messages and statistics
*/
export function compressMessages(messages, tokenizer, options) {
if (messages.length === 0) {
return {
compressedMessages: [],
originalTokenCount: 0,
compressedTokenCount: 0,
reductionPercentage: 0,
preservedInfo: {
keyDecisions: 0,
fileModifications: 0,
toolResults: 0,
recentMessages: 0,
},
};
}
const keepRecent = options.keepRecentMessages ??
COMPRESSION_CONSTANTS.DEFAULT_KEEP_RECENT_MESSAGES;
const originalTokenCount = countTotalTokens(messages, tokenizer);
// Separate messages into: system, compressible, and recent (keep full)
const systemMessages = [];
const compressibleMessages = [];
const recentMessages = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === 'system') {
systemMessages.push(msg);
}
else if (i >= messages.length - keepRecent) {
recentMessages.push(msg);
}
else {
compressibleMessages.push(msg);
}
}
// Compress the compressible messages
const compressed = compressMessageSegment(compressibleMessages, options.mode);
// Combine: system + compressed + recent
const compressedMessages = [
...systemMessages,
...compressed,
...recentMessages,
];
const compressedTokenCount = countTotalTokens(compressedMessages, tokenizer);
const reductionPercentage = originalTokenCount > 0
? ((originalTokenCount - compressedTokenCount) / originalTokenCount) * 100
: 0;
// Count preserved information
const preservedInfo = {
keyDecisions: countKeyDecisions(compressed),
fileModifications: countFileModifications(compressed),
toolResults: countToolResults(compressed),
recentMessages: recentMessages.length,
};
return {
compressedMessages,
originalTokenCount,
compressedTokenCount,
reductionPercentage,
preservedInfo,
};
}
// Compress a segment of messages excluding system and recent
function compressMessageSegment(messages, mode) {
if (messages.length === 0) {
return [];
}
const compressed = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
// Handle tool messages
if (msg.role === 'tool') {
const compressedTool = compressToolResult(msg, mode);
compressed.push(compressedTool);
i++;
continue;
}
// Handle assistant messages with tool calls
if (msg.role === 'assistant' &&
msg.tool_calls &&
msg.tool_calls.length > 0) {
// Keep assistant message with tool calls, but compress content if verbose
const compressedMsg = compressAssistantMessage(msg, mode);
compressed.push(compressedMsg);
i++;
continue;
}
// Handle user messages, summarize if verbose
if (msg.role === 'user') {
const compressedUser = compressUserMessage(msg, mode);
compressed.push(compressedUser);
i++;
continue;
}
// Handle assistant messages, summarize if verbose
if (msg.role === 'assistant') {
const compressedAssistant = compressAssistantMessage(msg, mode);
compressed.push(compressedAssistant);
i++;
continue;
}
// Unknown role keep
compressed.push(msg);
i++;
}
return compressed;
}
// Compress a tool result message, Keep things like facts,terminal logs, verbose JSON, etc.
function compressToolResult(msg, mode) {
if (msg.role !== 'tool' || !msg.name) {
return msg;
}
const toolName = msg.name;
const content = msg.content || '';
// Extract error status
const hasError = checkForError(content);
// Compress based on mode
let compressedContent;
if (mode === 'aggressive') {
// Most aggressive: just outcome
compressedContent = hasError
? compressToolError(toolName, content)
: `Tool: ${toolName}\nResult: success`;
}
else if (mode === 'conservative') {
// Conservative: keep more detail
compressedContent = compressToolResultConservative(toolName, content);
}
else {
// Default: balanced
compressedContent = compressToolResultDefault(toolName, content);
}
return {
...msg,
content: compressedContent,
};
}
// Compress tool result in default mode
function compressToolResultDefault(toolName, content) {
const error = extractErrorInfo(content);
if (error) {
return `Tool: ${toolName}\nError: ${error.type}\n${error.details}\nResolved: ${error.resolved ? 'yes' : 'no'}`;
}
// Check for success indicators
if (isSuccess(content)) {
return `Tool: ${toolName}\nResult: success`;
}
// Extract key information like first line, key metrics, etc.
const lines = content.split('\n');
const firstLine = lines[0] || '';
const keyInfo = extractKeyInfo(content);
if (keyInfo) {
return `Tool: ${toolName}\nResult: ${keyInfo}`;
}
// Fallback: truncate to first meaningful line
return `Tool: ${toolName}\nResult: ${firstLine.substring(0, 100)}`;
}
// Compress tool result in conservative mode
function compressToolResultConservative(toolName, content) {
const error = extractErrorInfo(content);
if (error) {
return `Tool: ${toolName}\nError: ${error.type}\n${error.details}\nResolved: ${error.resolved ? 'yes' : 'no'}`;
}
// Keep first few lines and summary
const lines = content.split('\n').filter(line => line.trim());
const importantLines = lines.slice(0, 3);
const summary = importantLines.join('\n');
return `Tool: ${toolName}\nResult: ${summary}${lines.length > 3 ? '...' : ''}`;
}
// Compress tool error
function compressToolError(toolName, content) {
const error = extractErrorInfo(content);
if (error) {
return `Tool: ${toolName}\nError: ${error.type}\n${error.details}\nResolved: ${error.resolved ? 'yes' : 'no'}`;
}
return `Tool: ${toolName}\nError: (error occurred)`;
}
// Extract error information from tool result
function extractErrorInfo(content) {
// Look for common error patterns
const errorPatterns = [/Error:\s*(\w+)/i, /(\w+Error):/i, /(\w+Exception):/i];
for (const pattern of errorPatterns) {
const match = content.match(pattern);
if (match) {
const errorType = match[1];
// Try to extract file and line if present
const fileMatch = content.match(/File:\s*([^\n]+)/i);
const lineMatch = content.match(/Line:\s*(\d+)/i);
let details = errorType;
if (fileMatch) {
details += ` in ${fileMatch[1]}`;
if (lineMatch) {
details += `:${lineMatch[1]}`;
}
}
// Check if resolved
const resolved = /fixed|resolved|success|working/i.test(content) &&
!/failed|error|broken/i.test(content.slice(content.indexOf(match[0]) + match[0].length));
return {
type: errorType,
details,
resolved,
};
}
}
return null;
}
// Check if content indicates success
function isSuccess(content) {
const successPatterns = [
/^success$/i,
/^ok$/i,
/^done$/i,
/completed successfully/i,
/no errors/i,
];
return successPatterns.some(pattern => pattern.test(content));
}
// Check for error in content
function checkForError(content) {
return /error|exception|failed|failure/i.test(content);
}
// Extract key information from tool result
function extractKeyInfo(content) {
// Look for key metrics or summary lines
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
return null;
}
// Prefer short, informative lines
for (const line of lines.slice(0, 5)) {
if (line.length < 100 && !line.includes('...') && !line.includes('node:')) {
return line;
}
}
return null;
}
// Compress user message
function compressUserMessage(msg, mode) {
if (msg.role !== 'user') {
return msg;
}
const content = msg.content || '';
// Conservative mode: only compress very long messages (>1000 chars)
if (mode === 'conservative') {
if (content.length > COMPRESSION_CONSTANTS.CONSERVATIVE_USER_MESSAGE_THRESHOLD) {
const summary = summarizeText(content, COMPRESSION_CONSTANTS.CONSERVATIVE_TRUNCATION_LIMIT);
return {
...msg,
content: summary,
};
}
return msg;
}
// Default/Aggressive: summarize if very long
if (content.length > COMPRESSION_CONSTANTS.USER_MESSAGE_COMPRESSION_THRESHOLD) {
const summary = summarizeText(content, mode === 'aggressive'
? COMPRESSION_CONSTANTS.AGGRESSIVE_TRUNCATION_LIMIT
: COMPRESSION_CONSTANTS.DEFAULT_TRUNCATION_LIMIT);
return {
...msg,
content: summary,
};
}
return msg;
}
// Compress assistant message
function compressAssistantMessage(msg, mode) {
if (msg.role !== 'assistant') {
return msg;
}
const content = msg.content || '';
// Keep tool calls as is
if (msg.tool_calls && msg.tool_calls.length > 0) {
// Compress content if verbose, but keep tool calls
if (content.length > COMPRESSION_CONSTANTS.ASSISTANT_WITH_TOOLS_THRESHOLD &&
mode !== 'conservative') {
return {
...msg,
content: summarizeText(content, mode === 'aggressive'
? COMPRESSION_CONSTANTS.AGGRESSIVE_TRUNCATION_LIMIT
: COMPRESSION_CONSTANTS.DEFAULT_TRUNCATION_LIMIT),
};
}
return msg;
}
// Conservative mode: keep as is
if (mode === 'conservative') {
return msg;
}
// Default/Aggressive: summarize if very long
if (content.length > COMPRESSION_CONSTANTS.USER_MESSAGE_COMPRESSION_THRESHOLD) {
const summary = summarizeText(content, mode === 'aggressive'
? COMPRESSION_CONSTANTS.AGGRESSIVE_ASSISTANT_LIMIT
: COMPRESSION_CONSTANTS.DEFAULT_ASSISTANT_LIMIT);
return {
...msg,
content: summary,
};
}
return msg;
}
// Summarize text to target length
function summarizeText(text, targetLength) {
if (text.length <= targetLength) {
return text;
}
// Try to keep first sentence/paragraph
const sentences = text.split(/[.!?]\s+/);
let summary = '';
for (const sentence of sentences) {
if ((summary + sentence).length <= targetLength - 3) {
summary += sentence + '. ';
}
else {
break;
}
}
if (summary) {
return summary.trim() + '...';
}
// Fallback
return text.substring(0, targetLength - 3) + '...';
}
// Count total tokens in messages
function countTotalTokens(messages, tokenizer) {
let total = 0;
for (const msg of messages) {
total += tokenizer.countTokens(msg);
}
return total;
}
// Count key decisions in compressed messages
function countKeyDecisions(messages) {
// Look for messages with decision indicators
let count = 0;
for (const msg of messages) {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = (msg.content || '').toLowerCase();
if (/decided|decision|chose|chosen|will use|using|selected|choose/i.test(content)) {
count++;
}
}
}
return count;
}
// Count file modifications
function countFileModifications(messages) {
let count = 0;
for (const msg of messages) {
if (msg.role === 'tool' && msg.name) {
const toolName = msg.name.toLowerCase();
if (toolName.includes('write') ||
toolName.includes('edit') ||
toolName.includes('create') ||
toolName.includes('modify')) {
count++;
}
}
}
return count;
}
// Count tool results
function countToolResults(messages) {
return messages.filter(msg => msg.role === 'tool').length;
}
//# sourceMappingURL=message-compression.js.map