Kaynağa Gözat

Historique v3 : messages modifiés et supprimés suivis

Chaque message Discord garde sa ligne dans la mémoire, avec son
identifiant, sa date, son auteur, sa citation, son texte et ses pièces
jointes séparés : le fichier est plus lisible.

Un message supprimé perd son contenu dans la mémoire (texte, pièces
jointes) ainsi que sa citation dans les messages qui y répondaient :
seuls sa date et son auteur restent, avec une mention « supprimé du
salon », et la réponse de Rika est gardée pour ne pas perdre le fil.
Un message modifié prend son nouveau texte, avec une mention « modifié
après ta réponse ». Un message supprimé avant sa réponse n'en reçoit
plus (mentions comprises), et un message modifié en attente part avec
son nouveau texte.

L'ancien fichier est converti au chargement en gardant exactement le
même texte pour Claude (le cache de prompt n'est pas réécrit), et une
copie datée est gardée : la 5.7 ne sait pas lire le format 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 gün önce
ebeveyn
işleme
7041bdd4ed
5 değiştirilmiş dosya ile 269 ekleme ve 61 silme
  1. 1 1
      README.md
  2. 1 1
      bot/__init__.py
  3. 68 24
      bot/discord_bot.py
  4. 176 35
      bot/history.py
  5. 23 0
      changelog.txt

+ 1 - 1
README.md

@@ -4,7 +4,7 @@ Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic)
 
 ## Fonctionnalités
 
-- **Salon dédié** : répond à tous les messages du salon défini par `CHANNEL_ID`, avec une mémoire des derniers échanges, sauvegardée entre deux redémarrages.
+- **Salon dédié** : répond à tous les messages du salon défini par `CHANNEL_ID`, avec une mémoire des derniers échanges, sauvegardée entre deux redémarrages. Un message modifié ou supprimé sur Discord l'est aussi dans la mémoire.
 - **Mode mention** : dans les autres salons, répond quand on le mentionne, en lisant les messages récents pour comprendre la discussion.
 - **Images** : commente une image envoyée dans le salon dédié (JPEG, PNG, GIF, WebP, une par message).
 - **Fichiers texte** : lit un fichier texte joint au message (`.txt`, `.md`, `.csv`, `.json`, `.log`, sous-titres, code…, un par message, 100 Ko maximum), dans le salon dédié et en mode mention. Seul le nom du fichier est gardé en mémoire, pas son contenu.

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """Chatbot Discord propulsé par Claude."""
 
-VERSION = "5.7"
+VERSION = "5.8"

+ 68 - 24
bot/discord_bot.py

@@ -19,7 +19,7 @@ from discord.ext import commands
 from . import VERSION
 from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
-from .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
+from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
 from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
 from .presence import PresenceManager, Status
@@ -89,10 +89,16 @@ def attachment_marker(attachment: ImageData) -> str:
 class PendingMessage:
     """Message du salon dédié, prêt à être envoyé à Claude."""
     message: discord.Message
-    user_text: str    # texte envoyé à Claude
-    stored_text: str  # texte conservé en mémoire
+    line: dict   # ligne conservée en mémoire (voir history.render_line)
+    extra: str   # ajouté pour Claude seulement : contenu du fichier texte joint
     image: ImageData | None
 
+    @property
+    def user_text(self) -> str:
+        """Texte envoyé à Claude : l'image ou le fichier lui-même remplace son repère."""
+        line = {key: value for key, value in self.line.items() if key != "attachments"}
+        return render_line(line) + self.extra
+
 
 class ChatBot(commands.Bot):
     def __init__(self, settings: Settings):
@@ -212,6 +218,39 @@ class ChatBot(commands.Bot):
         elif mentioned:
             await self.handle_mention(message)
 
