Преглед изворни кода

Mémoire des membres : outil « memoire » et réaction 📝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet пре 3 дана
родитељ
комит
a761bf0f4c
7 измењених фајлова са 300 додато и 16 уклоњено
  1. 6 0
      .env.exemple
  2. 4 2
      README.md
  3. 83 12
      bot/claude_client.py
  4. 7 0
      bot/config.py
  5. 18 2
      bot/discord_bot.py
  6. 169 0
      bot/memory.py
  7. 13 0
      changelog.txt

+ 6 - 0
.env.exemple

@@ -45,6 +45,12 @@ PERSONALITY_FILE=personality_prompt.txt
 MEMORY=off
 # Dossier des fiches et du registre des membres. Vide = dossier « memories » à côté de HISTORY_FILE.
 MEMORY_DIR=
+# Taille maximale d'une fiche de membre et du fichier général, en caractères. Les fiches utiles
+# sont relues à chaque message : plus elles sont longues, plus chaque message coûte.
+MEMORY_MEMBER_LIMIT=3000
+MEMORY_SERVER_LIMIT=5000
+# Lectures et écritures maximales dans la mémoire pour une même réponse
+MEMORY_MAX_OPERATIONS=5
 
 # --- Images et PDF -------------------------------------------------------
 # Taille max d'une image en octets

+ 4 - 2
README.md

@@ -55,11 +55,11 @@ Il te faut :
    - dans **Privileged Gateway Intents**, active **Message Content Intent**. Les intents « Presence » et « Server Members » ne servent pas.
 3. Onglet **OAuth2 > URL Generator** :
    - coche les scopes **bot** et **applications.commands** (ce dernier pour `/reset` et `/skip`) ;
-   - dans les permissions, coche **View Channels**, **Send Messages** et **Read Message History** ;
+   - dans les permissions, coche **View Channels**, **Send Messages** et **Read Message History**, plus **Add Reactions** avec `MEMORY=on` ;
    - ouvre l'adresse générée et invite le bot sur ton serveur.
 4. Dans Discord, active le mode développeur (**Paramètres utilisateur > Avancés > Mode développeur**), puis fais un clic droit sur le salon du bot > **Copier l'identifiant du salon** : c'est le `CHANNEL_ID`.
 
-Le bot n'a besoin d'aucune autre permission : il n'utilise que les emojis et stickers du serveur.
+Le bot n'a besoin d'aucune autre permission : il n'utilise que les emojis et stickers du serveur. Pour un bot déjà invité, **Add Reactions** s'ajoute à son rôle dans les paramètres du serveur.
 
 ## Installation
 
@@ -150,6 +150,7 @@ Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque app
 | `presence_state.json` | Statut Discord en cours, repris après un redémarrage |
 | `slash_commands.sha256` | Empreinte des commandes slash, pour ne les envoyer à Discord que quand elles changent |
 | `memories/registre.json` | Avec `MEMORY=on` : membres vus par le bot, leurs noms et anciens pseudos, et leurs réglages de la mémoire. Si le fichier est illisible, il est mis de côté sous un nom daté |
+| `memories/membres/<id>.md`, `memories/serveur.md` | Avec `MEMORY=on` : fiche de chaque membre et fichier général du serveur, tenus par Claude |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 
 Le détail de chaque version est dans `changelog.txt`.
