UNPKG

@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
/** * 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