+    async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
+        """Message modifié : la mémoire, ou le message s'il attend encore sa réponse, prend le nouveau texte."""
+        message = payload.message
+        # Aperçu d'un lien qui s'affiche, ou transfert (qui ne se modifie pas) : rien n'a changé.
+        if message.edited_at is None or message.message_snapshots:
+            return
+        content = self.message_body(message)
+        for pending in self._pending.get(payload.channel_id, []):
+            if pending.message.id == message.id:
+                pending.line["content"] = content
+        if await self.history.edit(payload.channel_id, message.id, content):
+            logger.info("Message modifié dans le salon %s : mémoire mise à jour.", payload.channel_id)
+
+    async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None:
+        await self.forget_deleted(payload.channel_id, {payload.message_id})
+
+    async def on_raw_bulk_message_delete(self, payload: discord.RawBulkMessageDeleteEvent) -> None:
+        await self.forget_deleted(payload.channel_id, payload.message_ids)
+
+    async def forget_deleted(self, channel_id: int, message_ids: set[int]) -> None:
+        """Messages supprimés : ils sortent de la mémoire et n'attendent plus de réponse."""
+        # Listes modifiées sur place : une réponse sur le point de partir les lit encore.
+        if pending := self._pending.get(channel_id):
+            pending[:] = [p for p in pending if p.message.id not in message_ids]
+        if mentions := self._mentions.get(channel_id):
+            mentions[:] = [m for m in mentions if m.id not in message_ids]
+        keys = {(channel_id, message_id) for message_id in message_ids}
+        if keys & self._waiting:
+            self._waiting -= keys
+            self._save_waiting()
+        if await self.history.delete(channel_id, message_ids):
+            logger.info("%s message(s) supprimé(s) dans le salon %s : mémoire mise à jour.", len(message_ids), channel_id)
+
     async def handle_main_channel(self, message: discord.Message) -> None:
         if self._last_main_message is None or message.created_at > self._last_main_message:
             self._last_main_message = message.created_at
@@ -237,23 +276,25 @@ class ChatBot(commands.Bot):
         reply, reply_image = await self.read_reply(message, want_image=image is None and forward_image is None)
         image = image or forward_image or reply_image
 
+        line: dict = {"id": message.id, "date": self.timestamp(message), "author": message.author.display_name}
         if forward:
-            user_text = f"{self.timestamp(message)}, {message.author.display_name} a transféré un message {forward}"
+            line["forward"] = forward
         else:
-            text = message.clean_content.strip()
-            if message.stickers:
-                text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
-            text = text or "(message sans texte)"
-            reply_part = f" ({reply})" if reply else ""
-            user_text = f"{self.timestamp(message)}, {message.author.display_name} a écrit{reply_part} : {text}"
-        stored_text = user_text + (f" {attachment_marker(image)}" if image else "")
+            if reply:
+                line["reply_to"] = message.reference.message_id  # citation retirée si ce message est supprimé
+                line["reply"] = reply
+            line["content"] = self.message_body(message)
+        markers = [attachment_marker(image)] if image else []
+        extra = ""
         if text_file:
-            user_text += text_file_prompt(*text_file)
-            stored_text += f" {text_file_marker(text_file[0])}"
+            extra = text_file_prompt(*text_file)
+            markers.append(text_file_marker(text_file[0]))
+        if markers:
+            line["attachments"] = markers
 
         # Un seul appel à la fois par salon : l'historique reste cohérent. Les messages arrivés
         # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel.
-        self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, user_text, stored_text, image))
+        self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, line, extra, image))
         if not self.available() or message.channel.id in self._waiters:
             self.answer_later(message, self.answer_main_channel)
             return
@@ -272,13 +313,13 @@ class ChatBot(commands.Bot):
                 logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), channel)
             # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
             parts = [(p.user_text, p.image) for p in batch]
-            stored_lines = [p.stored_text for p in batch]
+            lines = [p.line for p in batch]
             notes = [self.presence.late_note(late, several=len(batch) > 1)] if late else []
             if note := self.presence_note():
                 notes.append(note)
             for note in reversed(notes):
                 parts.insert(0, (note, None))
-                stored_lines.insert(0, note)
+                lines.insert(0, {"content": note})
             try:
                 async with typing(channel):
                     try:
