memory.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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 MemberRegistry, display_name
  13. logger = logging.getLogger("chatbot.memory")
  14. SERVER_FILE = "serveur.md"
  15. MEMBER_FILE = re.compile(r"^membres/(\d+)\.md$")
  16. MEMORY_TOOL = {
  17. "name": "memoire",
  18. "description": (
  19. "Lit ou modifie tes fichiers de mémoire : « serveur.md » (fichier général du serveur) et "
  20. "« membres/<identifiant>.md » (fiche d'un membre, chemin indiqué dans la liste des membres). "
  21. "Commandes : « lire » renvoie le fichier ; « ajouter » ajoute « texte » (une ou plusieurs lignes) "
  22. "à la fin du fichier, et le crée s'il n'existe pas ; « remplacer » remplace « ancien », recopié "
  23. "exactement et présent une seule fois dans le fichier, par « texte » (vide pour supprimer) ; "
  24. "« reecrire » remplace tout le fichier par « texte ». Les champs inutiles à la commande restent vides."
  25. ),
  26. "input_schema": {
  27. "type": "object",
  28. "properties": {
  29. "commande": {"type": "string", "enum": ["lire", "ajouter", "remplacer", "reecrire"]},
  30. "fichier": {"type": "string", "description": "serveur.md ou membres/<identifiant>.md"},
  31. "texte": {"type": "string"},
  32. "ancien": {"type": "string"},
  33. },
  34. "required": ["commande", "fichier", "texte", "ancien"],
  35. "additionalProperties": False,
  36. },
  37. "strict": True,
  38. }
  39. @dataclass
  40. class ToolResult:
  41. content: str
  42. is_error: bool = False
  43. written: bool = False # un fichier a été modifié
  44. class MemoryStore:
  45. """Lit et écrit les fichiers de mémoire, dans les seuls chemins autorisés et sous leur taille maximale."""
  46. def __init__(self, root: Path, members: MemberRegistry, member_limit: int, server_limit: int):
  47. self.root = root
  48. self.members = members
  49. self.member_limit = member_limit
  50. self.server_limit = server_limit
  51. self._lock = asyncio.Lock() # deux réponses en parallèle ne modifient pas un fichier en même temps
  52. # ------------------------------------------------------------------ accès
  53. def read(self, name: str) -> str:
  54. """Contenu d'un fichier de mémoire (vide s'il n'existe pas)."""
  55. try:
  56. return (self.root / name).read_text(encoding="utf-8").strip()
  57. except FileNotFoundError:
  58. return ""
  59. def member_file(self, member_id: int | str) -> str:
  60. return f"membres/{member_id}.md"
  61. async def run(self, command: dict) -> ToolResult:
  62. """Exécute une commande de l'outil « memoire »."""
  63. action = command.get("commande")
  64. name = self._normalize(command.get("fichier", ""))
  65. text = (command.get("texte") or "").strip()
  66. old = (command.get("ancien") or "").strip()
  67. limit, label = self._check(name)
  68. if limit is None:
  69. logger.warning("Mémoire : %s refusé sur %r (%s)", action, command.get("fichier"), label)
  70. return ToolResult(label, is_error=True)
  71. if action == "lire":
  72. content = self.read(name)
  73. return ToolResult(content or f"Le fichier {name} est vide : rien n'y est encore noté.")
  74. async with self._lock:
  75. before = self.read(name)
  76. if action == "ajouter":
  77. if not text:
  78. return ToolResult("Rien à ajouter : « texte » est vide.", is_error=True)
  79. # Une ligne déjà présente telle quelle n'est pas ajoutée une deuxième fois.
  80. existing = {line.strip() for line in before.splitlines()}
  81. new_lines = [line for line in text.splitlines() if line.strip() not in existing]
  82. after = "\n".join([before, *new_lines]) if before else text
  83. elif action == "remplacer":
  84. if not old:
  85. return ToolResult("« ancien » est vide : recopie exactement le passage à remplacer.", is_error=True)
  86. count = before.count(old)
  87. if count != 1:
  88. problem = "est introuvable" if count == 0 else f"apparaît {count} fois"
  89. return ToolResult(f"Rien n'a été remplacé : « {old} » {problem} dans {name}. "
  90. f"Recopie exactement un passage présent une seule fois.", is_error=True)
  91. after = before.replace(old, text)
  92. elif action == "reecrire":
  93. if not text:
  94. return ToolResult("« texte » est vide : un fichier ne peut pas être vidé ainsi.", is_error=True)
  95. after = text
  96. else:
  97. return ToolResult(f"Commande inconnue : {action!r}.", is_error=True)
  98. # Lignes vides laissées par une suppression : retirées.
  99. after = "\n".join(line.rstrip() for line in after.splitlines() if line.strip())
  100. if len(after) > limit:
  101. logger.info("Mémoire : %s refusé sur %s (%s), %d caractères pour %d au plus",
  102. action, name, label, len(after), limit)
  103. return ToolResult(
  104. f"Refusé : {name} ferait {len(after)} caractères pour {limit} au plus. Réécris tout le "
  105. f"fichier en plus court avec « reecrire », en fusionnant les lignes et en retirant le moins "
  106. f"important, puis ajoute la nouvelle information.", is_error=True)
  107. if after == before:
  108. return ToolResult(f"Aucun changement : {name} contenait déjà cela.")
  109. try:
  110. await asyncio.to_thread(self._write_atomic, self.root / name, after)
  111. except OSError as exc:
  112. logger.error("Mémoire : impossible d'écrire %s : %s", name, exc)
  113. return ToolResult("Écriture impossible pour l'instant, à cause d'une erreur du bot.", is_error=True)
  114. logger.info("Mémoire : %s sur %s (%s), %d → %d caractères", action, name, label, len(before), len(after))
  115. return ToolResult(f"C'est fait. {name} : {len(after)}/{limit} caractères.", written=True)
  116. # ------------------------------------------------------------ internes
  117. @staticmethod
  118. def _normalize(name: str) -> str:
  119. """Accepte aussi « /memories/membres/123.md » ou « membres/123 » pour « membres/123.md »."""
  120. name = name.strip().replace("\\", "/").removeprefix("/memories/").lstrip("/")
  121. if name.startswith("membres/") and not name.endswith(".md"):
  122. name += ".md"
  123. return name
  124. def _check(self, name: str) -> tuple[int | None, str]:
  125. """Taille maximale du fichier et nom lisible pour les logs, ou (None, raison du refus).
  126. Seuls deux chemins existent : aucun autre fichier ne peut être lu ou écrit."""
  127. if name == SERVER_FILE:
  128. return self.server_limit, "fichier général"
  129. match = MEMBER_FILE.match(name)
  130. if match is None:
  131. return None, "Fichier inconnu : seuls serveur.md et membres/<identifiant>.md existent."
  132. entry = self.members.get(match.group(1))
  133. if entry is None:
  134. return None, "Membre inconnu : utilise un chemin de la liste des membres."
  135. if not entry["memory"]:
  136. return None, f"{display_name(entry)} a désactivé la mémoire : ne note rien sur ce membre."
  137. return self.member_limit, display_name(entry)
  138. @staticmethod
  139. def _write_atomic(path: Path, text: str) -> None:
  140. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  141. path.parent.mkdir(parents=True, exist_ok=True)
  142. tmp = path.with_suffix(path.suffix + ".tmp")
  143. tmp.write_text(text + "\n", encoding="utf-8")
  144. os.replace(tmp, path)