Les sessions persistantes — explication
De quoi s'agit-il ?
Une session est une conversation : le prompt système, les questions, les réponses et les appels d'outils entre les deux. Jusqu'au 16 septembre 2026 elle vivait en mémoire et mourait avec le processus ; un éditeur qui relançait mm ne pouvait qu'ouvrir un nouveau fil, ce que Zed exprimait par « Loading or resuming sessions is not supported by this agent ». Les sessions persistantes (sessions.enabled) écrivent chaque conversation dans un fichier et permettent aux deux façades de la retrouver.
Pourquoi des fichiers, par projet
Les sessions vivent sous le projet, dans .mm/sessions/, un fichier JSON chacune, réécrit en entier après chaque tour. Trois raisons. Une conversation porte sur un projet — ses fichiers, ses commandes — et a sa place à côté d'eux, pas dans une base de données globale où les projets se mélangent. Les fichiers se lisent : jq répond à « qu'a lu le modèle ? » sans outil. Et la réécriture complète est ce dont la compression a besoin : /compact remplace les vieux tours par un résumé, et le fichier doit dire la même chose que la mémoire, pas la mémoire plus tout ce qu'elle contenait avant.
L'identifiant est l'heure de création suivie d'un suffixe aléatoire (20260916-101530-4f2a9c1e) : une liste se trie par date, et deux façades ne se marchent jamais dessus.
Pourquoi un lancement est une nouvelle session
Quand mm démarre, il ouvre une nouvelle session et indique comment retrouver les anciennes, plutôt que de reprendre la dernière de lui-même. C'est le comportement de docker agent (cagent) : sa commande run ne reprend jamais seule — --session -1 ou le navigateur /sessions de son interface le font — et c'est le défaut le plus sûr. La dernière conversation peut porter sur autre chose ; la reprendre en silence donnerait au modèle un contexte périmé, et l'utilisateur ne s'en apercevrait qu'aux réponses de travers. Un /resume explicite coûte une ligne et évite la surprise.
Le terminal écrit le fichier à la première question, pas au lancement : un lancement sans question ne laisse aucune trace, et /sessions liste des conversations, pas des lancements.
Pourquoi session/load et un rejeu complet
L'Agent Client Protocol offre deux retours vers une session. session/load fait partie de la spécification stable : l'agent rejoue la conversation sous forme de notifications session/update et l'éditeur reconstruit le fil à partir d'elles. session/resume est l'autre : l'agent ne fait que réenregistrer la session, et l'éditeur garde ce qu'il affichait. docker agent a choisi la seconde voie (loadSession: false, capacité Resume, base SQLite dans ~/.cagent/session.db). mini-me a choisi la première, parce qu'elle était stable et parce qu'un rejeu remet le fil en place même dans un éditeur qui n'avait rien gardé.
Ce choix tient toujours, mais il n'est plus exclusif. Quand il a été fait, session/list et session/resume étaient des extensions instables. Le SDK qu'utilise mini-me (acp-go-sdk v0.13.5) les place désormais dans son interface Agent stable, sans marque d'instabilité — contrairement à session/fork et session/delete —, et Zed n'affiche les fils passés d'un agent que si celui-ci annonce sessionCapabilities.list. Depuis le ticket 95, mini-me répond donc aux deux : session/list donne à l'éditeur les conversations stockées du projet, intitulées par leur première question, et session/resume en réenregistre une sans la rejouer, pour un éditeur qui l'affiche encore. session/load et son rejeu ne changent pas, pour un éditeur qui n'a rien gardé. La liste omet les conversations sans question : chaque fil ACP écrit son fichier dès session/new, et une liste de fils vides n'aide personne.
« Rejouer » veut dire renvoyer des enregistrements. Chaque appel d'outil revient comme un seul tool_call déjà terminé, portant l'entrée donnée par le modèle et la sortie renvoyée par l'outil à l'époque. Rien n'est réexécuté : recharger un fil qui a un jour lancé rm ne lance rien.
Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'agent.yaml doit atteindre aussi les anciennes conversations.
/new dans les deux façades
Dans le terminal, /new laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que /new a fermée.
Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans
Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau errors : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : generation (une réponse échouée), compaction (un résumé échoué, automatique ou /compact) et resume (un /resume qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré. Depuis le 2026-09-27, chaque enregistrement nomme aussi le model qui a échoué, et le fichier le provider et le model avec lesquels il a été écrit en dernier : la première chose dont une enquête a besoin, et la seule qu'elle ne pouvait pas retrouver — un résumé revenu vide ne dit rien tant qu'on ignore que le modèle réfléchit avant d'écrire.
Le tableau est à côté de messages, pas dedans, à dessein. Le modèle ne le lit jamais : une erreur concerne l'exécution, pas le projet, et la lui renvoyer lui ferait expliquer son infrastructure au lieu de faire sa tâche. /resume et session/load ne le rejouent jamais : tous deux ne lisent que messages, donc une vieille session se charge exactement comme avant et une nouvelle avec des erreurs se charge comme si le champ n'existait pas. Et la compression n'y touche pas : /compact réécrit messages, la trace reste entière. L'enregistrement suit sa conversation : en ACP, /new le déplace dans le fichier archivé avec l'historique auquel il appartient.
À quoi cela sert : demander après coup, le fichier en main, pourquoi une exécution a mal tourné — jq .errors <fichier> pour un humain, ou le fichier confié à un agent de code à qui l'on demande d'expliquer les échecs et de proposer des changements de code ou de YAML (un watchdogTimeout plus long, un autre modèle, un maxOutput plus petit). La liste /sessions et le compte rendu de /resume affichent · ⚠️ N error(s) pour qu'une session à examiner se remarque. La trace est plafonnée à 200 enregistrements, les plus anciens sortent d'abord, pour qu'une exécution qui échoue à chaque tour ne fasse pas grossir son fichier sans fin.
Pourquoi le ↑ de la fenêtre lit les questions enregistrées
Dans la fenêtre, ↑ depuis la première rangée de la zone rappelle les questions déjà posées (ticket 68), et seulement quand les sessions sont activées. Les questions sont lues dans les fichiers de session eux-mêmes — les messages user de la conversation en mémoire, puis des autres fichiers du projet, du plus récent au plus ancien — et non dans une liste à part. C'est le choix de l'utilisateur : rien de nouveau n'est écrit, le rappel couvre toutes les sessions que le projet a gardées, y compris celles écrites avant la fonctionnalité, et il disparaît avec elles quand on vide le répertoire.
La contrepartie : une question rappelée est ce que le modèle a lu, pas exactement ce qui a été tapé — les lignes [attached file: …] ajoutées par un @chemin, un collage en entier plutôt que son jeton, un /ask sous sa forme [asked …] ; les commandes /, jamais envoyées au modèle, n'y sont pas du tout. Ce que mm a écrit lui-même comme message utilisateur est écarté — un résumé de /compact, la note après un appel d'outil cassé, le marqueur d'un prompt bloqué par un hook — de même que le contexte ajouté par un hook preGeneration. Un trou que les fichiers ne peuvent pas combler : un tour qui échoue avant de répondre retire sa question de l'historique, et c'est justement celle qu'on veut relancer. La fenêtre la garde en mémoire jusqu'à la question suivante, pour que ↑ la retrouve ; elle n'est écrite nulle part.
Pourquoi la recherche dans la mémoire porte sur les mots
/memory et l'outil search_memory (ticket 48) parcourent les fichiers de session du projet à chaque appel : pas d'index, pas de base, pas de modèle d'embedding. Les sessions d'un projet pèsent quelques centaines de kilo-octets — les 43 fichiers de ce dépôt font 508 Ko — et les lire est instantané. Un modèle d'embedding trouverait « database » dans une session qui disait « storage », mais c'est un modèle de plus chargé à côté du modèle de chat, sur des machines où la mémoire est la contrainte ; ce genre de mémoire relève du travail sur la mémoire hiérarchique (ticket 74), pas d'une recherche.
Le prix a été mesuré, sur Qwen3.5 4B via llmman avec deux sessions enregistrées (2026-10-20). Les questions dont les mots figuraient dans une session — le port de l'API, « le port convenu » pour écrire un fichier — ont été trouvées 9 fois sur 9 (un essai de chauffe, trois de chaque, puis un de chaque à nouveau après le dernier changement), et l'outil n'a jamais été appelé sur une salutation, un calcul, une liste de fichiers ou une demande de code (0 sur 12). Une question en français sur un choix écrit en anglais n'a rien trouvé : le modèle a cherché « base de données », puis, invité par la ligne « no hit » de l'outil à essayer l'anglais, « database » — que la session n'a jamais écrit (« storage », « SQLite »). Il a trouvé la réponse en lisant lui-même les fichiers JSON (5 essais sur 6 ; le sixième a été perdu à cause d'un blocage du serveur).
La construction de demos/memory-search/ a imposé trois règles de plus, chacune née d'une mauvaise réponse. Une question qui correspond vient avec la réponse sur laquelle son tour s'est terminé : « format logs » ne trouvait que la question « Quel format pour les logs ? » (la réponse dit « log/slog »), et le modèle concluait que rien n'avait été décidé. Les mots d'une ou deux lettres sont ignorés à côté de mots plus longs : « de » dans « base de données » trouvait « Deployment », « modernc » et « Module », renvoyait tout l'historique, et la bonne réponse arrivait par chance. Et un extrait va du premier mot trouvé au dernier : coupé après le premier, un résumé qui nommait le déploiement dans sa première ligne perdait sa ligne sur les sauvegardes, et le modèle répondait à côté. La ligne d'en-tête du résumé et l'accusé de réception qui le suit, les mots de mm, ne sont plus cherchés.
Deux choix découlent de ce que l'outil est entre les mains du modèle. La conversation en cours est exclue par son contenu, pas par l'identifiant de session : aucune façade ne dit à un outil dans quel fichier elle enregistre, et le fichier d'une session reprise ou compressée contient justement les messages en mémoire ; « un message que le modèle a déjà » est donc à la fois ce qu'il faut écarter et quelque chose que chaque façade sait calculer. Et l'outil et la commande ont des clés séparées, toutes deux désactivées par défaut : un outil dans le catalogue d'un petit modèle change ce qu'il fait, même inutilisé, et le déclarer est la décision de l'utilisateur.
Alternatives écartées
- Reprendre automatiquement la dernière session au lancement. Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus.
- Un journal en ajout seul par session. Écarté : la compression raccourcit l'historique, et un journal continuerait de dire ce que la mémoire ne contient plus.
- Un index ou des embeddings pour la recherche dans la mémoire (ticket 48). Écartés en v1 : les fichiers sont assez petits pour être parcourus, et un modèle d'embedding est un modèle de plus en mémoire. Le coût — aucune correspondance entre langues ni entre synonymes — est mesuré plus haut.
- SQLite, comme docker agent. Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet.
session/resumeplutôt quesession/load. Écarté : dépendant d'un éditeur qui aurait gardé le fil, et hors de la spécification stable quand le choix a été fait. Depuis le ticket 95, il est proposé à côté desession/load, pas à sa place.- Une commande
/resumeen ACP. Écartée (ticket 95) : c'est l'éditeur qui dessine le fil, donc une ancienne conversation rejouée dans un fil ouvert arriverait après ce qu'il affiche déjà, et le fichier ne correspondrait plus à l'identifiant de session de l'éditeur. Lister les sessions à l'éditeur est la voie du protocole. - Ne rejouer que les messages texte. Écarté par l'utilisateur : les appels d'outils sont la partie d'une session de code qui vaut d'être revue.
- Une liste à part des saisies dans le fichier de session (ticket 68). Elle aurait rappelé exactement ce qui a été tapé — commandes
/et jetons de collage compris — mais c'était un nouveau champ à tenir en phase avec l'historique, vide pour tous les fichiers existants. L'utilisateur a préféré les messages enregistrés. - Enregistrer les erreurs comme des messages de l'historique. Écarté : le modèle les lirait,
session/loaddevrait les rejouer sous une forme ou une autre, et un vieux fichier se chargerait autrement qu'un nouveau. Un champ séparé ne coûte rien au chargement et ne change rien pour le modèle.
Liens avec le reste
internal/store est la couche fichiers, utilisée par internal/agent (un keeper qui enregistre après chaque tour et sert /sessions et /resume) et par internal/acp (un fichier par session, session/load, le rejeu dans replay.go). internal/session.WithSystem remplace le prompt enregistré par l'actuel. La compression (internal/compact) ignore la persistance : les façades enregistrent après son passage.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
|