@@ -292,10 +333,7 @@ class ChatBot(commands.Bot):
                         return
             finally:
                 self.forget_waiting(*(p.message for p in batch))
-            stored_text = "\n".join(stored_lines)
-            if answer.web_used:
-                stored_text += f" {WEB_MARKER}"
-            await self.history.add_exchange(channel.id, stored_text, answer.text)
+            await self.history.add_exchange(channel.id, lines, answer.text, answer.web_used)
         await self.send_answer(last, answer.text)
 
     async def handle_mention(self, message: discord.Message) -> None:
@@ -427,10 +465,8 @@ class ChatBot(commands.Bot):
             except ClaudeError as exc:
                 logger.warning("Message d'absence non envoyé : %s", exc.user_message)
                 return
-            stored_text = "\n".join(text for text, _ in parts)
-            if answer.web_used:
-                stored_text += f" {WEB_MARKER}"
-            await self.history.add_exchange(channel.id, stored_text, answer.text)
+            lines = [{"content": text} for text, _ in parts]
+            await self.history.add_exchange(channel.id, lines, answer.text, answer.web_used)
         logger.info("Conversation en cours dans #%s : le salon est prévenu de l'absence.", channel)
         for chunk in split_message(answer.text):
             try:
@@ -693,6 +729,14 @@ class ChatBot(commands.Bot):
     def presence_note(self) -> str | None:
         return self.presence.prompt_note() if self.presence else None
 
+    @staticmethod
+    def message_body(message: discord.Message) -> str:
+        """Texte d'un message du salon dédié, tel qu'il est conservé en mémoire (sans ce qui le précède)."""
+        text = message.clean_content.strip()
+        if message.stickers:
+            text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
+        return text or "(message sans texte)"
+
     def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
         return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
 

+ 176 - 35
bot/history.py

@@ -6,12 +6,13 @@ import asyncio
 import json
 import logging
 import os
+import shutil
 from datetime import datetime
 from pathlib import Path
 
 logger = logging.getLogger("chatbot.history")
 
-FILE_VERSION = 2
+FILE_VERSION = 3
 
 # Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
 IMAGE_MARKER = "[image jointe : tu l'as vue et analysée à ce moment-là, mais elle n'est plus conservée en mémoire]"
@@ -19,14 +20,43 @@ _OLD_IMAGE_MARKER = "[image jointe]"
 # Ajouté au message de l'utilisateur quand Claude a fait une recherche web pour y répondre
 # (les recherches et leurs résultats ne sont pas conservés).
 WEB_MARKER = "[recherche web : tu as réellement cherché sur Internet pour répondre à ce message, mais les résultats ne sont plus conservés en mémoire]"
+# Ajouté à un message modifié sur Discord après avoir été mémorisé.
+EDITED_MARKER = "[message modifié après ta réponse]"
+# Remplace le texte d'un message supprimé de Discord après avoir été mémorisé…
+DELETED_MARKER = "[message supprimé du salon depuis ta réponse]"
+# … et sa citation dans les messages qui y répondaient.
+DELETED_REPLY = "en réponse à un message supprimé depuis"
+# Ce qu'un message supprimé perd dans la mémoire : seuls son identifiant, sa date et son auteur restent.
+_DELETED_KEYS = ("reply_to", "reply", "forward", "content", "edited", "attachments")
+
+
+def render_line(line: dict) -> str:
+    """Texte d'une ligne de la mémoire, tel qu'il est envoyé à Claude.
+
+    Une ligne est un message Discord (id, date, author, content ou forward, et éventuellement
+    reply, reply_to, edited, attachments, ou deleted seul), ou une simple note du bot (content seul)."""
+    if "author" not in line:
+        return line["content"]
+    head = f"{line['date']}, {line['author']}"
+    if line.get("deleted"):
+        return f"{head} a écrit : {DELETED_MARKER}"
+    if "forward" in line:
+        text = f"{head} a transféré un message {line['forward']}"
+    else:
+        reply = f" ({line['reply']})" if line.get("reply") else ""
+        text = f"{head} a écrit{reply} : {line['content']}"
+        if line.get("edited"):
+            text += f" {EDITED_MARKER}"
+    return " ".join([text, *line.get("attachments", [])])
 
 
 class ConversationHistory:
     """Garde les derniers échanges de chaque salon.
 
-    Les messages sont stockés au format de l'API d'Anthropic, en texte uniquement :
-    une image est remplacée par IMAGE_MARKER, ce qui évite de conserver des liens
-    Discord qui expirent et de repayer l'image à chaque message.
+    Chaque message de l'utilisateur garde ses lignes séparées, une par message Discord, avec
+    son identifiant : un message modifié ou supprimé sur Discord l'est aussi dans la mémoire.
+    Tout est en texte : une image est remplacée par IMAGE_MARKER, ce qui évite de conserver
+    des liens Discord qui expirent et de repayer l'image à chaque message.
     """
 
     def __init__(self, file_path: Path, max_length: int):
