guest@6admin.io:~$

opencode-memory: mes sessions d'agents partagent un seul cerveau (SQLite, local)

Les sessions d’agents ont la mémoire d’un poisson rouge titularisé: chacune démarre brillante et ignorante, redécouvre la moitié de vos décisions d’architecture et meurt avec tout ce qu’elle a appris. La compaction aggrave le problème: le résumé de la session est justement ce qui se fait tronquer. opencode-memory (v0.3.0, TypeScript, npm @log0u7/opencode-memory) est mon remède: un seul store SQLite partagé, que chaque session de la machine lit et écrit, pour que les décisions et les faits survivent à la session qui les a produits. Un paquet, deux hôtes: le même module expose les deux points d’entrée depuis v0.2.0, server() pour OpenCode 1.18, setup() pour OpenCode 2.

Quatre outils, un store

Chaque session reçoit memory_save, memory_search, memory_list, memory_forget. Le store vit à ~/.local/share/opencode-memory/memory.db (honore XDG_DATA_HOME), en mode WAL pour que les sessions concurrentes ne fassent pas la queue derrière les unes les autres. Rien à faire à la main: le plugin injecte une guidance système qui pousse à sauver les faits durables quand ils émergent et la première requête de chaque session embarque un inventaire déterministe des entrées récentes du projet (titres, kinds, dates, sans les corps, ~1200 caractères au pire). Lire la mémoire ne dépend donc plus du modèle qui se souvient d’appeler memory_search; le tool reste pour les recherches plus profondes. Le store tient aussi son rayon propre: memory_save réécrit une entrée qui répète le même scope et titre au lieu d’empiler des doublons, les lignes expirées sont purgées à la sauvegarde, les limites sont bornées à 1..50 et les corps rendus en previews de 500 caractères. Vous pouvez aussi le piloter à la main:

memory_save(kind="decision", title="Use WAL mode", body="...", tags=["db"])
memory_search("postgres migration strategy")
memory_forget(id="...")

Le champ kind est libre, mais les conventions qui ont tenu sont parlantes: decision, fact, preference, gotcha, compaction, lesson. “Gotcha” est la plus précieuse: le truc pour lequel la session précédente a juré pendant une heure, préservé pour que la suivante saute le jurement.

Les scopes: le worktree est la mémoire

Les entrées ont par défaut le scope du worktree du projet de la session, avec un scope global pour les vérités machine (“ce GPU fait tourner Ollama sur le port 11434”). Pourquoi le worktree et pas le repo? Parce que le même repo checkouté dans deux worktrees peut légitimement diverger et parce que le travail multi-sessions (voir opencode-mesh ) se passe à travers les worktrees: un worker dans wt/fix-parser ne doit pas polluer la mémoire du checkout principal, mais un fait machine (“le runner CI est down”) doit atteindre tout le monde.

Là aussi, les deux points d’entrée s’étaient mal compris: en v1 le scope dérivait du worktree de session, en v2 du répertoire du plugin et une même session pouvait écrire à deux adresses selon l’hôte. Les deux dérivent maintenant une clé de scope canonique et memory_forget respecte la frontière: par query, l’effacement ne quitte jamais le scope de l’appelant (plus global), pour qu’un match flou ne supprime jamais les entrées d’un autre projet; par id, il vise partout, parce que les ids sont globalement uniques et qu’un worktree fermé doit rester nettoyable depuis n’importe où.

La recherche est SQLite FTS5 avec ranking BM25 et un départage newest-first en cas d’égalité de score. Ce choix compte plus qu’il n’y paraît: à BM25 équivalent, la décision de la semaine dernière doit passer avant l’expérience abandonnée de l’an dernier. Et un match strict qui ne renvoie rien est retenté en OR par préfixe, pour que “migrat” trouve quand même l’entrée “migration strategy”.

Les deux gardes qui valent d’être écrites

