import type { AgentSession } from "./agent"; import type { Message } from "./types"; // Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ // est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ; // la nouvelle session (prompt système, persistance « nom__N ») est construite par l'application. /** Prompt envoyé au LLM pour obtenir le résumé. */ export const COMPACT_PROMPT = `Faire un résumé compacté de nos échanges. Retourne le résultat sous le format suivant : ## Contexte du projet - [Description brève du projet] ## Objectif actuel - [Ce qu'on cherche à faire] ## Fichiers pertinents - [Liste des fichiers importants] ## Décisions/Conclusions - [Points clés établis] ## Prochaines étapes - [Ce qui reste à faire]`; /** Nombre maximal de tokens que le LLM peut utiliser pour écrire le résumé. */ export const COMPACT_SUMMARY_TOKENS = 4000; /** Marge d'erreur (estimation des tokens). */ export const COMPACT_MARGIN_TOKENS = 500; /** Seuil de déclenchement automatique : Seuil_max = Max_Token_Context - (4000 + 500). */ export function compactThreshold(maxContextTokens: number): number { return maxContextTokens - (COMPACT_SUMMARY_TOKENS + COMPACT_MARGIN_TOKENS); } /** Vrai si la taille du contexte franchit le seuil (jamais si le seuil n'a pas de sens, ex. fenêtre minuscule). */ export function shouldCompact(contextTokens: number, maxContextTokens: number | null): boolean { if (maxContextTokens === null) return false; const threshold = compactThreshold(maxContextTokens); return threshold > 0 && contextTokens > threshold; } export interface CompactSummary { /** Résumé au format demandé. */ summary: string; /** true si le LLM a été coupé par la limite de 4000 tokens (le résumé peut être incomplet). */ truncated: boolean; } /** Rien à résumer : conversation vide et aucun contexte en attente. */ export function isCompactable(session: Pick): boolean { return session.messages.length > 0 || session.pendingContext.length > 0; } /** * Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée : * c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu). * Lève une erreur si le résumé est vide. */ export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise { // Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n"); const messages: Message[] = [...session.messages]; const last = messages.at(-1); if (last?.role === "user") { // L'alternance user/assistant doit être respectée : on complète le dernier message messages[messages.length - 1] = { role: "user", content: [...last.content, { type: "text", text: request }] }; } else { messages.push({ role: "user", content: [{ type: "text", text: request }] }); } let summary = ""; let truncated = false; for await (const ev of session.provider.chat( { // Les outils restent déclarés (l'historique contient des appels d'outils) mais ne doivent pas être utilisés system: `${session.system}\n\nPour cette demande, n'appelle aucun outil : réponds uniquement par le résumé demandé.`, messages, tools: [...session.tools.values()].map((t) => t.spec), maxTokens: COMPACT_SUMMARY_TOKENS, }, signal, )) { if (ev.type === "text_delta") summary += ev.text; else if (ev.type === "done" && ev.stopReason === "max_tokens") truncated = true; } summary = summary.trim(); if (!summary) throw new Error("Le modèle a renvoyé un résumé vide."); return { summary, truncated }; }