@@ -48,14 +78,18 @@ class ConversationHistory:
         return self._locks[key]
 
     def get(self, channel_id: int | str) -> list[dict]:
-        """Renvoie une copie des messages mémorisés pour ce salon."""
-        return [dict(m) for m in self._data.get(str(channel_id), [])]
+        """Renvoie les messages mémorisés pour ce salon, au format de l'API d'Anthropic."""
+        return [self._render(m) for m in self._data.get(str(channel_id), [])]
 
-    async def add_exchange(self, channel_id: int | str, user_text: str, assistant_text: str) -> None:
-        """Ajoute un échange complet (question + réponse) puis sauvegarde."""
+    async def add_exchange(self, channel_id: int | str, lines: list[dict], assistant_text: str,
+                           web_used: bool = False) -> None:
+        """Ajoute un échange complet (lignes de l'utilisateur + réponse) puis sauvegarde."""
         key = str(channel_id)
         messages = self._data.setdefault(key, [])
-        messages.append({"role": "user", "content": user_text})
+        user: dict = {"role": "user", "lines": lines}
+        if web_used:
+            user["web"] = True
+        messages.append(user)
         messages.append({"role": "assistant", "content": assistant_text})
         self._data[key] = self._trim(messages)
         await self._save()
@@ -67,8 +101,64 @@ class ConversationHistory:
             self._data[str(channel_id)] = []
             await self._save()
 
+    async def edit(self, channel_id: int | str, message_id: int, content: str) -> bool:
+        """Remplace le texte d'un message modifié sur Discord. Renvoie True s'il était mémorisé."""
+        if not self._known(channel_id):
+            return False
+        # Le verrou attend la fin d'une réponse en cours : le message n'est mémorisé qu'après.
+        async with self.lock(channel_id):
+            for line in self._lines(channel_id):
+                if line.get("id") == message_id and "content" in line and line["content"] != content:
+                    line["content"] = content
+                    line["edited"] = True
+                    await self._save()
+                    return True
+        return False
+
+    async def delete(self, channel_id: int | str, message_ids: set[int]) -> bool:
+        """Efface le contenu des messages supprimés sur Discord, ainsi que leurs citations.
+
+        La ligne reste, réduite à sa date et à son auteur, avec la réponse du bot : la
+        conversation garde son fil sans conserver ce qui a été supprimé.
+        Renvoie True si la mémoire a changé."""
+        if not self._known(channel_id):
+            return False
+        async with self.lock(channel_id):
+            changed = False
+            for line in self._lines(channel_id):
+                if line.get("id") in message_ids and not line.get("deleted"):
+                    for key in _DELETED_KEYS:
+                        line.pop(key, None)
+                    line["deleted"] = True
+                    changed = True
+                elif line.get("reply_to") in message_ids:
+                    line["reply"] = DELETED_REPLY
+                    del line["reply_to"]
+                    changed = True
+            if changed:
+                await self._save()
+            return changed
+
     # ------------------------------------------------------------ internes
 
