import OpenAI from "openai" import { Conversation, type Message } from "../agent/conversation" import { estimateTokens } from "../utils/tokens" import * as fs from "fs" import * as path from "path" import * as os from "os" export interface CompactResult { tokensSaved: number preCompactTokens: number postCompactTokens: number preCompactMessages: number postCompactMessages: number summary: string } const COMPACT_SYSTEM_PROMPT = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Your task is to create a detailed summary of the conversation so far. Include: 1. Primary Request and Intent: What the user asked for 2. Key Technical Concepts: Technologies, frameworks, patterns discussed 3. Files and Code Sections: Files examined, modified, or created (with key snippets) 4. Errors and Fixes: Problems encountered and how they were resolved 5. Problem Solving: What was accomplished 6. All User Messages: Summarize all non-tool-result user messages 7. Pending Tasks: Tasks explicitly requested but not yet done 8. Current Work: What was being worked on immediately before this summary Respond ONLY with plain text. No XML tags. No tool calls.` export async function compactConversation( client: OpenAI, model: string, conversation: Conversation, sessionsDir?: string, ): Promise { const messages = conversation.messages const preCompactTokens = estimateTokens( messages.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))).join(" "), ) const preCompactCount = messages.length // Save raw history before compacting saveRawHistory(conversation, sessionsDir) // Build context for summary: last N messages const contextMessages = messages.slice(-20) const summaryRequestMessages = contextMessages .filter((m) => m.role !== "system") .map( (m): OpenAI.ChatCompletionMessageParam => ({ role: m.role as "user" | "assistant", content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), }), ) summaryRequestMessages.push({ role: "user", content: COMPACT_SYSTEM_PROMPT }) let summary = "" try { const response = await client.chat.completions.create({ model, messages: summaryRequestMessages, max_tokens: 4096, temperature: 0, }) summary = response.choices[0]?.message?.content?.trim() ?? "" } catch { summary = buildFallbackSummary(messages) } if (!summary) { summary = buildFallbackSummary(messages) } // Replace conversation with boundary marker + summary const boundaryMsg: Message = { role: "system", content: `[COMPACT BOUNDARY] Compacted at ${new Date().toISOString()}. ${preCompactCount} messages summarized. Raw history preserved.`, } const summaryMsg: Message = { role: "system", content: `## Conversation Summary\n\n${summary}`, } // Keep system messages + boundary + summary const systemMessages = messages.filter((m) => m.role === "system" && m === messages[0]) conversation.messages.length = 0 conversation.messages.push(...systemMessages, boundaryMsg, summaryMsg) const postCompactTokens = estimateTokens( conversation.messages.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))).join(" "), ) const postCompactCount = conversation.messages.length return { tokensSaved: Math.max(0, preCompactTokens - postCompactTokens), preCompactTokens, postCompactTokens, preCompactMessages: preCompactCount, postCompactMessages: postCompactCount, summary, } } export function uncompactConversation(conversation: Conversation, sessionsDir?: string): boolean { const dir = sessionsDir ?? path.join(os.homedir(), ".llmtune", "sessions") const rawPath = path.join(dir, `${conversation.id}.raw.json`) if (!fs.existsSync(rawPath)) { return false } try { const raw = JSON.parse(fs.readFileSync(rawPath, "utf-8")) as { messages: Message[] } conversation.messages.length = 0 conversation.messages.push(...raw.messages) return true } catch { return false } } function saveRawHistory(conversation: Conversation, sessionsDir?: string): void { const dir = sessionsDir ?? path.join(os.homedir(), ".llmtune", "sessions") if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } const rawPath = path.join(dir, `${conversation.id}.raw.json`) if (!fs.existsSync(rawPath)) { fs.writeFileSync(rawPath, JSON.stringify({ messages: conversation.messages }, null, 2), "utf-8") } } function buildFallbackSummary(messages: Message[]): string { const userMsgs = messages .filter((m) => m.role === "user") .map((m) => (typeof m.content === "string" ? m.content.slice(0, 200) : "")) .filter(Boolean) const toolNames = messages .filter((m) => m.role === "assistant" && m.toolCalls) .flatMap((m) => m.toolCalls?.map((tc) => tc.function.name) ?? []) const parts = [`Conversation had ${messages.length} messages.`] if (toolNames.length > 0) { const unique = [...new Set(toolNames)] parts.push(`Tools used: ${unique.join(", ")}`) } if (userMsgs.length > 0) { parts.push(`Last user message: ${userMsgs[userMsgs.length - 1].slice(0, 150)}`) } return parts.join("\n") }