Les secrets sont refusés à la porte. Les entrées qui ressemblent à des identifiants (sk-..., ghp_..., xoxb-...) lèvent une SecretDetectedError au lieu d’être sauvées. Un store de mémoire partagée est exactement là où un prompt négligent du genre “sauvegarde la config API” finit, donc le plugin traite la mémoire comme une frontière de confiance et valide l’input comme telle. Et le scan couvre le titre, le kind, le corps et les tags: un identifiant niché dans un titre ou un kind contournait la garde, il ne le peut plus (le kind a aussi un plafond de longueur).

La compaction devient un point de sauvegarde. Le plugin accroche la compaction de session et demande au prompt de compaction d’émettre les décisions durables comme entrées de mémoire. C’est l’insight de design de tout le projet: la compaction est l’endroit où le contexte est perdu par construction, donc c’est aussi là que sauver compte le plus. Le résumé finit toujours tronqué; les décisions, maintenant, s’échappent avant. Depuis v0.3.0, le contexte de compaction liste aussi les entrées déjà stockées à côté de l’instruction de sauvegarde: le modèle résume depuis un inventaire au lieu d’un rappel et cesse de re-sauver ce qui est déjà là.

Les leçons: l’échec qui ne se répète pas

La boucle manquante, c’était le retour d’expérience. La guidance système et le prompt de compaction poussent maintenant l’agent à sauver des leçons réutilisables (kind=lesson) quand une approche non évidente échoue ou réussit et à consulter les leçons stockées avant de retenter une approche qui a déjà planté. Dans les sessions suivantes, les leçons passent en tête de l’inventaire (“lessons first, then newest”), pour que l’échec d’hier précède le bruit de récence: store.list a gagné un filtre par kind pour ça.

Le second étage, optionnel et éteint par défaut: une réflexion LLM à l’événement session.idle. Activée, elle envoie la fin bornée du transcript de la session à un appel LLM unique qui en distille au plus maxLessons leçons durables et les sauve par le même store, garde-fou secrets compris. Les sessions de subagents sont ignorées, les échecs sont silencieux (best-effort) et une session n’est marquée “réfléchie” qu’une fois une exécution réellement tentée: un transcript trop court reste retentable au tour suivant.

{
  "plugins": {
    "@log0u7/opencode-memory": { "reflection": { "enabled": true } }
  }
}

Sur un hôte v1, les mêmes options voyagent dans la forme tuple sous la clé plugin: ["@log0u7/opencode-memory", { "reflection": { "enabled": true } }], que v2 normalise au chargement. Les options: enabled (faux par défaut), model ("provider/model", par défaut le modèle de l’hôte), maxLessons (3, plafond 10) et transcriptChars (8000).

Une ligne d’honnêteté sur les versions: v0.3.0 porte tout ce qui précède cette section, sauf l’oubli par id global et le retry de réflexion après transcript court, fusionnés sur main, pas encore étiquetés, comme la boucle de leçons.

Pourquoi local, pourquoi SQLite, pourquoi pas une vector DB

Pas de cloud, pas de clés API, pas de service d’embeddings: la mémoire d’un agent qui lit mes repos privés ne doit pas quitter la machine où il les lit. Et pas de base vectorielle, exprès: FTS5 gère le rappel exact et flou par mots-clés (la mémoire technique est pleine d’identifiants: noms de tables, noms de flags, chaînes d’erreur), le store est un seul fichier que je peux sauvegarder avec cp et le plugin n’a aucune dépendance runtime: le store est le SQLite intégré de l’hôte (bun:sqlite sous Bun, node:sqlite sous Node 22.5+). Les embeddings auraient été le choix impressionnant; le rappel en forme de grep est celui qui correspond à ce que les sessions demandent vraiment.

Un seul cerveau

Au bout du compte, trois convictions portent ce store. La compaction de session est la vraie frontière de la mémoire, l’y accrocher transforme la perte de contexte en gain. Les scopes sont une fonctionnalité de sécurité, la mémoire par worktree limite le rayon d’impact, global est un privilège, pas un défaut. Et le rappel ennuyeux gagne, BM25 et un départage newest-first couvrent la mémoire des agents, la base vectorielle fantaisiste attendra qu’un échec mesurable l’exige.