memory.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """Fichiers de mémoire tenus par Claude : une fiche par membre et un fichier général du serveur.
  2. Outil maison plutôt que l'outil mémoire d'Anthropic (memory_20250818) : celui-ci ajoute au prompt
  3. l'ordre de consulter tout le dossier avant chaque réponse, soit un appel de plus à chaque message,
  4. alors que le bot joint déjà lui-même les fiches utiles."""
  5. from __future__ import annotations
  6. import asyncio
  7. import logging
  8. import os
  9. import re
  10. from dataclasses import dataclass
  11. from pathlib import Path
  12. from .members import NAME_KEYS, MemberRegistry, display_name
  13. logger = logging.getLogger("chatbot.memory")
  14. SERVER_FILE = "serveur.md"
  15. MEMBER_FILE = re.compile(r"^membres/(\d+)\.md$")
  16. # Première ligne d'une fiche : les surnoms du membre, repris dans la liste des membres.
  17. NICKNAMES_LINE = re.compile(r"^surnoms\s*:\s*(.*)$", re.IGNORECASE)
  18. MEMORY_TOOL = {
  19. "name": "memoire",
  20. "description": (
  21. "Lit ou modifie tes fichiers de mémoire : « serveur.md » (fichier général du serveur) et "
  22. "« membres/<identifiant>.md » (fiche d'un membre, chemin indiqué dans la liste des membres). "
  23. "Commandes : « lire » renvoie le fichier ; « ajouter » ajoute « texte » (une ou plusieurs lignes) "
  24. "à la fin du fichier, et le crée s'il n'existe pas ; « remplacer » remplace « ancien », recopié "
  25. "exactement et présent une seule fois dans le fichier, par « texte » (vide pour supprimer) ; "
  26. "« reecrire » remplace tout le fichier par « texte ». Les champs inutiles à la commande restent vides."
  27. ),
  28. "input_schema": {
  29. "type": "object",
  30. "properties": {
  31. "commande": {"type": "string", "enum": ["lire", "ajouter", "remplacer", "reecrire"]},
  32. "fichier": {"type": "string", "description": "serveur.md ou membres/<identifiant>.md"},
  33. "texte": {"type": "string"},
  34. "ancien": {"type": "string"},
  35. },
  36. "required": ["commande", "fichier", "texte", "ancien"],
  37. "additionalProperties": False,
  38. },
  39. "strict": True,
  40. }
  41. @dataclass
  42. class ToolResult:
  43. content: str
  44. is_error: bool = False
  45. written: bool = False # un fichier a été modifié
  46. class MemoryStore:
  47. """Lit et écrit les fichiers de mémoire, dans les seuls chemins autorisés et sous leur taille maximale."""
  48. def __init__(self, root: Path, members: MemberRegistry, member_limit: int, server_limit: int):
  49. self.root = root
  50. self.members = members
  51. self.member_limit = member_limit
  52. self.server_limit = server_limit
  53. self._lock = asyncio.Lock() # deux réponses en parallèle ne modifient pas un fichier en même temps
  54. # ------------------------------------------------------------------ accès
  55. def read(self, name: str) -> str:
  56. """Contenu d'un fichier de mémoire (vide s'il n'existe pas)."""
  57. try:
  58. return (self.root / name).read_text(encoding="utf-8").strip()
  59. except FileNotFoundError:
  60. return ""
  61. def member_file(self, member_id: int | str) -> str:
  62. return f"membres/{member_id}.md"
  63. def nicknames(self, member_id: int | str) -> list[str]:
  64. """Surnoms notés sur la première ligne de la fiche (« Surnoms : pnt, le Pent »)."""
  65. first_line = self.read(self.member_file(member_id)).partition("\n")[0]
  66. match = NICKNAMES_LINE.match(first_line.strip())
  67. if match is None:
  68. return []
  69. return [name.strip() for name in re.split(r"[,;]", match.group(1)) if name.strip()]
  70. def system_context(self) -> str:
  71. """Liste des membres (tous leurs noms et le chemin de leur fiche) et fichier général, pour le
  72. prompt système. Ils changent rarement : le prompt reste presque toujours en cache."""
  73. lines = []
  74. for member_id, entry in self.members.members().items():
  75. if not entry["memory"]:
  76. continue # mémoire désactivée : Rika ne doit rien noter sur ce membre
  77. name = display_name(entry)
  78. # Sans doublon, majuscules comprises (« penta » et « Penta » : un seul nom).
  79. others: dict[str, str] = {name.casefold(): name}
  80. for other in [entry[key] for key in NAME_KEYS] + self.nicknames(member_id):
  81. if other:
  82. others.setdefault(other.casefold(), other)
  83. others = list(others.values())[1:]
  84. details = []
  85. if others:
  86. details.append("aussi : " + ", ".join(others))
  87. if entry["former_names"]:
  88. details.append("anciens pseudos : " + ", ".join(entry["former_names"]))
  89. suffix = f" ({' ; '.join(details)})" if details else ""
  90. lines.append(f"- {name}{suffix} → {self.member_file(member_id)}")
  91. members = "\n".join(lines) or "(aucun membre pour l'instant)"
  92. server = self.read(SERVER_FILE) or "(vide pour l'instant)"
  93. return (f"\n\nListe des membres, avec tous leurs noms et le chemin de leur fiche :\n{members}"
  94. f"\n\nTon fichier général ({SERVER_FILE}) :\n{server}")
  95. def notes_for(self, member_ids: list[int]) -> str:
  96. """Fiches des membres concernés par un message, jointes à ce message (vide s'il n'y en a aucune).
  97. Les membres inconnus du registre ou qui ont désactivé la mémoire sont ignorés."""
  98. sections = []
  99. for member_id in dict.fromkeys(member_ids):
  100. entry = self.members.get(member_id)
  101. if entry is None or not entry["memory"]:
  102. continue
  103. path = self.member_file(member_id)
  104. content = self.read(path) or "(rien de noté pour l'instant)"
  105. sections.append(f"=== {display_name(entry)} ({path}) ===\n{content}")
  106. if not sections:
  107. return ""
  108. return ("[Tes fiches sur les membres concernés par ce message, jointes automatiquement : "
  109. "elles ne font pas partie du message.]\n\n" + "\n\n".join(sections))
  110. async def run(self, command: dict) -> ToolResult:
  111. """Exécute une commande de l'outil « memoire »."""
  112. action = command.get("commande")
  113. name = self._normalize(command.get("fichier", ""))
  114. text = (command.get("texte") or "").strip()
  115. old = (command.get("ancien") or "").strip()
  116. limit, label = self._check(name)
  117. if limit is None:
  118. logger.warning("Mémoire : %s refusé sur %r (%s)", action, command.get("fichier"), label)
  119. return ToolResult(label, is_error=True)
  120. if action == "lire":
  121. content = self.read(name)
  122. return ToolResult(content or f"Le fichier {name} est vide : rien n'y est encore noté.")
  123. async with self._lock:
  124. before = self.read(name)
  125. if action == "ajouter":
  126. if not text:
  127. return ToolResult("Rien à ajouter : « texte » est vide.", is_error=True)
  128. # Une ligne déjà présente telle quelle n'est pas ajoutée une deuxième fois.
  129. existing = {line.strip() for line in before.splitlines()}
  130. new_lines = [line for line in text.splitlines() if line.strip() not in existing]
  131. after = "\n".join([before, *new_lines]) if before else text
  132. elif action == "remplacer":
  133. if not old:
  134. return ToolResult("« ancien » est vide : recopie exactement le passage à remplacer.", is_error=True)
  135. count = before.count(old)
  136. if count != 1:
  137. problem = "est introuvable" if count == 0 else f"apparaît {count} fois"
  138. return ToolResult(f"Rien n'a été remplacé : « {old} » {problem} dans {name}. "
  139. f"Recopie exactement un passage présent une seule fois.", is_error=True)
  140. after = before.replace(old, text)
  141. elif action == "reecrire":
  142. if not text:
  143. return ToolResult("« texte » est vide : un fichier ne peut pas être vidé ainsi.", is_error=True)
  144. after = text
  145. else:
  146. return ToolResult(f"Commande inconnue : {action!r}.", is_error=True)
  147. # Lignes vides laissées par une suppression : retirées.
  148. after = "\n".join(line.rstrip() for line in after.splitlines() if line.strip())
  149. if len(after) > limit:
  150. logger.info("Mémoire : %s refusé sur %s (%s), %d caractères pour %d au plus",
  151. action, name, label, len(after), limit)
  152. return ToolResult(
  153. f"Refusé : {name} ferait {len(after)} caractères pour {limit} au plus. Réécris tout le "
  154. f"fichier en plus court avec « reecrire », en fusionnant les lignes et en retirant le moins "
  155. f"important, puis ajoute la nouvelle information.", is_error=True)
  156. if after == before:
  157. return ToolResult(f"Aucun changement : {name} contenait déjà cela.")
  158. try:
  159. await asyncio.to_thread(self._write_atomic, self.root / name, after)
  160. except OSError as exc:
  161. logger.error("Mémoire : impossible d'écrire %s : %s", name, exc)
  162. return ToolResult("Écriture impossible pour l'instant, à cause d'une erreur du bot.", is_error=True)
  163. logger.info("Mémoire : %s sur %s (%s), %d → %d caractères", action, name, label, len(before), len(after))
  164. return ToolResult(f"C'est fait. {name} : {len(after)}/{limit} caractères.", written=True)
  165. # ------------------------------------------------------------ internes
  166. @staticmethod
  167. def _normalize(name: str) -> str:
  168. """Accepte aussi « /memories/membres/123.md » ou « membres/123 » pour « membres/123.md »."""
  169. name = name.strip().replace("\\", "/").removeprefix("/memories/").lstrip("/")
  170. if name.startswith("membres/") and not name.endswith(".md"):
  171. name += ".md"
  172. return name
  173. def _check(self, name: str) -> tuple[int | None, str]:
  174. """Taille maximale du fichier et nom lisible pour les logs, ou (None, raison du refus).
  175. Seuls deux chemins existent : aucun autre fichier ne peut être lu ou écrit."""
  176. if name == SERVER_FILE:
  177. return self.server_limit, "fichier général"
  178. match = MEMBER_FILE.match(name)
  179. if match is None:
  180. return None, "Fichier inconnu : seuls serveur.md et membres/<identifiant>.md existent."
  181. entry = self.members.get(match.group(1))
  182. if entry is None:
  183. return None, "Membre inconnu : utilise un chemin de la liste des membres."
  184. if not entry["memory"]:
  185. return None, f"{display_name(entry)} a désactivé la mémoire : ne note rien sur ce membre."
  186. return self.member_limit, display_name(entry)
  187. @staticmethod
  188. def _write_atomic(path: Path, text: str) -> None:
  189. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  190. path.parent.mkdir(parents=True, exist_ok=True)
  191. tmp = path.with_suffix(path.suffix + ".tmp")
  192. tmp.write_text(text + "\n", encoding="utf-8")
  193. os.replace(tmp, path)