+    def _known(self, channel_id: int | str) -> bool:
+        """Salon dont la mémoire existe ou est en train d'être écrite."""
+        return str(channel_id) in self._data or str(channel_id) in self._locks
+
+    def _lines(self, channel_id: int | str):
+        for entry in self._data.get(str(channel_id), []):
+            if entry["role"] == "user":
+                yield from entry["lines"]
+
+    @staticmethod
+    def _render(entry: dict) -> dict:
+        if entry["role"] == "assistant":
+            return {"role": "assistant", "content": entry["content"]}
+        content = "\n".join(render_line(line) for line in entry["lines"])
+        if entry.get("web"):
+            content += f" {WEB_MARKER}"
+        return {"role": "user", "content": content}
+
     def _trim(self, messages: list[dict]) -> list[dict]:
         """Garde au moins les N derniers messages, en commençant toujours par un message utilisateur.
 
@@ -87,10 +177,13 @@ class ConversationHistory:
         if not self.file_path.exists():
             return {}
         try:
-            channels = self._channels(json.loads(self.file_path.read_text(encoding="utf-8")))
+            raw = json.loads(self.file_path.read_text(encoding="utf-8"))
+            channels = self._channels(raw)
         except (ValueError, OSError) as exc:  # JSON invalide, mauvais encodage ou structure inattendue
             self._set_aside(exc)
             return {}
+        if raw.get("version") != FILE_VERSION:
+            self._keep_old_copy(raw.get("version") or 1)
         return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
 
     @staticmethod
@@ -98,12 +191,11 @@ class ConversationHistory:
         """Messages de chaque salon, quel que soit le format du fichier (ValueError s'il est inattendu)."""
         if not isinstance(raw, dict):
             raise ValueError(f"le fichier contient un {type(raw).__name__} au lieu d'un dictionnaire")
-        if raw.get("version") == FILE_VERSION:
+        if raw.get("version") in (2, FILE_VERSION):
             channels = raw.get("channels") or {}
             if not isinstance(channels, dict):
                 raise ValueError(f"« channels » est un {type(channels).__name__} au lieu d'un dictionnaire")
             return channels
-        logger.info("Ancien format d'historique détecté : conversion automatique.")
         return {cid: val.get("messages") for cid, val in raw.items() if isinstance(val, dict)}
 
     def _set_aside(self, exc: Exception) -> None:
@@ -117,37 +209,86 @@ class ConversationHistory:
         except OSError as err:
             logger.error("Copie de secours impossible : %s", err)
 
-    @staticmethod
-    def _sanitize(messages) -> list[dict]:
-        """Convertit des messages (éventuellement de l'ancien format Mistral) en texte simple,
-        et fusionne les messages consécutifs d'un même rôle."""
+    def _keep_old_copy(self, version: int) -> None:
+        """Copie un fichier d'un ancien format avant sa conversion : une version précédente
+        du bot ne sait pas lire le nouveau format et repartirait d'une mémoire vide."""
+        stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+        backup = self.file_path.with_name(f"{self.file_path.name}.v{version}-{stamp}")
+        try:
+            shutil.copy2(self.file_path, backup)
+        except OSError as err:
+            logger.error("Copie de l'ancien historique impossible : %s", err)
+            return
+        logger.info("Historique au format %s : conversion au format %s. Copie de l'ancien fichier : %s",
+                    version, FILE_VERSION, backup)
+
+    @classmethod
+    def _sanitize(cls, messages) -> list[dict]:
+        """Met les messages au format actuel (en convertissant les anciens formats, dont celui
+        de Mistral), et fusionne les messages consécutifs d'un même rôle."""
         if not isinstance(messages, list):
             return []
         clean: list[dict] = []
         for msg in messages:
-            if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
-                continue
-            content = msg.get("content")
-            if isinstance(content, list):
-                parts = []
-                for block in content:
-                    if not isinstance(block, dict):
-                        continue
-                    if block.get("type") == "text":
-                        parts.append(block.get("text", ""))
-                    elif block.get("type") in ("image", "image_url"):
-                        parts.append(IMAGE_MARKER)
-                content = " ".join(p for p in parts if p)
-            if not isinstance(content, str) or not content.strip():
+            entry = cls._entry(msg)
+            if entry is None:
                 continue
-            if msg["role"] == "user" and content.endswith(_OLD_IMAGE_MARKER):
-                content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
-            if clean and clean[-1]["role"] == msg["role"]:
-                clean[-1]["content"] += "\n" + content
+            if clean and clean[-1]["role"] == entry["role"]:
+                previous = clean[-1]
+                if entry["role"] == "user":
+                    previous["lines"] += entry["lines"]
+                    if entry.get("web"):
+                        previous["web"] = True
+                else:
+                    previous["content"] += "\n" + entry["content"]
             else:
-                clean.append({"role": msg["role"], "content": content})
+                clean.append(entry)
         return clean
 
+    @classmethod
+    def _entry(cls, msg) -> dict | None:
+        """Un message du fichier au format actuel, ou None s'il est vide ou illisible."""
+        if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
+            return None
+        if msg["role"] == "user" and isinstance(msg.get("lines"), list):
+            lines = [line for line in msg["lines"] if cls._readable(line)]
+            if not lines:
+                return None
+            return {"role": "user", "lines": lines, **({"web": True} if msg.get("web") else {})}
+
+        # Anciens formats : un seul texte par message.
+        content = msg.get("content")
+        if isinstance(content, list):
+            parts = []
+            for block in content:
+                if not isinstance(block, dict):
+                    continue
+                if block.get("type") == "text":
+                    parts.append(block.get("text", ""))
+                elif block.get("type") in ("image", "image_url"):
+                    parts.append(IMAGE_MARKER)
+            content = " ".join(p for p in parts if p)
+        if not isinstance(content, str) or not content.strip():
+            return None
+        if msg["role"] == "assistant":
+            return {"role": "assistant", "content": content}
+        # Le texte converti reste identique une fois rendu : le cache de prompt est conservé.
+        web = content.endswith(f" {WEB_MARKER}")
+        if web:
+            content = content[: -len(WEB_MARKER) - 1]
+        if content.endswith(_OLD_IMAGE_MARKER):
+            content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
+        return {"role": "user", "lines": [{"content": content}], **({"web": True} if web else {})}
+
+    @staticmethod
+    def _readable(line) -> bool:
+        if not isinstance(line, dict):
+            return False
+        try:
+            return bool(render_line(line).strip())
+        except (KeyError, TypeError, AttributeError):
+            return False
+
     async def _save(self) -> None:
         payload = {"version": FILE_VERSION, "channels": self._data}
         text = json.dumps(payload, ensure_ascii=False, indent=2)

+ 23 - 0
changelog.txt

@@ -2,6 +2,29 @@ CHANGELOG — Chatbot Discord
 ===========================
 
 
+Version 5.8
+-----------
+
+Nouveautés
+- Messages modifiés ou supprimés : dans son salon, Rika oublie le
+  contenu d'un message supprimé (texte, pièces jointes) et sa citation
+  dans les réponses qui le citaient. Elle se souvient seulement que
+  quelqu'un a écrit un message à ce moment-là, supprimé depuis, et garde
+  sa propre réponse : la conversation ne perd pas son fil. Un message
+  modifié prend son nouveau texte, avec une mention indiquant qu'il a
+  été modifié après sa réponse. Un message supprimé avant qu'elle y réponde (pendant une
+  absence par exemple) ne reçoit plus de réponse, y compris une mention
+  hors de son salon ; modifié, elle répond au nouveau texte.
+- Nouveau format de l'historique (version 3), plus lisible : chaque
+  message Discord y garde son identifiant, et la date, l'auteur, la
+  citation, le texte et les pièces jointes sont séparés. L'ancien
+  fichier est converti automatiquement au premier lancement, sans
+  réécrire le cache de prompt, et une copie en est gardée à côté
+  (conversation_history.json.v2-<date>). Revenir à la 5.7 demande de
+  remettre cette copie à la place du fichier : la 5.7 ne sait pas lire
+  le nouveau format et repartirait d'une mémoire vide.
+
+
 Version 5.7
 -----------