import type { Message } from '../models/types.js'; import type { AgentOrchestrator } from '../backends/native/orchestrator.js'; import type { MemoryStore } from '../memory/store.js'; import { COMPACTION_SYSTEM_PROMPT, MEMORY_EXTRACTION_PROMPT } from '../backends/native/prompts.js'; import { estimateMessageTokens } from './tokens.js'; export interface CompactionConfig { /** Percentage of context window that triggers compaction (default: 80). */ thresholdPct: number; /** Number of recent turns (user+assistant pairs) to always keep intact. */ keepTurns: number; /** Maximum tokens for the compaction summary response. */ summaryMaxTokens: number; } export interface CompactionResult { /** The compacted messages: [summary, ...recentMessages]. */ messages: Message[]; /** Number of messages that were compacted (removed). */ compactedCount: number; /** Estimated tokens before compaction. */ tokensBefore: number; /** Estimated tokens after compaction. */ tokensAfter: number; } export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { thresholdPct: 80, keepTurns: 4, summaryMaxTokens: 1024, }; export async function compactHistory(opts: { messages: Message[]; orchestrator: AgentOrchestrator; config: CompactionConfig; memoryStore?: MemoryStore; autoExtract?: boolean; }): Promise { const { messages, orchestrator, config } = opts; const keepCount = config.keepTurns * 2; if (messages.length <= keepCount) { return { messages, compactedCount: 0, tokensBefore: estimateMessageTokens(messages), tokensAfter: estimateMessageTokens(messages), }; } const toCompact = messages.slice(0, -keepCount); const toKeep = messages.slice(-keepCount); const formattedConversation = toCompact.map((msg) => `${msg.role}: ${msg.content}`).join('\n\n'); const tier = orchestrator.getDelegationTier('compaction'); const result = await orchestrator.delegate({ tier, systemPrompt: COMPACTION_SYSTEM_PROMPT, message: formattedConversation, maxTokens: config.summaryMaxTokens, }); const summaryMessage: Message = { role: 'assistant', content: '[Summary of earlier conversation]\n\n' + result.content, }; // Phase 2: Extract persistent facts and append to memory (if enabled) if (opts.memoryStore && opts.autoExtract !== false) { try { const extractionTier = orchestrator.getDelegationTier('memory_extraction'); const extraction = await orchestrator.delegate({ tier: extractionTier, systemPrompt: MEMORY_EXTRACTION_PROMPT, message: `Extract persistent facts from this conversation:\n\n${formattedConversation}`, maxTokens: 512, }); // Only write if the extraction produced meaningful content const extractedContent = extraction.content.trim(); if (extractedContent.length > 0 && !extractedContent.toLowerCase().includes('no facts')) { opts.memoryStore.write('global', extractedContent, 'append'); console.log(`[Flynn:memory] Extracted ${extractedContent.length} chars of facts to global memory`); } } catch (error) { // Memory extraction is best-effort — don't fail compaction if it errors console.warn('[Flynn:memory] Failed to extract facts during compaction:', error); } } return { messages: [summaryMessage, ...toKeep], compactedCount: toCompact.length, tokensBefore: estimateMessageTokens(messages), tokensAfter: estimateMessageTokens([summaryMessage, ...toKeep]), }; }