// Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai // Context compiler: DB history and model context are separate concepts. // Compiles the active thread into a request that fits the model's window. import type { ChatMessageInput } from "@/lib/openrouter"; import type { MessageRow } from "@/lib/conversations"; /** Cheap token estimate: ~4 characters per token. Deliberately conservative. */ export function estimateTokens(text: string): number { return Math.ceil(text.length / 3.6); } export interface CompiledContext { messages: ChatMessageInput[]; estimatedPromptTokens: number; trimmedCount: number; } const RESERVED_OUTPUT_TOKENS = 4096; /** * Compile the thread for a request: keep system instructions, keep the most * recent messages, trim oldest low-value context when over budget. */ export function compileContext( thread: MessageRow[], contextLength: number | undefined, systemPrompt?: string ): CompiledContext { const budget = Math.max((contextLength ?? 128_000) - RESERVED_OUTPUT_TOKENS, 8_000); const system: ChatMessageInput[] = systemPrompt ? [{ role: "system", content: systemPrompt }] : []; const systemTokens = systemPrompt ? estimateTokens(systemPrompt) : 0; // Only completed / meaningful messages reach the model. const usable = thread.filter( (m) => (m.role === "user" || m.role === "assistant") && m.content.length > 0 ); // Walk from newest to oldest, accumulating until the budget is spent. const kept: MessageRow[] = []; let used = systemTokens; for (let i = usable.length - 1; i >= 0; i--) { const t = estimateTokens(usable[i].content) + 6; // per-message overhead if (used + t > budget && kept.length > 0) break; kept.push(usable[i]); used += t; } kept.reverse(); return { messages: [ ...system, ...kept.map((m): ChatMessageInput => ({ role: m.role as "user" | "assistant", content: m.content })), ], estimatedPromptTokens: used, trimmedCount: usable.length - kept.length, }; }