diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs
index 2677102..9d1e5a9 100644
--- a/rust/loki-rs/src/chat/compact.rs
+++ b/rust/loki-rs/src/chat/compact.rs
@@ -1 +1,3620 @@
-//! Lot 6 : module en cours de portage.
+//! Estimation du contexte et compactage de l'historique — port fidèle de
+//! `chat_compact.go` et `chat_compact_cont.go`.
+//!
+//! Compactage façon Hermes Agent : au lieu de vider la conversation quand la
+//! fenêtre se remplit, on la scinde en trois zones — tête (messages système,
+//! protégée), queue (tours récents dans un budget de jetons, protégée) et
+//! torse (tout le milieu, SEULE zone compactée). Le torse est dégraissé sans
+//! IA (repli), résumé par le modèle en UN appel, et remplacé par le résumé.
+//!
+//! Ce qui, en Go, venait d'un état global devient ici un paramètre explicite :
+//! - la configuration (`ReadConfig()` : COMPACT, CTX, COMPACT_CONTINUATION,
+//! EXTERNAL, MODEL) et la politique de raisonnement renvoyé
+//! (`currentEchoPolicy()`) → [`CompactConfig`] ;
+//! - l'appel au moteur pour le résumé (`postSummary` par la porte du moteur),
+//! la relecture du tampon du slot (`engineSlotHolds`) et la détection d'un
+//! appel d'outil écrit en texte (`textualToolCallSnippet`) → trait
+//! [`Summarizer`] ;
+//! - l'archive recall (`archiveRecallBlock`) → trait [`RecallArchive`] ;
+//! - la mémoire des refus et des échecs de la continuation (globales Go
+//! `compactRefused`, `compactContFails`) → [`CompactMemory`] ;
+//! - l'état des critères du mode Code (`critRender(critList(conv))`) → champ
+//! [`Compactor::criteria`] ;
+//! - la vue d'envoi du tour (`turnViewDry`, `turnReasoning`, `wireMessages`)
+//! → fermetures passées à [`compact_opts_for`] / [`compact_opts_mid`].
+//!
+//! Les traces de diagnostic partent sur la sortie d'erreur, comme en Go.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::future::Future;
+use std::sync::{Mutex, MutexGuard};
+
+use serde::Deserialize;
+use serde_json::{Map, Value};
+
+use super::types::{Message, Tool};
+
+// ---- constantes ----------------------------------------------------------
+
+/// Seuil de déclenchement proactif : on compacte quand l'historique estimé
+/// dépasse cette fraction de la fenêtre de contexte.
+pub const COMPACT_TRIGGER_FRAC: f64 = 0.75;
+/// Budget de la queue : fraction de la CONVERSATION gardée intacte.
+pub const COMPACT_TAIL_FRAC: f64 = 0.20;
+/// Un résultat d'outil du torse plus long que ça (octets) devient un marqueur
+/// dans le torse DÉGRAISSÉ (repli si le résumé échoue).
+pub const COMPACT_TOOL_PRUNE_LEN: usize = 200;
+/// Longueur (caractères) à laquelle on raccourcit un résultat d'outil avant de
+/// le donner au résumeur.
+pub const COMPACT_TOOL_SUMMARY_LEN: usize = 1200;
+
+/// Remplace un vieux résultat d'outil dans le torse dégraissé.
+pub const COMPACT_PRUNED_MARKER: &str = "[Old tool result removed to save context. The important content is in the summary above — do NOT call this tool again to fetch it back.]";
+
+/// Ouvre le message `user` synthétique qui porte le résumé.
+pub const COMPACT_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTED]";
+
+/// Remplace la partie `image_url` d'un message multimodal dans [`msg_text`].
+pub const IMAGE_LOST_MARKER: &str = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]";
+
+/// La réponse assistant synthétique qui suit le résumé.
+pub const COMPACT_ACK_MSG: &str = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done.";
+
+/// Ce qu'un résumé de mode Code doit garder en plus.
+pub const CODE_SUMMARY_RULES: &str = "\nThis is a CODING session. Also keep:\n- Every file created or modified (path) and what changed in it\n- Errors hit (build, tests, tools) and how each was resolved — or that it is still open\n- The commands that build and test the project";
+
+/// Le rôle du résumeur sur transcription.
+pub const COMPACT_TRANSCRIPT_INTRO: &str = "You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.\n\nThe assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.\n\n";
+
+/// Ce que tout résumé de compaction doit garder (transcription et continuation).
+pub const COMPACT_SUMMARY_RULES: &str = "Summarize densely and faithfully, keeping ONLY the essentials:\n- The user's CURRENT request, goal(s) and constraints\n- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.\n- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time\n- Decisions made and established facts\n- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step\nStrict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.\nWrite the summary in the SAME language as the conversation.";
+
+/// Température de toute requête de résumé.
+pub const SUMMARY_TEMPERATURE: f64 = 0.2;
+
+/// Marque de troncature de [`shrink_to_fit`].
+pub const SHRINK_TRUNC_MARKER: &str = "\n[…tronqué : contexte plein. Relance l'outil de façon plus ciblée si tu as besoin de la suite]";
+
+/// Borne de TOUT résultat d'outil dans la vue du modèle (caractères).
+pub const TOOL_RESULT_MAX: usize = 30000;
+
+/// Ouvre la demande de résumé en continuation : jamais rangée.
+pub const COMPACT_CONT_PREFIX: &str = "[COMPACTION]";
+
+/// Conversations suivies au plus par la mémoire des refus.
+pub const COMPACT_REFUSED_MAX: usize = 64;
+/// Échecs de la continuation au-delà desquels elle est suspendue pour le modèle.
+pub const COMPACT_CONT_MAX_FAILS: i64 = 2;
+
+/// Un bloc du torse plus long que ça (caractères) est archivé (`chat_recall.go`).
+pub const RECALL_ARCHIVE_MIN_LEN: usize = 800;
+/// Extrait de tête laissé au résumeur pour un bloc archivé (`chat_recall.go`).
+pub const RECALL_SUMMARY_HEAD_LEN: usize = 400;
+
+const THINK_OPEN: &str = "";
+const THINK_CLOSE: &str = "";
+
+// Rappels de loki (llm_injected.go) : reconnus pour ne pas les prendre pour
+// une demande de l'utilisateur.
+const LOKI_NOTE_PREFIX: &str = "[system] ";
+const THINK_NUDGE_FIRST: &str = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.";
+const THINK_NUDGE_STUCK: &str = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant.";
+const RETRY_CORRECTIVE_LEAD: &str =
+ "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n";
+const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.";
+const BUDGET_NUDGE_ENDS: [&str; 3] = [
+ "unless exactly one specific call is genuinely still missing.",
+ "say so in the answer instead of investigating further.",
+ "Write your final answer in this message. Do not call another tool.",
+];
+
+// Blocs (chat_projsnap.go).
+const CTX_UPD_OPEN: &str = "";
+
+// ---- configuration -------------------------------------------------------
+
+/// Politique de raisonnement renvoyé (`echoPolicy`, REASONING_ECHO) : ce qui
+/// décide si le raisonnement rangé compte dans le contexte. Défaut : coupé.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct EchoPolicy {
+ pub on: bool,
+ pub model: String,
+ pub keeps_past: bool,
+}
+
+/// Instantané de l'état global Go que lit la compaction.
+#[derive(Debug, Clone, PartialEq)]
+pub struct CompactConfig {
+ /// `compactEnabled()` (COMPACT).
+ pub enabled: bool,
+ /// `ctxWindow()` (CTX) — aussi la fenêtre de `engineMainNow()`.
+ pub ctx_window: i64,
+ /// `compactContinuationOn(cfg)` (COMPACT_CONTINUATION, moteur local).
+ pub continuation: bool,
+ /// Preset externe (`isExternalConfig` / `resolveChatEndpoint().External`).
+ pub external: bool,
+ /// Modèle de `engineMainNow()` : base du chemin MODEL.
+ pub engine_model: String,
+ /// `currentEchoPolicy()`.
+ pub echo: EchoPolicy,
+}
+
+impl Default for CompactConfig {
+ fn default() -> Self {
+ CompactConfig {
+ enabled: true,
+ ctx_window: 32768,
+ continuation: false,
+ external: false,
+ engine_model: ".".into(),
+ echo: EchoPolicy::default(),
+ }
+ }
+}
+
+impl CompactConfig {
+ /// Construit l'instantané depuis la carte de configuration (`ReadConfig()`)
+ /// et la politique d'écho courante (calculée ailleurs : sonde de gabarit,
+ /// mémoire des refus).
+ pub fn from_config(cfg: &HashMap, echo: EchoPolicy) -> Self {
+ CompactConfig {
+ enabled: compact_enabled(cfg),
+ ctx_window: ctx_window(cfg),
+ continuation: compact_continuation_on(cfg),
+ external: is_external_config(cfg),
+ engine_model: path_base(cfg.get("MODEL").map_or("", |s| s.trim())),
+ echo,
+ }
+ }
+}
+
+fn cfg_get<'a>(cfg: &'a HashMap, k: &str) -> &'a str {
+ cfg.get(k).map_or("", String::as_str)
+}
+
+/// `compactEnabled` : actif par défaut ; seul off/false/0/no/non le coupe.
+pub fn compact_enabled(cfg: &HashMap) -> bool {
+ !matches!(
+ cfg_get(cfg, "COMPACT").trim().to_lowercase().as_str(),
+ "off" | "false" | "0" | "no" | "non"
+ )
+}
+
+/// `ctxWindow` : CTX, 32768 par défaut.
+pub fn ctx_window(cfg: &HashMap) -> i64 {
+ match cfg_get(cfg, "CTX").parse::() {
+ Ok(n) if n > 0 => n,
+ _ => 32768,
+ }
+}
+
+/// `isExternalConfig` : EXTERNAL = "1".
+pub fn is_external_config(cfg: &HashMap) -> bool {
+ cfg_get(cfg, "EXTERNAL").trim() == "1"
+}
+
+/// `compactContinuationOn` : clé posée, moteur local.
+pub fn compact_continuation_on(cfg: &HashMap) -> bool {
+ if is_external_config(cfg) {
+ return false;
+ }
+ matches!(
+ cfg_get(cfg, "COMPACT_CONTINUATION")
+ .trim()
+ .to_lowercase()
+ .as_str(),
+ "on" | "1" | "true" | "yes" | "oui"
+ )
+}
+
+/// `filepath.Base` : dernier élément du chemin, "." pour un chemin vide.
+fn path_base(p: &str) -> String {
+ let sep = |c: char| c == '/' || (cfg!(windows) && c == '\\');
+ if p.is_empty() {
+ return ".".into();
+ }
+ let t = p.trim_end_matches(sep);
+ if t.is_empty() {
+ return if cfg!(windows) && p.starts_with('\\') {
+ "\\".into()
+ } else {
+ "/".into()
+ };
+ }
+ t.rsplit(sep).next().unwrap_or(t).to_string()
+}
+
+/// `compactSummaryBudget` : budget du résumé, ~4 % de la fenêtre, borné à
+/// 700..1600.
+pub fn compact_summary_budget(ctx_window: i64) -> i64 {
+ (ctx_window * 4 / 100).clamp(700, 1600)
+}
+
+/// `summaryRuneCap` : filet de sécurité en caractères du résumé.
+pub fn summary_rune_cap(ctx_window: i64) -> i64 {
+ compact_summary_budget(ctx_window) * 6
+}
+
+// ---- texte et estimation ---------------------------------------------------
+
+fn text_value(s: impl Into) -> Option {
+ Some(Value::String(s.into()))
+}
+
+fn rune_count(s: &str) -> usize {
+ s.chars().count()
+}
+
+fn rune_prefix(s: &str, n: usize) -> &str {
+ match s.char_indices().nth(n) {
+ Some((i, _)) => &s[..i],
+ None => s,
+ }
+}
+
+/// `msgText` : texte d'un message (chaîne, ou parties `text` recollées avec
+/// [`IMAGE_LOST_MARKER`] à la place de chaque partie `image_url`).
+pub fn msg_text(m: &Message) -> String {
+ match &m.content {
+ Some(Value::String(s)) => s.clone(),
+ Some(Value::Array(parts)) => {
+ let mut b = String::new();
+ for p in parts {
+ let Some(part) = p.as_object() else { continue };
+ match part.get("type").and_then(Value::as_str) {
+ Some("text") => {
+ if let Some(Value::String(s)) = part.get("text") {
+ b.push_str(s);
+ }
+ }
+ Some("image_url") => b.push_str(IMAGE_LOST_MARKER),
+ _ => {}
+ }
+ }
+ b
+ }
+ _ => String::new(),
+ }
+}
+
+/// `msgTokens` : ~4 octets par jeton plus un forfait de 4 par message, plus
+/// le coût mesuré d'une image gardée. Sans le raisonnement renvoyé.
+pub fn msg_tokens(m: &Message) -> i64 {
+ let mut n = 4 + msg_text(m).len() as i64 / 4;
+ for tc in &m.tool_calls {
+ n += (tc.function.name.len() + tc.function.arguments.len()) as i64 / 4;
+ }
+ n + m.img_tokens
+}
+
+/// `hasBody` : texte ou appels d'outils.
+fn has_body(m: &Message) -> bool {
+ if !m.tool_calls.is_empty() {
+ return true;
+ }
+ match &m.content {
+ None | Some(Value::Null) => false,
+ Some(Value::String(s)) => !s.is_empty(),
+ Some(_) => true,
+ }
+}
+
+fn echoable(m: &Message, p: &EchoPolicy) -> bool {
+ p.on && m.role == "assistant"
+ && !m.reasoning_content.is_empty()
+ && m.reasoning_model == p.model
+ && has_body(m)
+}
+
+/// `echoTokens` : jetons que le raisonnement renvoyé ajoute, message par
+/// message (`None` = aucun).
+fn echo_tokens(msgs: &[Message], p: &EchoPolicy) -> Option> {
+ if !msgs.iter().any(|m| !m.reasoning_content.is_empty()) {
+ return None;
+ }
+ echo_tokens_for(msgs, p)
+}
+
+fn echo_tokens_for(msgs: &[Message], p: &EchoPolicy) -> Option> {
+ if !p.on {
+ return None;
+ }
+ let last_user = msgs.iter().rposition(|m| m.role == "user");
+ let mut out: Option> = None;
+ for (i, m) in msgs.iter().enumerate() {
+ let before_last = last_user.is_some_and(|lu| i < lu);
+ if !echoable(m, p) || (before_last && !p.keeps_past) {
+ continue;
+ }
+ out.get_or_insert_with(|| vec![0; msgs.len()])[i] = m.reasoning_content.len() as i64 / 4;
+ }
+ out
+}
+
+/// `estimateTokens` : taille estimée de l'historique, raisonnement renvoyé
+/// compris quand il part réellement.
+pub fn estimate_tokens(msgs: &[Message], echo: &EchoPolicy) -> i64 {
+ let mut total: i64 = msgs.iter().map(msg_tokens).sum();
+ if let Some(e) = echo_tokens(msgs, echo) {
+ total += e.iter().sum::();
+ }
+ total
+}
+
+/// `compactWouldTrigger` : compactage actif ET contexte au-dessus du seuil.
+/// `known_tokens` ≤ 0 : repli sur l'estimation.
+pub fn compact_would_trigger(cfg: &CompactConfig, msgs: &[Message], known_tokens: i64) -> bool {
+ if !cfg.enabled {
+ return false;
+ }
+ let mut used = known_tokens;
+ if used <= 0 {
+ used = estimate_tokens(msgs, &cfg.echo);
+ }
+ used >= (cfg.ctx_window as f64 * COMPACT_TRIGGER_FRAC) as i64
+}
+
+/// `compactNeeded` : seuil, ou marge de génération insuffisante.
+pub fn compact_needed(cfg: &CompactConfig, msgs: &[Message], used: i64, peak_gen: i64) -> bool {
+ if compact_would_trigger(cfg, msgs, used) {
+ return true;
+ }
+ cfg.enabled && gen_headroom_short(used, peak_gen, cfg.ctx_window)
+}
+
+/// `genHeadroomShort` : la fenêtre ne laisse plus la place d'une génération de
+/// 1,2 × la plus longue récente (plancher 8 k ramené à 1/8 de la fenêtre),
+/// plus une marge — jamais avant la moitié de la fenêtre.
+pub fn gen_headroom_short(used: i64, peak_gen: i64, window: i64) -> bool {
+ if used <= 0 || peak_gen <= 0 || window <= 0 {
+ return false;
+ }
+ let need = (peak_gen * 6 / 5).max(8192.min(window / 8)) + 1024.min(window / 32);
+ used + need.min(window / 2) > window
+}
+
+/// `logCtx` : trace un fait du comptage de contexte.
+pub fn log_ctx(msg: &str) {
+ eprintln!("[ctx] {msg}");
+}
+
+/// `logCtxEstimate` : ne parle qu'au-delà de 2 % d'écart.
+pub fn log_ctx_estimate(phase: &str, est: i64, actual: i64) {
+ if actual <= 0 {
+ return;
+ }
+ let d = est - actual;
+ if d * 50 > actual || -d * 50 > actual {
+ log_ctx(&format!("{phase} estimé={est} réel={actual} écart={d:+}"));
+ }
+}
+
+/// `ctxPending` : dernier contexte mesuré (valable pour les `measured`
+/// premiers messages) plus l'estimation des messages arrivés depuis.
+pub fn ctx_pending(used: i64, measured: usize, msgs: &[Message], echo: &EchoPolicy) -> i64 {
+ if used <= 0 || measured == 0 || measured > msgs.len() {
+ return used;
+ }
+ used + estimate_tokens(&msgs[measured..], echo)
+}
+
+/// `logCompact` : une ligne par décision de compaction.
+pub fn log_compact(
+ cfg: &CompactConfig,
+ phase: &str,
+ used: i64,
+ before: &[Message],
+ after: &[Message],
+ changed: bool,
+) {
+ eprintln!(
+ "[compact] {} ctx={} seuil={}/{} est_avant={} est_apres={} msgs={}→{} changé={}",
+ phase,
+ used,
+ (cfg.ctx_window as f64 * COMPACT_TRIGGER_FRAC) as i64,
+ cfg.ctx_window,
+ estimate_tokens(before, &cfg.echo),
+ estimate_tokens(after, &cfg.echo),
+ before.len(),
+ after.len(),
+ changed
+ );
+}
+
+// ---- rappels de loki, images gardées, blocs projet ------------------------
+
+/// `isLokiInjected` : un message `user` venu de loki, pas de l'utilisateur.
+fn is_loki_injected(m: &Message) -> bool {
+ if m.role != "user" {
+ return false;
+ }
+ if m.img_relay {
+ return true;
+ }
+ let Some(Value::String(s)) = &m.content else {
+ return false;
+ };
+ if s == THINK_NUDGE_FIRST
+ || s == THINK_NUDGE_STUCK
+ || s.strip_prefix(LOKI_NOTE_PREFIX) == Some(TOOLS_OFF_HINT)
+ || s.starts_with(RETRY_CORRECTIVE_LEAD)
+ {
+ return true;
+ }
+ if !s.starts_with(LOKI_NOTE_PREFIX) {
+ return false;
+ }
+ BUDGET_NUDGE_ENDS.iter().any(|e| s.ends_with(e))
+}
+
+/// `dropKeptImages` : copie où chaque image gardée devient sa légende suivie
+/// d'[`IMAGE_LOST_MARKER`], et coût libéré. `None` : aucune image gardée.
+fn drop_kept_images(msgs: &[Message]) -> Option<(Vec, i64)> {
+ if !msgs.iter().any(|m| m.img_tokens > 0) {
+ return None;
+ }
+ let mut out = msgs.to_vec();
+ let mut freed = 0;
+ for m in out.iter_mut().filter(|m| m.img_tokens > 0) {
+ let mut texts: Vec<&str> = Vec::new();
+ if let Some(Value::Array(parts)) = &m.content {
+ for p in parts.iter().filter_map(Value::as_object) {
+ if let (Some(Value::String(t)), Some("text")) =
+ (p.get("text"), p.get("type").and_then(Value::as_str))
+ {
+ texts.push(t);
+ }
+ }
+ }
+ freed += m.img_tokens;
+ let text = texts.join("\n") + IMAGE_LOST_MARKER;
+ m.content = text_value(text);
+ m.img_tokens = 0;
+ }
+ Some((out, freed))
+}
+
+fn log_kept_images_dropped(phase: &str, freed: i64) {
+ eprintln!("[images] {phase} : images gardées retirées ({freed} jetons)");
+}
+
+/// `stripCtxUpdText` : retire le(s) bloc(s) posé(s) en TÊTE du texte.
+fn strip_ctx_upd_text(s: &str) -> (String, bool) {
+ let mut s = s;
+ let mut stripped = false;
+ while s.starts_with(CTX_UPD_OPEN) {
+ let Some(i) = s.find(CTX_UPD_CLOSE) else {
+ break;
+ };
+ let rest = &s[i + CTX_UPD_CLOSE.len()..];
+ s = rest.strip_prefix("\n\n").unwrap_or(rest);
+ stripped = true;
+ }
+ (s.to_string(), stripped)
+}
+
+/// `withoutCtxUpdate` : le message sans son bloc de tête
+/// (seulement un message `user` marqué `ctx_upd`).
+fn without_ctx_update(m: &Message) -> Option {
+ if m.role != "user" || !m.ctx_upd {
+ return None;
+ }
+ let mut out = m.clone();
+ match &m.content {
+ Some(Value::String(c)) => {
+ let (s, ok) = strip_ctx_upd_text(c);
+ if !ok {
+ return None;
+ }
+ out.content = text_value(s);
+ }
+ Some(Value::Array(parts)) => {
+ let first = parts.first()?.as_object()?;
+ if first.get("type").and_then(Value::as_str) != Some("text") {
+ return None;
+ }
+ let t = first.get("text").and_then(Value::as_str).unwrap_or("");
+ if t.is_empty() {
+ return None;
+ }
+ let (s, ok) = strip_ctx_upd_text(t);
+ if !ok {
+ return None;
+ }
+ let mut v: Vec = Vec::with_capacity(parts.len());
+ if !s.is_empty() {
+ let mut q = first.clone();
+ q.insert("text".into(), Value::String(s));
+ v.push(Value::Object(q));
+ }
+ v.extend(parts[1..].iter().cloned());
+ out.content = Some(Value::Array(v));
+ }
+ _ => return None,
+ }
+ out.ctx_upd = false;
+ Some(out)
+}
+
+/// `stripContextUpdates` : l'historique sans aucun bloc .
+fn strip_context_updates(msgs: Vec) -> Vec {
+ msgs.into_iter()
+ .map(|m| without_ctx_update(&m).unwrap_or(m))
+ .collect()
+}
+
+// ---- bornes et assemblage --------------------------------------------------
+
+/// `compactBounds` : frontières tête/queue. Tête = messages système de tête ;
+/// queue = depuis la fin jusqu'à remplir `tail_budget`, reculée jusqu'à une
+/// frontière sûre (`user` vrai ou `assistant`). Le torse est
+/// `[head, tail_start)`, vide si `tail_start <= head`.
+pub fn compact_bounds(msgs: &[Message], tail_budget: i64, echo: &EchoPolicy) -> (usize, usize) {
+ let mut head = 0;
+ while head < msgs.len() && msgs[head].role == "system" {
+ head += 1;
+ }
+ let mut tail_start = msgs.len();
+ let mut acc = 0;
+ let et = echo_tokens(msgs, echo);
+ for i in (head..msgs.len()).rev() {
+ acc += msg_tokens(&msgs[i]);
+ if let Some(e) = &et {
+ acc += e[i];
+ }
+ tail_start = i;
+ if acc >= tail_budget {
+ break;
+ }
+ }
+ while tail_start > head
+ && ((msgs[tail_start].role != "user" && msgs[tail_start].role != "assistant")
+ || is_loki_injected(&msgs[tail_start]))
+ {
+ tail_start -= 1;
+ }
+ (head, tail_start)
+}
+
+/// `compactPending` : la demande en cours à réinjecter avant la queue (quand
+/// un résumé a été obtenu) ; vide si la queue porte déjà une vraie demande.
+pub fn compact_pending(msgs: &[Message], torso: &[Message], tail_start: usize) -> Vec {
+ if msgs[tail_start..]
+ .iter()
+ .any(|m| m.role == "user" && !is_loki_injected(m))
+ {
+ return Vec::new();
+ }
+ for m in torso.iter().rev() {
+ if m.role != "user" || is_loki_injected(m) {
+ continue;
+ }
+ if msg_text(m).starts_with(COMPACT_SUMMARY_PREFIX) {
+ continue; // résumé d'une compaction précédente, pas une demande
+ }
+ return vec![m.clone()];
+ }
+ Vec::new()
+}
+
+/// `compactAssemble` : tête, zone résumée, demande réinjectée, queue.
+pub fn compact_assemble(
+ msgs: &[Message],
+ head: usize,
+ tail_start: usize,
+ mid: &[Message],
+ pending: &[Message],
+) -> Vec {
+ let mut out = Vec::with_capacity(head + mid.len() + pending.len() + msgs.len() - tail_start);
+ out.extend_from_slice(&msgs[..head]);
+ out.extend_from_slice(mid);
+ out.extend_from_slice(pending);
+ out.extend_from_slice(&msgs[tail_start..]);
+ out
+}
+
+/// `compactHopeless` : même un résumé VIDE ne passerait pas la garantie de
+/// réduction (20 %).
+pub fn compact_hopeless(
+ msgs: &[Message],
+ head: usize,
+ tail_start: usize,
+ echo: &EchoPolicy,
+) -> bool {
+ let torso = &msgs[head..tail_start];
+ let mid = [
+ Message {
+ role: "user".into(),
+ content: text_value(compact_summary_user_msg("", &[])),
+ ..Default::default()
+ },
+ Message {
+ role: "assistant".into(),
+ content: text_value(COMPACT_ACK_MSG),
+ ..Default::default()
+ },
+ ];
+ let floor = compact_assemble(
+ msgs,
+ head,
+ tail_start,
+ &mid,
+ &compact_pending(msgs, torso, tail_start),
+ );
+ estimate_tokens(&floor, echo) > estimate_tokens(msgs, echo) * 4 / 5
+}
+
+/// `summaryLooksEmpty` : résumé raté (moins de 40 caractères de VRAI texte,
+/// hors références recall).
+pub fn summary_looks_empty(s: &str) -> bool {
+ let s = s.trim();
+ if rune_count(s) < 40 {
+ return true;
+ }
+ const CUT: &str = ".,;:()[]-•*";
+ let mut real = String::new();
+ for f in s.split_whitespace() {
+ let low = f.trim_matches(|c| CUT.contains(c)).to_lowercase();
+ if low.starts_with("recall:r") || low.starts_with("recall(") {
+ continue;
+ }
+ real.push_str(f);
+ real.push(' ');
+ }
+ rune_count(real.trim()) < 40
+}
+
+// ---- recall ----------------------------------------------------------------
+
+/// Un id d'archive et son libellé court (`recallEntry`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RecallEntry {
+ pub id: String,
+ pub label: String,
+}
+
+/// L'archive recall telle que la compaction l'appelle (`archiveRecallBlock`).
+pub trait RecallArchive {
+ /// Range `content` VERBATIM et rend son id (« r7 »). Une erreur (base
+ /// absente…) laisse le bloc non archivé : repli transparent.
+ fn archive(&self, label: &str, role: &str, content: &str) -> Result;
+}
+
+/// Archive absente : rien n'est jamais archivé.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct NoRecall;
+
+impl RecallArchive for NoRecall {
+ fn archive(&self, _label: &str, _role: &str, _content: &str) -> Result {
+ Err("archive recall indisponible".into())
+ }
+}
+
+/// `recallEligible` : bloc assez gros d'un rôle réel, qui n'est pas déjà un
+/// résumé de compaction.
+pub fn recall_eligible(m: &Message) -> bool {
+ if !matches!(m.role.as_str(), "tool" | "user" | "assistant") {
+ return false;
+ }
+ let t = msg_text(m);
+ if rune_count(&t) <= RECALL_ARCHIVE_MIN_LEN {
+ return false;
+ }
+ !t.starts_with(COMPACT_SUMMARY_PREFIX)
+}
+
+/// `torsoToolNames` : ToolCallID → nom d'outil.
+fn torso_tool_names(torso: &[Message]) -> HashMap {
+ let mut names = HashMap::new();
+ for m in torso {
+ for tc in &m.tool_calls {
+ if !tc.id.is_empty() {
+ names.insert(tc.id.clone(), tc.function.name.clone());
+ }
+ }
+ }
+ names
+}
+
+/// `recallLabel` (`chat_recall.go`) : rôle (ou nom d'outil) et première ligne.
+fn recall_label(role: &str, tool_name: &str, content: &str) -> String {
+ let mut head = content
+ .split('\n')
+ .map(str::trim)
+ .find(|s| !s.is_empty())
+ .unwrap_or("")
+ .to_string();
+ if rune_count(&head) > 70 {
+ head = rune_prefix(&head, 70).trim().to_string() + "…";
+ }
+ let tag = if role == "tool" && !tool_name.is_empty() {
+ tool_name
+ } else {
+ role
+ };
+ if head.is_empty() {
+ return tag.to_string();
+ }
+ format!("{tag}: {head}")
+}
+
+/// `recallMarker` : remplace un bloc archivé dans le torse dégraissé.
+pub fn recall_marker(id: &str, label: &str) -> String {
+ format!(
+ "[Block archived to save context — full content retrievable with recall(\"{id}\") ({label}). Do NOT re-run a tool to fetch it back.]"
+ )
+}
+
+/// `compactSummaryUserMsg` : le message `user` qui porte le résumé et l'index
+/// des blocs rappelables créés à cette passe.
+pub fn compact_summary_user_msg(summary: &str, index: &[RecallEntry]) -> String {
+ let mut b = String::from(COMPACT_SUMMARY_PREFIX);
+ if !index.is_empty() {
+ b.push_str(" The earlier turns of this conversation were summarized to save context, but nothing is lost: any block referenced below (or in the summary) as recall:rN can be brought back verbatim with the recall(id) tool, and recall_search(\"keywords\") finds older archived blocks not listed here. Here is the summary:\n\n");
+ } else {
+ b.push_str(
+ " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n",
+ );
+ }
+ b.push_str(summary);
+ if !index.is_empty() {
+ b.push_str("\n\nArchived blocks you can bring back with recall(id):\n");
+ for e in index {
+ b.push_str(&format!("- {} — {}\n", e.id, e.label));
+ }
+ b.push_str("Only recall a block when you actually need its full content.");
+ }
+ b
+}
+
+/// `renderTranscript` : le torse en texte pour le résumeur, plafonné à
+/// ~2,8 octets par jeton de la fenêtre (la FIN est gardée).
+///
+/// Écart assumé : Go coupe à l'octet, quitte à couper un caractère multioctet
+/// (ses octets orphelins deviennent chacun U+FFFD à l'encodage JSON). Une
+/// `String` Rust devant rester valide, chaque octet orphelin est remplacé ici
+/// par U+FFFD — ce qui part au moteur est identique.
+pub fn render_transcript(msgs: &[Message], ctx_window: i64) -> String {
+ let mut b = String::new();
+ for m in msgs {
+ match m.role.as_str() {
+ "user" => {
+ if is_loki_injected(m) {
+ b.push_str(&format!("System: {}\n", msg_text(m)));
+ continue;
+ }
+ b.push_str(&format!("User: {}\n", msg_text(m)));
+ }
+ "assistant" => {
+ let t = msg_text(m);
+ if !t.is_empty() {
+ b.push_str(&format!("Assistant: {t}\n"));
+ }
+ for tc in &m.tool_calls {
+ b.push_str(&format!(
+ "Assistant → tool {}({})\n",
+ tc.function.name, tc.function.arguments
+ ));
+ }
+ }
+ "tool" => b.push_str(&format!("Tool result: {}\n", msg_text(m))),
+ "system" => b.push_str(&format!("System: {}\n", msg_text(m))),
+ _ => {}
+ }
+ }
+ let max_chars = (ctx_window as f64 * 2.8) as usize;
+ if max_chars > 0 && b.len() > max_chars {
+ let cut = b.len() - max_chars;
+ let mut k = cut;
+ while !b.is_char_boundary(k) {
+ k += 1;
+ }
+ return format!(
+ "[…start truncated…]\n{}{}",
+ "\u{FFFD}".repeat(k - cut),
+ &b[k..]
+ );
+ }
+ b
+}
+
+// ---- le moteur de résumé ---------------------------------------------------
+
+/// Premier choix d'une réponse de résumé (`summaryChoice`).
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct SummaryChoice {
+ pub content: String,
+ pub reasoning_content: String,
+ /// Appels d'outil émis malgré tool_choice « none » : jamais un résumé.
+ pub tool_calls: Vec,
+ pub finish_reason: String,
+}
+
+/// Échec d'une requête de résumé.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SummaryError {
+ /// `summaryHTTPError` : le moteur a refusé la requête (corps tronqué à
+ /// 500 octets et rogné). `who` : « llama-server » ou « API externe ».
+ Http {
+ who: String,
+ status: u16,
+ body: String,
+ },
+ /// Toute autre erreur (réseau, décodage…).
+ Other(String),
+}
+
+impl fmt::Display for SummaryError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ SummaryError::Http { who, status, body } => {
+ write!(f, "résumé: {who} {status}: {body}")
+ }
+ SummaryError::Other(s) => f.write_str(s),
+ }
+ }
+}
+
+impl std::error::Error for SummaryError {}
+
+#[derive(Deserialize)]
+struct WireResp {
+ #[serde(default)]
+ choices: Option>,
+}
+
+#[derive(Deserialize)]
+struct WireChoice {
+ #[serde(default)]
+ message: WireMsg,
+ #[serde(default)]
+ finish_reason: Option,
+}
+
+#[derive(Deserialize, Default)]
+struct WireMsg {
+ #[serde(default)]
+ content: Option,
+ #[serde(default)]
+ reasoning_content: Option,
+ #[serde(default)]
+ tool_calls: Option>,
+}
+
+/// Décodage d'une réponse non streamée de /v1/chat/completions, comme
+/// `postSummary` : seule la PREMIÈRE valeur JSON compte (un octet parasite
+/// après elle est ignoré), aucun choix → « résumé: réponse vide ».
+pub fn parse_summary_response(raw: &[u8]) -> Result {
+ let first = serde_json::Deserializer::from_slice(raw)
+ .into_iter::()
+ .next();
+ let resp = match first {
+ None => return Err(SummaryError::Other("EOF".into())),
+ Some(Err(e)) => return Err(SummaryError::Other(e.to_string())),
+ Some(Ok(r)) => r,
+ };
+ let Some(ch) = resp.choices.and_then(|c| c.into_iter().next()) else {
+ return Err(SummaryError::Other("résumé: réponse vide".into()));
+ };
+ Ok(SummaryChoice {
+ content: ch.message.content.unwrap_or_default(),
+ reasoning_content: ch.message.reasoning_content.unwrap_or_default(),
+ tool_calls: ch.message.tool_calls.unwrap_or_default(),
+ finish_reason: ch.finish_reason.unwrap_or_default(),
+ })
+}
+
+/// Résumé sur transcription (`summarizeTranscriptFor`). L'implémentation
+/// envoie au point de chat résolu (`resolveChatEndpoint`) :
+/// `{"model", "messages": [system, user(transcript)], "stream": false,
+/// "temperature": 0.2, "max_tokens", "chat_template_kwargs":
+/// {"enable_thinking": false}}` — sans `chat_template_kwargs` sur un preset
+/// externe ; sur le slot annexe (SIDE_SLOT) s'il y en a un ; télémétrie
+/// kind=compact rattachée à la discussion courante.
+#[derive(Debug, Clone, PartialEq)]
+pub struct TranscriptRequest {
+ pub system: String,
+ pub transcript: String,
+ pub max_tokens: i64,
+}
+
+/// Résumé en continuation (`summarizeContinuation`). L'implémentation
+/// construit la charge comme le tour (`buildChatPayload(ep, messages, 0.2,
+/// {tools, toolChoiceNone: true, effort, kwargs, noSampling: true})`), puis
+/// `stream: false`, sans `stream_options`, `max_tokens`, sur le slot de la
+/// discussion ; télémétrie kind=compact rattachée à `conv`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ContinuationRequest {
+ /// La vue d'envoi du tour suivie de la demande de résumé.
+ pub messages: Vec,
+ pub tools: Vec,
+ pub effort: String,
+ /// Arguments du gabarit du tour, `enable_thinking` forcé à false.
+ pub kwargs: Map,
+ pub max_tokens: i64,
+ pub conv: String,
+}
+
+/// Le moteur vu de la compaction : remplace `postSummary` (HTTP par la porte
+/// du moteur) et les lectures d'état global du chemin en continuation.
+pub trait Summarizer {
+ /// Poste un résumé sur transcription et rend le premier choix.
+ fn summarize_transcript(
+ &self,
+ req: TranscriptRequest,
+ ) -> impl Future