Mémoire et synchronisation
La mémoire est un plugin d'architecture intégré à oHarness (memory), pas un serveur MCP. Il enregistre les outils de mémoire, l'index du prompt système, le rappel par message, l'extraction optionnelle, la page Memory et un synchroniseur en arrière-plan, le tout via le même PluginHost que les plugins de modèle, d'outils et d'audit. L'extension de page reste minimale : des descripteurs enregistrés et des moteurs de rendu détenus par l'hôte (actuellement memory), et non du JavaScript frontend arbitraire ou un SDK d'iframe.
Où vivent les notes
Une note par fichier Markdown, avec un frontmatter JSON :
---
{"id":"pricing-borderless-3fa","summary":"Pricing cards use fills, not borders","type":"feedback","tags":["design"],"createdAt":1789000000000,"updatedAt":1789000000000}
---
Cards on the pricing page are separated by fill and whitespace.
**Why:** the outlines were the loudest thing on a page of numbers.
**How to apply:** keep the border declared but transparent. See [[pricing-layout-a91]].
| Portée | Répertoire | Contenu |
|---|---|---|
project | <project>/MEMORY/ | Décisions, contraintes et références qui se déplacent et s'exportent avec le projet |
user | ~/.oharness/memory/user/ | Qui est l'utilisateur et comment il aime travailler, dans tous les projets |
Chaque projet enregistré contient .oharness/project.json. Son identifiant stable maintient le rattachement des sessions côté Home, de l'état de synchronisation et des statistiques de rappel quand le dossier est renommé ou exporté. Un dépôt Git utilise d'abord son commit racine comme identifiant ; les autres répertoires reçoivent un identifiant de projet aléatoire. OHARNESS_HOME déplace les notes utilisateur et l'état propre à la machine, mais ne déplace jamais le répertoire MEMORY/ du projet.
memory.dir (ou l'ancien memoryDir) dans la configuration remplace le répertoire du projet, relativement à la racine du projet. Si le répertoire utilisateur ne peut pas être créé, les notes du projet restent disponibles dans <project>/MEMORY ; seuls les notes de niveau utilisateur et l'état Memory côté Home sont indisponibles, et un avertissement le signale.
Types
| Type | Portée par défaut | Ce qu'il enregistre |
|---|---|---|
user | utilisateur | Le rôle, l'expertise et les préférences de travail de l'utilisateur |
feedback | projet | Une correction, ou une approche que l'utilisateur a confirmée |
project | projet | Une décision et sa raison, une contrainte, un travail en cours qu'on ne peut pas déduire du code |
reference | projet | Où se trouvent des informations externes |
Seules les notes user sont par défaut dans la portée utilisateur. Le feedback va par défaut dans le projet, car la plupart des corrections portent sur la façon de travailler dans une base de code donnée ; une correction qui vaut manifestement partout est placée volontairement dans la portée utilisateur.
Les notes écrites avant l'apparition des types n'en ont pas et continuent de fonctionner. type n'entre dans le hash du contenu d'une note que s'il est présent : les notes existantes gardent ainsi les hashs déjà enregistrés dans leurs références de synchronisation.
Écrire des notes
Le modèle écrit avec remember, lit avec memory et supprime avec forget. Le remember du plugin de mémoire passe sans demander dans tous les modes de permission sauf plan, qui le refuse comme toute autre écriture. Une règle ask ou deny visant remember s'applique toujours, forget demande toujours, et l'outil remember d'un autre plugin ne bénéficie d'aucune exemption de ce type. Les tags sont nettoyés, découpés sur les virgules et les espaces, et dédoublonnés avant l'enregistrement de la note. La section Memory du prompt système indique quand une note vaut la peine d'être écrite et quand elle ne la vaut pas. remember renvoie les notes existantes les plus proches de ce qui vient d'être écrit, signale un doublon probable et liste les liens qui pointent vers des notes qui n'existent pas encore. Tout ce qui ressemble à un identifiant secret est refusé. Les notes doivent porter des tags pour les autres mots qu'on pourrait employer : synonymes, et le terme dans l'autre langue utilisée, car le rappel compare des mots, pas du sens.
Les notes se lient entre elles dans le corps sous la forme [[note-id]]. Les liens sont déduits des corps à chaque chargement des notes ; rien n'est stocké séparément, et le protocole de synchronisation ignore leur existence. Un lien est d'abord résolu dans la portée du projet, puis dans la portée utilisateur. Un lien vers une note manquante est conservé : il désigne généralement une note que quelqu'un comptait écrire.
Extraction automatique
Désactivée par défaut, car chaque passe est un appel de modèle payé avec le crédit de l'utilisateur. Avec memory.autoExtract.onStop, la fin d'une exécution transmet au petit modèle la conversation depuis la dernière passe, dès qu'au moins minUserTurns vrais messages utilisateur se sont accumulés depuis. Avec onCompaction (ou onStop), la portion que la compaction s'apprête à retirer et qu'aucune passe n'a encore lue est transmise en premier, si courte soit-elle, puisque c'est la dernière occasion de la lire. Une session reprise commence à compter à partir du point de reprise au lieu de réextraire tout son historique. Aucune extraction n'est faite à partir des sous-agents.
L'extracteur voit les notes existantes les plus proches de la conversation. Les quelques plus pertinentes sont fournies avec leur corps complet, dans une limite de taille, et seules celles-ci peuvent être mises à jour ; les autres sont listées par résumé pour pouvoir y faire des liens. Une mise à jour remplace le corps : la version remplacée est donc d'abord sauvegardée dans ~/.oharness/memory-backups/. Sa réponse est traitée comme non fiable : les mises à jour d'identifiants qu'on ne lui a pas montrés sont ignorées, les liens vers des identifiants qu'on ne lui a pas montrés perdent leurs crochets, les identifiants secrets sont masqués, et les nouvelles notes d'un même lot se désignent entre elles par [[new:N]] jusqu'à ce qu'elles aient un identifiant. La configuration d'un dépôt peut désactiver l'extraction ; seule la configuration propre à l'utilisateur peut l'activer.
L'extraction se termine après la fin de l'exécution qui l'a déclenchée : les notes enregistrées sont donc annoncées au début du message suivant, « saved N memories from this conversation », dans toutes les interfaces.
Rappel
Deux chemins placent des notes devant le modèle :
- L'index. Au début de chaque exécution, une ligne par note, de la plus récemment modifiée à la plus ancienne, jusqu'à
memory.indexCharscaractères. Les autres sont comptées, pas perdues ; l'outilmemoryles parcourt toutes. - Le rappel par message. Chaque message utilisateur est comparé à toutes les notes, et les meilleures correspondances sont jointes à ce message dans
<recalled-memory>, avec les notes auxquelles elles renvoient listées par résumé. Les interfaces affichent « recalled N memories ». Rien n'est joint quand aucune note ne dépasse le seuil, ce qui est le cas habituel. Sans aucune note, l'index est remplacé par deux lignes indiquant quand en écrire une, au lieu des consignes complètes.
Le score est lexical et calculé localement : les mots sont racinisés, le texte CJK est découpé en paires qui se chevauchent, et les correspondances dans le résumé et les tags pèsent plus que celles du corps. Une note doit correspondre dans son résumé ou ses tags pour être jointe. Une note déjà jointe ne l'est pas à nouveau tant qu'un message qui la porte reste dans la transcription, y compris après la reprise de la session ou le redémarrage de l'application ; les identifiants sont relus dans les métadonnées des messages. La compaction libère les notes dont elle a résumé les messages. Les notes jointes occupent leur propre bloc du message utilisateur, consigné dans les métadonnées du message : les transcriptions, les titres de session et l'historique web affichent donc les mots de l'utilisateur lui-même. Les sous-agents n'effectuent pas de rappel.
Un échec du rappel n'empêche jamais l'envoi d'un message.
Limites
| Paramètre | Défaut | Signification |
|---|---|---|
memory.maxNotes | 5000 | Par portée. Au-delà, les fichiers modifiés le moins récemment ne sont pas chargés et un avertissement indique combien ; les nouvelles notes sont refusées jusqu'à ce que certaines soient supprimées |
memory.maxNoteBytes | 131072 | Taille du corps ; le serveur de synchronisation applique aussi sa propre limite |
memory.indexChars | 4000 | Caractères de résumés dans le prompt système |
memory.recall.enabled | true | Rappel par message |
memory.recall.perTurn | 3 | Notes jointes à un message |
memory.recall.budgetChars | 2000 | Caractères que ces notes peuvent occuper |
memory.recall.minScore | 1.2 | Seuil pour joindre une note |
memory.autoExtract.onCompaction / onStop | false | Extraction automatique |
memory.autoExtract.minUserTurns | 3 | Les conversations plus courtes sont ignorées |
Le nombre de rappels de chaque note est conservé par machine dans ~/.oharness/memory-usage/, en dehors des notes : le rappel ne provoque donc jamais d'envoi de synchronisation. Il ajuste le classement et n'est jamais synchronisé.
Synchronisation cloud
- Les notes fonctionnent toujours en local, déconnecté et hors ligne.
- La connexion active la synchronisation automatique par défaut. La synchronisation est bidirectionnelle : les modifications locales sont envoyées, et celles faites sur d'autres appareils arrivent d'elles-mêmes.
- La page Memory propose 关闭云同步 et 立即同步. Le commutateur couvre les deux portées. La désactivation persiste après redémarrage et nouvelle connexion, et bloque les requêtes cloud automatiques et manuelles jusqu'à réactivation explicite. Les outils locaux et l'édition continuent de fonctionner.
- Désactiver la synchronisation annule les requêtes en cours. Les données déjà acceptées par Cloud ne peuvent pas être retirées ; désactiver la synchronisation ne supprime pas les copies cloud.
- Chaque portée logique projet/utilisateur est liée à son premier compte de synchronisation et à son origine Cloud. Changer de compte ou d'origine met la synchronisation en pause. La page ne propose aucune réaffectation risquée en un clic.
La page gère ~/.oharness/plugins.json (OHARNESS_HOME le remplace), sous runtimePlugins[id=memory].config.syncTargets[sha256(stableScopeKey)] :
{ "userId": "verified-account-label", "origin": "https://your-cloud.example", "enabled": false }
Ne mettez ni clés R2 ni configuration Supabase dans ce fichier. L'URL de Cloud reste dans cloud.json ; les identifiants R2 n'existent que dans l'environnement du déploiement de synchronisation. Pour désactiver le plugin entier (page, outils, prompts, rappel et travail en arrière-plan), ajoutez { "id": "memory", "enabled": false } à runtimePlugins ; la modification prend effet à la prochaine création du Harness. L'enregistrement d'un projet crée toujours les répertoires vides standard MEMORY/ et ARTIFACTS/. Pour les intégrateurs, harness.memory est le magasin du projet et harness.memoryUserService celui de l'utilisateur ; tous deux sont absents quand le plugin est désactivé. AgentOptions.memory reste une API d'intégration de plus bas niveau et bénéficie du rappel sans hôte de plugins.
Quand la synchronisation a lieu
Un coordinateur par processus planifie tous les répertoires de notes, quel que soit le nombre de projets ouverts par un serveur web local ou l'application de bureau. Chaque répertoire a un seul service de synchronisation dans le processus : les notes propres à l'utilisateur sont donc synchronisées une fois, et non une fois par projet ouvert.
- Une écriture locale est envoyée environ trois secondes plus tard ; une rafale d'écritures donne une seule passe.
- Sinon, un répertoire est vérifié toutes les minutes, puis toutes les cinq minutes tant que rien ne change d'un côté ni de l'autre.
- Vos propres notes sont toujours vérifiées. Les notes d'un projet ne sont vérifiées que pendant son utilisation (un message envoyé dedans, ou la page Memory ouverte) et pendant les trente minutes qui suivent.
- Le processus se limite à une vingtaine de requêtes par minute, ce qui laisse de la marge aux autres appareils sous la limite par compte du service de synchronisation.
- Un
429met en pause tous les répertoires jusqu'à l'heure indiquée dans sonRetry-After, et la page indique que la synchronisation était trop fréquente et sera retentée, au lieu d'incriminer la configuration.
立即同步 s'exécute toujours immédiatement.
Ce qu'affiche la page
Le commutateur, « last synced » et les erreurs couvrent les deux portées : la synchronisation n'est considérée comme active que si elle l'est pour les deux, l'heure affichée est la plus ancienne des deux, et chaque erreur précise si elle concerne les notes du projet ou les vôtres. La page affiche aussi ce que la dernière récupération a rapporté (ajouts, modifications, suppressions) et, sur une offre gratuite, les limites de l'offre.
Ranger les notes
La liste 待整理 de la page est une revue effectuée sur cette machine, sans modèle :
| Constat | Signification | Ce que vous pouvez faire |
|---|---|---|
| Même sujet | Deux à six notes d'une même portée dont les résumés et les tags se recoupent en grande partie | Fusionner, ou ignorer |
| Lien cassé | Un [[id]] qui ne désigne aucune note | Ouvrir la note, ou ignorer |
| Fichier manquant | Une note de projet cite un chemin relatif absent du projet | Ouvrir, ou ignorer |
| Inutilisée | Ni rappelée ni modifiée depuis memory.consolidate.unusedDays (90) | Ouvrir, ou ignorer |
| Trop longue | Un corps de plus de 8 000 caractères | Ouvrir, ou ignorer |
Un constat ignoré est conservé localement pour les versions actuelles des notes : modifier l'une d'elles fait réapparaître le constat.
La fusion ouvre un éditeur avec un brouillon : celui du petit modèle (生成合并建议) ou tous les originaux mis bout à bout (手动合并). L'appliquer conserve l'identifiant de la note la plus ancienne, supprime les autres, redirige chaque lien [[deleted]] de cette portée vers la note conservée et sauvegarde chaque note modifiée. Une fusion préparée à partir de notes qui ont changé depuis est refusée.
Avec la synchronisation cloud active, la portée est d'abord synchronisée et la fusion est validée dans le cloud avant cette machine (commitFirst) : une modification faite entre-temps depuis un autre appareil fait refuser la fusion au lieu de créer un conflit. Le corps fusionné doit respecter la limite par note du service de synchronisation. Sans synchronisation, la fusion est une simple modification locale.
La fusion en arrière-plan est activée par défaut pour les offres payantes uniquement ("auto": "subscribers") : elle s'exécute quand la dernière réponse du service de synchronisation indique que le compte, ou l'une de ses équipes, est sur une offre payante, et reste désactivée pour les comptes gratuits et chaque fois que l'offre est inconnue (synchronisation cloud désactivée, déconnexion). true ou false remplace ce comportement :
{ "memory": { "consolidate": { "auto": false, "cooldownHours": 24, "maxMerges": 3 } } }
Quand elle est activée, une passe démarre deux minutes après le lancement, au plus une fois par période de refroidissement, et fusionne jusqu'à maxMerges groupes de notes de même sujet avec le petit modèle, par le même chemin que la page. Elle n'agit jamais sur les autres constats. Une configuration de projet peut la désactiver mais pas l'activer.
Sauvegardes et restauration
Une copie est écrite dans ~/.oharness/memory-backups/ avant qu'une note soit remplacée ou supprimée sans que quelqu'un ait choisi cette version : suppression depuis la page, forget, choix de la version cloud lors d'un conflit, récupération automatique qui modifie ou supprime une note, réécriture par l'extracteur, et restauration qui remplace une version plus récente. La page Memory liste les cinquante plus récentes, avec leur date et leur motif, et restaure n'importe laquelle dans le répertoire d'origine ; la note se synchronise ensuite comme toute autre modification locale. Les sauvegardes écrites avant l'enregistrement des motifs sont aussi listées et restaurables.
Stockage et réconciliation
Le serveur de synchronisation expose /v1/memory/identity, /snapshot et /commit, avec authentification. Il ne stocke aucune ligne de base de données. R2 contient des révisions Markdown immuables et un manifeste JSON remplacé de façon atomique :
users/<server-verified-user-id>/projects/<project-id>/memory/
users/<server-verified-user-id>/memory/ user scope
manifest.json
objects/<note-id>/<revision-id>.md
Des implémentations reposant sur un autre système d'identité ou de stockage sont possibles ; la forme exacte des requêtes, l'algorithme de hash, le comportement CAS et les codes d'erreur sont spécifiés dans Protocole de synchronisation.
La référence locale (~/.oharness/memory-sync/) permet une réconciliation à trois voies : les notes inchangées sont ignorées et les modifications d'un seul côté sont copiées. À côté, le client conserve une copie de chaque note telle qu'elle était à la dernière synchronisation : une note modifiée des deux côtés est donc fusionnée, et pas seulement signalée :
- le résumé et le type viennent du côté qui les a modifiés ;
- les tags sont combinés comme des ensembles, en gardant ce que chaque côté a ajouté et en retirant ce que chaque côté a supprimé ;
- les corps sont fusionnés ligne par ligne, et les modifications éloignées sont combinées ;
- une modification face à une suppression conserve la modification, car supprimer à nouveau prend un clic alors qu'une modification perdue l'est pour de bon.
Les deux versions sont sauvegardées avant une fusion. Ce qui ne peut pas être combiné (deux résumés ou types différents, des modifications sur les mêmes lignes ou des lignes voisines, une fusion trop volumineuse pour l'offre, ou une note sans copie de sa dernière version synchronisée) devient un conflit. Un conflit ne bloque que sa propre note : tout le reste continue d'être envoyé et récupéré. La page Memory affiche la dernière version synchronisée, celle de cette machine et celle du cloud, et propose de garder celle de cette machine, de prendre celle du cloud, de garder les deux (celle du cloud devient une nouvelle note à côté) ou de fusionner à la main ; ce qui est écarté est sauvegardé, quel que soit le côté d'où il vient. Il n'y a pas d'écrasement « dernière écriture gagnante ». Les suppressions sont des tombstones, et les écritures du manifeste utilisent un compare-and-swap sur l'ETag.
Les modifications par lots que personne ne relit note par note (consolidation) sont écrites d'abord dans le cloud : chaque note concernée doit être entièrement synchronisée, le commit porte la version du manifeste qui a été vérifiée, et le lot n'est écrit en local qu'une fois accepté par le service. Une modification faite entre-temps depuis un autre appareil est refusée par le service au lieu de devenir un conflit.
Les requêtes listent les hashs des notes que détient cette machine (have) ; le serveur ne lit ni ne renvoie ces corps : une synchronisation où peu de choses ont changé transfère donc peu de données. Les commits sont découpés selon le quota de modifications annoncé par le serveur. Les tombstones plus anciens que la fenêtre du serveur sont retirés du manifeste ; une machine qui revient après ce délai et trouve qu'une note vue synchronisée et inchangée n'est plus listée la supprime, tandis qu'une note qu'elle a modifiée entre-temps devient un conflit. Deux identifiants de notes qui ne diffèrent que par la casse sont refusés, car les systèmes de fichiers de macOS et de Windows ne peuvent pas contenir les deux.
La synchronisation relance les échecs transitoires après un délai de 60 secondes ; la synchronisation manuelle peut relancer immédiatement. Les conflits attendent l'utilisateur. La page sauvegarde les notes supprimées, ainsi que les versions locales perdantes des conflits, sous ~/.oharness/memory-backups/ ; forget fait de même. Les modifications directes dans le système de fichiers restent prises en charge, mais une écriture externe concurrente pendant une fusion n'est pas protégée par le verrou d'écriture local au processus ; gardez un seul processus en cours par répertoire.
R2 est privé et utilise TLS, mais il ne s'agit pas d'un chiffrement de bout en bout : Cloud peut lire le contenu des notes. Seules les notes sont synchronisées, pas les journaux de session, les fichiers ni les identifiants.
Quotas et offres
Le serveur détient ses quotas et les annonce dans chaque réponse ; les clients n'en gardent aucune copie. Valeurs par défaut : 5000 notes actives par projet et par utilisateur, 500 modifications par commit, 128 Kio par corps, 16 Mio de corps par réponse, 90 jours par tombstone. Un quota abaissé ne bloque que la croissance : un utilisateur qui le dépasse peut toujours supprimer. Voir Faire tourner votre propre serveur de synchronisation pour les variables.
Les comptes gratuits sont soumis en plus à des limites plus strictes : un nombre de notes par projet et par utilisateur, et une taille par note, définis dans src/memory-entitlements.json du service de synchronisation. Les comptes payants n'ont aucune limite d'offre, seulement le quota ci-dessus. L'offre est lue dans la claim ent que Cloud écrit dans le token d'accès : une offre payante de l'utilisateur, ou de l'une de ses équipes, compte comme payante. Seule la synchronisation cloud est limitée, pas les notes sur la machine ; un compte gratuit au-delà de la limite conserve toutes ses notes locales et cesse d'en envoyer de nouvelles, avec un message qui explique pourquoi.
Supprimer un compte
Supprimer un compte sur le site web efface tout ce qu'il a synchronisé : les flux de sessions et la mémoire dans les deux portées. La passerelle appelle POST /v1/admin/erase du service de synchronisation avec une clé qu'elle seule détient (OHARNESS_SYNC_ADMIN_KEY des deux côtés), avant la suppression du compte lui-même, puis une nouvelle fois après. Le service de synchronisation écrit d'abord un marqueur pour le compte et refuse dès lors ses requêtes de flux et de mémoire : un appareil encore connecté ne peut donc pas renvoyer les données. Les notes et les sessions sur vos propres machines ne sont pas touchées.
Supprimer une équipe fait de même pour l'historique de sessions partagé de l'équipe : la passerelle efface feed/team-<id>/ (l'effacement {team} du service de synchronisation) après avoir résilié l'abonnement de l'équipe et avant de supprimer ses lignes, puis une nouvelle fois. Le service de synchronisation refuse dès lors les flux de cette équipe, même pour un token qui la mentionne encore. Les équipes n'ont pas de mémoire.
Mise à niveau
Mettez à niveau le serveur de synchronisation avant les clients. Face à un ancien serveur, un nouveau client revient à l'ancien protocole pour les notes de projet sans type. Il ne peut pas synchroniser les notes de niveau utilisateur, ni les notes typées, avec un ancien serveur. Un ancien client qui reçoit une note typée du cloud cesse de synchroniser avec une erreur de checksum jusqu'à sa mise à niveau.
Vérification et déploiement
Lancez npm run test:memory, npm run test:sync-memory, npm run test:plugins, npm run typecheck et npm run build. script/memory-ui-demo.ts démarre une interface isolée, déconnectée, sur le port 4319 avec un répertoire personnel temporaire ; elle ne lit jamais les identifiants de l'utilisateur. Une configuration R2 manquante produit une erreur de synchronisation visible sans casser la mémoire locale. Redémarrez le serveur de développement local après toute modification du code source.