@@ -163,6 +164,7 @@ bot/models.py            particularités de chaque modèle Claude (réflexion, t
 bot/claude_client.py     appels à l'API de Claude, personnalité
 bot/history.py           mémoire des conversations
 bot/members.py           registre des membres (noms, anciens pseudos, réglages de la mémoire)
+bot/memory.py            fiches des membres et fichier général : outil « memoire » de Claude
 bot/discord_bot.py       événements Discord, réponses, /reset et /skip
 bot/emojis.py            emojis du serveur : liste pour Claude, noms remplacés à l'envoi
 bot/presence.py          statut Discord : tirage au sort et changements

+ 83 - 12
bot/claude_client.py

@@ -12,6 +12,7 @@ import anthropic
 
 from .config import Settings
 from .emojis import DISCORD_EMOJI, SHORT_EMOJI
+from .memory import MEMORY_TOOL, MemoryStore, ToolResult
 from .models import thinking_off
 from .text_utils import strip_reasoning
 
@@ -58,15 +59,35 @@ WEB_NOTE = (
     "que tu ne connais pas, ou un lien partagé dans la conversation ; sinon réponds directement. "
     "Garde des réponses courtes ; quand c'est utile, nomme simplement ta source dans ta phrase "
     "(par exemple « selon Famitsu »), sans recopier d'adresse. "
-    # Le texte écrit avant un appel d'outil n'est jamais affiché (voir _final_text). L'interdire
-    # pousse au contraire le modèle à écrire ses appels d'outils sous forme de texte.
-    "Quand tu utilises un outil, tu peux d'abord dire une courte phrase. "
-    "Si aucun outil ne permet de faire ce qu'on te demande, dis-le au lieu de deviner. "
     "Dans les messages précédents, la mention « [recherche web : …] » signifie que tu as bien cherché sur Internet "
     "pour y répondre : ta réponse d'alors s'appuyait sur de vrais résultats, même si tu ne les vois plus. "
     "Ne prétends donc jamais avoir inventé une réponse marquée ainsi."
 )
 
+# Ajouté quand MEMORY=on. Les fiches jointes et la liste des membres viennent avec chaque message.
+MEMBER_MEMORY_NOTE = (
+    "\n\nMémoire des membres : avec ton outil « memoire », tu tiens une fiche sur chaque membre et un fichier "
+    "général sur le serveur (blagues récurrentes, événements, habitudes). Quand tu apprends sur quelqu'un "
+    "quelque chose qui vaut la peine d'être retenu, note-le dans sa fiche, quelle que soit la personne qui "
+    "te l'apprend ; une info donnée par quelqu'un d'autre que l'intéressé se note avec sa source "
+    "(« d'après Toto »). Inutile de dire dans ta réponse tout ce que tu retiens. "
+    "Écris en style télégraphique, une ligne par fait, sans doublon, et mets à jour la ligne existante plutôt "
+    "que d'en ajouter une (« jeu préféré : Zelda » devient « jeu préféré : Elden Ring »). La première ligne "
+    "d'une fiche est « Surnoms : … » : complète-la dès que tu comprends qu'un surnom désigne ce membre. "
+    "Si tu ne sais pas avec certitude de qui on parle, ne note rien, ou demande. "
+    "Une fiche est limitée à {member_limit} caractères et le fichier général à {server_limit} : si une note "
+    "est refusée faute de place, réécris tout le fichier en plus court (« reecrire »), en fusionnant les "
+    "lignes et en retirant le moins important."
+)
+
+# Ajouté quand Claude a au moins un outil (recherche web ou mémoire). Le texte écrit avant un appel
+# d'outil n'est jamais affiché (voir _final_text) ; l'interdire pousserait au contraire le modèle à
+# écrire ses appels d'outils sous forme de texte.
+TOOL_NOTE = (
+    "\n\nQuand tu utilises un outil, tu peux d'abord dire une courte phrase. "
+    "Si aucun outil ne permet de faire ce qu'on te demande, dis-le au lieu de deviner."
+)
+
 # Ajouté quand PRESENCE=on : le statut Discord en cours est indiqué au début des messages.
 PRESENCE_NOTE = (
     "\n\nAu début d'un message, « [ton statut Discord : …] » indique ce que tu fais à ce moment-là "
@@ -136,11 +157,13 @@ class ImageData:
 class Answer:
     text: str
     web_used: bool  # Claude a fait au moins une recherche web ou lu une page
+    memory_written: bool = False  # Claude a noté quelque chose dans sa mémoire
 
 
 class ClaudeClient:
-    def __init__(self, settings: Settings):
+    def __init__(self, settings: Settings, memory: MemoryStore | None = None):
         self.settings = settings
+        self.memory = memory  # None : mémoire des membres désactivée
         # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…).
         self._client = anthropic.AsyncAnthropic(
             api_key=settings.anthropic_api_key,
@@ -204,6 +227,11 @@ class ClaudeClient:
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
         if self.settings.web_search:
             system += WEB_NOTE
+        if self.memory:
+            system += MEMBER_MEMORY_NOTE.format(member_limit=self.memory.member_limit,
+                                                server_limit=self.memory.server_limit)
+        if self.settings.web_search or self.memory:
+            system += TOOL_NOTE
         if self.settings.presence:
             system += PRESENCE_NOTE
         if emojis:
@@ -231,18 +259,39 @@ class ClaudeClient:
             params["thinking"] = off
         if self.settings.effort:
             params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
+        tools: list[dict] = []
         if self.settings.web_search:
-            # Outils exécutés par Anthropic : pas de boucle d'outils à gérer de notre côté.
-            params["tools"] = [
+            # Outils exécutés par Anthropic : leurs appels se font sans nous.
+            tools += [
                 {"type": "web_search_20260209", "name": "web_search", "max_uses": self.settings.web_max_uses},
                 {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
             ]
+        if self.memory:
+            tools.append(MEMORY_TOOL)  # exécuté par le bot : voir _run_tools
+        if tools:
+            params["tools"] = tools
 
         responses = [await self._call(params, attachments)]
-        # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
-        # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
-        while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
-            messages = messages + [{"role": "assistant", "content": responses[-1].content}]
+        pauses = operations = 0
+        memory_written = False
+        while True:
+            last = responses[-1]
+            # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
+            # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
+            if last.stop_reason == "pause_turn" and pauses < MAX_CONTINUATIONS:
+                pauses += 1
+                follow_up = []
+            # Appel à l'outil « memoire » : le bot l'exécute et renvoie le résultat. Une fois la limite
+            # d'opérations atteinte, un dernier tour lui demande de répondre, puis la boucle s'arrête.
+            elif last.stop_reason == "tool_use" and self.memory and operations <= self.settings.memory_max_operations:
+                results, written = await self._run_tools(last, operations)
+                follow_up = [{"role": "user", "content": results}]
+                memory_written = memory_written or written
+                operations += len(results)
+            else:
+                break
+            # Réponse renvoyée telle quelle, réflexion comprise : l'API l'exige pour continuer.
+            messages = messages + [{"role": "assistant", "content": last.content}, *follow_up]
             responses.append(await self._call({**params, "messages": messages}, attachments))
 
         text = self._final_text(responses)
@@ -262,7 +311,29 @@ class ClaudeClient:
         if not text:
             logger.warning("Réponse vide de Claude (stop_reason=%s).", stop_reason)
             raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
-        return Answer(text, web_used)
+        return Answer(text, web_used, memory_written)
+
+    async def _run_tools(self, response, done: int) -> tuple[list[dict], bool]:
+        """Exécute les appels à l'outil « memoire » d'une réponse. Renvoie leurs résultats, à envoyer
+        tous dans un même message comme l'exige l'API, et si un fichier a été modifié.
+        done : opérations déjà faites pour cette réponse."""
+        results = []
+        written = False
+        for block in response.content:
+            if block.type != "tool_use":
+                continue
+            if done >= self.settings.memory_max_operations:
+                result = ToolResult("Limite d'opérations atteinte pour ce message : réponds maintenant, "
+                                    "sans rien noter de plus.", is_error=True)
+            elif block.name != MEMORY_TOOL["name"] or not isinstance(block.input, dict):
+                result = ToolResult(f"Outil inconnu : {block.name}.", is_error=True)
+            else:
+                result = await self.memory.run(block.input)
+            done += 1
+            written = written or result.written
+            results.append({"type": "tool_result", "tool_use_id": block.id, "content": result.content,
+                            "is_error": result.is_error})
+        return results, written
 
     async def write_custom_status(self, kind: str, now: str, duration: str, recent: list[str],
                                   max_length: int) -> str | None:

+ 7 - 0
bot/config.py

@@ -135,6 +135,9 @@ class Settings:
     # Mémoire des membres : fiches tenues par Claude (voir members.py)
     memory: bool
     memory_dir: Path
+    memory_member_limit: int   # caractères maximum d'une fiche de membre
+    memory_server_limit: int   # caractères maximum du fichier général
+    memory_max_operations: int  # lectures et écritures maximum par réponse
 
     # Images et PDF
     max_image_size: int
@@ -220,6 +223,10 @@ def load_settings() -> Settings:
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
         memory=memory_raw == "on",
         memory_dir=Path(memory_dir) if memory_dir else history_file.with_name("memories"),
+        # Chaque fiche jointe est relue à chaque message : plus elle est longue, plus il coûte.
+        memory_member_limit=_get_int("MEMORY_MEMBER_LIMIT", default=3000, minimum=200),
+        memory_server_limit=_get_int("MEMORY_SERVER_LIMIT", default=5000, minimum=200),
+        memory_max_operations=_get_int("MEMORY_MAX_OPERATIONS", default=5, minimum=1),
         # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
         max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
         # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).

+ 18 - 2
bot/discord_bot.py

@@ -23,6 +23,7 @@ from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
 from .members import MemberRegistry
+from .memory import MemoryStore
 from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
 from .presence import PresenceManager, Status
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
@@ -40,6 +41,8 @@ QUOTE_MAX_LENGTH = 500
 USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
 # En passant absent ou en « Ne pas déranger », le bot prévient son salon si on y a parlé depuis moins de :
 DEPARTURE_WINDOW = timedelta(minutes=3)
+# Ajoutée au message auquel le bot répond quand il a noté quelque chose dans sa mémoire.
+NOTED_REACTION = "📝"
 
 
 def looks_like_image(attachment: discord.Attachment) -> bool:
@@ -114,9 +117,11 @@ class ChatBot(commands.Bot):
             allowed_mentions=discord.AllowedMentions.none(),
         )
         self.settings = settings
-        self.claude = ClaudeClient(settings)
-        self.history = ConversationHistory(settings.history_file, settings.max_history_length)
         self.members = MemberRegistry(settings.memory_dir / "registre.json") if settings.memory else None
+        self.memory = MemoryStore(settings.memory_dir, self.members, settings.memory_member_limit,
+                                  settings.memory_server_limit) if self.members else None
+        self.claude = ClaudeClient(settings, self.memory)
+        self.history = ConversationHistory(settings.history_file, settings.max_history_length)
         self._pending: dict[int, list[PendingMessage]] = {}  # messages en attente de réponse, par salon
         self._announced = False
         self.presence = PresenceManager(self, self.claude, settings) if settings.presence else None
@@ -355,6 +360,8 @@ class ChatBot(commands.Bot):
                 self.forget_waiting(*(p.message for p in batch))
             await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
         await self.send_answer(last, answer.text)
+        if answer.memory_written:
+            await self.mark_noted(last)
 
     async def handle_mention(self, message: discord.Message) -> None:
         if not self.available() or message.channel.id in self._waiters:
@@ -440,6 +447,8 @@ class ChatBot(commands.Bot):
                 return
         # En retard, la réponse cite la mention : on sait à quoi elle répond.
         await self.send_answer(message, answer.text, reply=late is not None)
+        if answer.memory_written:
+            await self.mark_noted(message)
 
     # ------------------------------------------------------ réponses différées
 
@@ -776,6 +785,13 @@ class ChatBot(commands.Bot):
                 logger.error("Envoi de la réponse impossible : %s", exc)
                 return
 
+    async def mark_noted(self, message: discord.Message) -> None:
+        """Réaction 📝 sur le message auquel le bot vient de répondre : il a noté quelque chose."""
+        try:
+            await message.add_reaction(NOTED_REACTION)
+        except discord.HTTPException as exc:  # permission « Ajouter des réactions » absente, message supprimé…
+            logger.warning("Réaction %s impossible : %s", NOTED_REACTION, exc)
+
     async def safe_reply(self, message: discord.Message, text: str) -> None:
         try:
             await message.reply(text, mention_author=False)

+ 169 - 0
bot/memory.py

@@ -0,0 +1,169 @@
+"""Fichiers de mémoire tenus par Claude : une fiche par membre et un fichier général du serveur.
+
+Outil maison plutôt que l'outil mémoire d'Anthropic (memory_20250818) : celui-ci ajoute au prompt
+l'ordre de consulter tout le dossier avant chaque réponse, soit un appel de plus à chaque message,
+alors que le bot joint déjà lui-même les fiches utiles."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from .members import MemberRegistry, display_name
+
+logger = logging.getLogger("chatbot.memory")
+
+SERVER_FILE = "serveur.md"
+MEMBER_FILE = re.compile(r"^membres/(\d+)\.md$")
+
+MEMORY_TOOL = {
+    "name": "memoire",
+    "description": (
+        "Lit ou modifie tes fichiers de mémoire : « serveur.md » (fichier général du serveur) et "
+        "« membres/<identifiant>.md » (fiche d'un membre, chemin indiqué dans la liste des membres). "
+        "Commandes : « lire » renvoie le fichier ; « ajouter » ajoute « texte » (une ou plusieurs lignes) "
+        "à la fin du fichier, et le crée s'il n'existe pas ; « remplacer » remplace « ancien », recopié "
+        "exactement et présent une seule fois dans le fichier, par « texte » (vide pour supprimer) ; "
+        "« reecrire » remplace tout le fichier par « texte ». Les champs inutiles à la commande restent vides."
+    ),
+    "input_schema": {
+        "type": "object",
+        "properties": {
+            "commande": {"type": "string", "enum": ["lire", "ajouter", "remplacer", "reecrire"]},
+            "fichier": {"type": "string", "description": "serveur.md ou membres/<identifiant>.md"},
+            "texte": {"type": "string"},
+            "ancien": {"type": "string"},
+        },
+        "required": ["commande", "fichier", "texte", "ancien"],
+        "additionalProperties": False,
+    },
+    "strict": True,
+}
+
+
+@dataclass
+class ToolResult:
+    content: str
+    is_error: bool = False
+    written: bool = False  # un fichier a été modifié
+
+
+class MemoryStore:
+    """Lit et écrit les fichiers de mémoire, dans les seuls chemins autorisés et sous leur taille maximale."""
+
+    def __init__(self, root: Path, members: MemberRegistry, member_limit: int, server_limit: int):
+        self.root = root
+        self.members = members
+        self.member_limit = member_limit
+        self.server_limit = server_limit
+        self._lock = asyncio.Lock()  # deux réponses en parallèle ne modifient pas un fichier en même temps
+
+    # ------------------------------------------------------------------ accès
+
+    def read(self, name: str) -> str:
+        """Contenu d'un fichier de mémoire (vide s'il n'existe pas)."""
+        try:
+            return (self.root / name).read_text(encoding="utf-8").strip()
+        except FileNotFoundError:
+            return ""
+
+    def member_file(self, member_id: int | str) -> str:
+        return f"membres/{member_id}.md"
+
+    async def run(self, command: dict) -> ToolResult:
+        """Exécute une commande de l'outil « memoire »."""
+        action = command.get("commande")
+        name = self._normalize(command.get("fichier", ""))
+        text = (command.get("texte") or "").strip()
+        old = (command.get("ancien") or "").strip()
+
+        limit, label = self._check(name)
+        if limit is None:
+            logger.warning("Mémoire : %s refusé sur %r (%s)", action, command.get("fichier"), label)
+            return ToolResult(label, is_error=True)
+
+        if action == "lire":
+            content = self.read(name)
+            return ToolResult(content or f"Le fichier {name} est vide : rien n'y est encore noté.")
+
+        async with self._lock:
+            before = self.read(name)
+            if action == "ajouter":
+                if not text:
+                    return ToolResult("Rien à ajouter : « texte » est vide.", is_error=True)
+                # Une ligne déjà présente telle quelle n'est pas ajoutée une deuxième fois.
+                existing = {line.strip() for line in before.splitlines()}
+                new_lines = [line for line in text.splitlines() if line.strip() not in existing]
+                after = "\n".join([before, *new_lines]) if before else text
+            elif action == "remplacer":
+                if not old:
+                    return ToolResult("« ancien » est vide : recopie exactement le passage à remplacer.", is_error=True)
+                count = before.count(old)
+                if count != 1:
+                    problem = "est introuvable" if count == 0 else f"apparaît {count} fois"
+                    return ToolResult(f"Rien n'a été remplacé : « {old} » {problem} dans {name}. "
+                                      f"Recopie exactement un passage présent une seule fois.", is_error=True)
+                after = before.replace(old, text)
+            elif action == "reecrire":
+                if not text:
+                    return ToolResult("« texte » est vide : un fichier ne peut pas être vidé ainsi.", is_error=True)
+                after = text
+            else:
+                return ToolResult(f"Commande inconnue : {action!r}.", is_error=True)
+
+            # Lignes vides laissées par une suppression : retirées.
+            after = "\n".join(line.rstrip() for line in after.splitlines() if line.strip())
+            if len(after) > limit:
+                logger.info("Mémoire : %s refusé sur %s (%s), %d caractères pour %d au plus",
+                            action, name, label, len(after), limit)
+                return ToolResult(
+                    f"Refusé : {name} ferait {len(after)} caractères pour {limit} au plus. Réécris tout le "
+                    f"fichier en plus court avec « reecrire », en fusionnant les lignes et en retirant le moins "
+                    f"important, puis ajoute la nouvelle information.", is_error=True)
+            if after == before:
+                return ToolResult(f"Aucun changement : {name} contenait déjà cela.")
+            try:
+                await asyncio.to_thread(self._write_atomic, self.root / name, after)
+            except OSError as exc:
+                logger.error("Mémoire : impossible d'écrire %s : %s", name, exc)
+                return ToolResult("Écriture impossible pour l'instant, à cause d'une erreur du bot.", is_error=True)
+        logger.info("Mémoire : %s sur %s (%s), %d → %d caractères", action, name, label, len(before), len(after))
+        return ToolResult(f"C'est fait. {name} : {len(after)}/{limit} caractères.", written=True)
+
+    # ------------------------------------------------------------ internes
+
+    @staticmethod
+    def _normalize(name: str) -> str:
+        """Accepte aussi « /memories/membres/123.md » ou « membres/123 » pour « membres/123.md »."""
+        name = name.strip().replace("\\", "/").removeprefix("/memories/").lstrip("/")
+        if name.startswith("membres/") and not name.endswith(".md"):
+            name += ".md"
+        return name
+
+    def _check(self, name: str) -> tuple[int | None, str]:
+        """Taille maximale du fichier et nom lisible pour les logs, ou (None, raison du refus).
+
+        Seuls deux chemins existent : aucun autre fichier ne peut être lu ou écrit."""
+        if name == SERVER_FILE:
+            return self.server_limit, "fichier général"
+        match = MEMBER_FILE.match(name)
+        if match is None:
+            return None, "Fichier inconnu : seuls serveur.md et membres/<identifiant>.md existent."
+        entry = self.members.get(match.group(1))
+        if entry is None:
+            return None, "Membre inconnu : utilise un chemin de la liste des membres."
+        if not entry["memory"]:
+            return None, f"{display_name(entry)} a désactivé la mémoire : ne note rien sur ce membre."
+        return self.member_limit, display_name(entry)
+
+    @staticmethod
+    def _write_atomic(path: Path, text: str) -> None:
+        # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
+        path.parent.mkdir(parents=True, exist_ok=True)
+        tmp = path.with_suffix(path.suffix + ".tmp")
+        tmp.write_text(text + "\n", encoding="utf-8")
+        os.replace(tmp, path)

+ 13 - 0
changelog.txt

@@ -13,6 +13,19 @@ Nouveautés
   5 derniers anciens pseudos, mis à jour à chaque message. Le registre
   est dans memories/registre.json, à côté de l'historique (MEMORY_DIR
   pour un autre dossier), et n'est réécrit que si quelque chose change.
+  Rika tient une fiche par membre (memories/membres/<id>.md) et un
+  fichier général du serveur (memories/serveur.md), avec un outil
+  « memoire » exécuté par le bot : lire, ajouter, remplacer ou réécrire,
+  dans ces seuls fichiers. Elle note en style télégraphique, une ligne
+  par fait, avec la source quand l'info vient d'un autre membre. Une
+  fiche est limitée à 3000 caractères (MEMORY_MEMBER_LIMIT) et le
+  fichier général à 5000 (MEMORY_SERVER_LIMIT) : une note de trop est
+  refusée, et Rika réécrit alors le fichier en plus court. Au plus 5
+  lectures ou écritures par réponse (MEMORY_MAX_OPERATIONS). Quand elle
+  a noté quelque chose, elle ajoute 📝 au message auquel elle répond
+  (permission « Ajouter des réactions » nécessaire). Outil maison plutôt
+  que celui d'Anthropic, qui lui ferait relire tout le dossier avant
+  chaque réponse (un appel de plus à chaque message).
 
 Divers
 - /reset et /skip sont réservés aux membres qui ont la permission