Files
Loki/internal/loki/mem_index.go
T
MichaelandClaude Opus 5.5 4932999d5c Contexte : PROJ_SNAPSHOT fige le bloc projet par discussion, ses changements arrivent en mise à jour, en opt-in
Le contexte du projet (description, index mémoire, trackers, AGENTS.md) part
en tête du premier message utilisateur, reconstruit à chaque tour : une page
créée ou une valeur de tracker notée faisait recalculer toute la conversation
derrière lui, 10 à 45k tokens. Nouvelle clé PROJ_SNAPSHOT (off par défaut) :
avec on, chaque discussion garde une copie datée du bloc, renvoyée à l'octet
près, et les changements arrivent en tête du message suivant dans un bloc
<context_update from="loki">, rangé dans l'historique. Sans la clé, la requête
est identique à l'octet près (testé contre l'ancien assemblage).

- en-têtes figés « as of <date> » pour l'index mémoire et les trackers, sans
  « answer straight from this » ; ligne fixe du système, seulement avec la clé
- mises à jour par type : +/~/- par page (clé « ](fichier) »), une ligne
  complète par tracker (clé = slug), texte COMPLET pour la description,
  AGENTS.md, ou un index dont la prose a changé — jamais de diff de prose
- état annoncé structuré et persisté (ProjSnap, omitempty), instantané pris
  sur marqueur explicite : la première page d'un projet vide arrive en mise
  à jour, le premier message ne bouge pas
- rafraîchi (blocs retirés de tout l'historique) quand le prompt change de
  toute façon : compaction début/fin/manuelle, compaction ou réduction en
  cours de tour (bloc vivant aussitôt, rangé comme instantané : pas de
  recalcul de plus), système ou outils modifiés, redémarrage de Loki, réglage
  moteur ou modèle, projet, nom, mode mémoire, mode Code, dépôt, textes des
  en-têtes ; et au-delà d'un seuil de mises à jour accumulées
- mise à jour posée sous c.mu avec l'epoch, après la compaction de début de
  tour ; texte ou multimodal ; retirée du titre, de l'export JSON, de l'entrée
  du résumeur et de la tâche du vérificateur
- loadFrom, Reset et le chargement remettent l'instantané à zéro ; clé retirée
  = nettoyage ; agent off = rien du projet ; tâches et presets externes
  inchangés ; un sous-agent ne voit pas le bloc du tour
- ordre des trackers déjà déterministe (lot 1, test existant)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:44:46 +02:00

262 lines
9.6 KiB
Go

package loki
// mem_index.go — maintien AUTOMATIQUE de l'index MEMORY.md du projet, par le CODE
// (pas par le modèle). À chaque création / suppression / renommage d'une page, on
// ajoute ou retire sa ligne `- [titre](fichier.md)` dans MEMORY.md. L'index
// reflète donc TOUJOURS l'état réel des pages, quel que soit le modèle — un petit
// modèle qui « oublie » de tenir son index ne peut plus le désynchroniser. Le
// modèle garde une seule liberté : enrichir l'accroche après le titre, qu'on ne
// touche jamais.
//
// L'index est ensuite INJECTÉ comme un message système au début de la
// conversation (et après un compactage) plutôt que recopié dans le prompt à
// chaque tour : le modèle sait d'emblée quelles pages existent, sans payer la
// liste à chaque échange ni avoir à lancer une recherche pour la découvrir.
//
// Best-effort partout : si l'index est illisible, on n'échoue pas — il sera
// re-synchronisé au prochain passage (reconcileMemIndex).
import (
"os"
"path/filepath"
"strings"
)
const memIndexFile = "MEMORY.md"
// memIndexPrefix marque le message d'index injecté dans l'historique, pour le
// détecter et éviter les doublons.
const memIndexPrefix = "Project memory index"
// memIndexMessage construit le message système d'index à injecter UNE FOIS au
// début de la conversation (et après un compactage). ok=false hors mode mémoire
// proactif ou si l'index est vide. On n'injecte que l'INDEX (titres + accroches),
// jamais le contenu des pages — l'IA fait mem_read pour lire une page.
func memIndexMessage() (Message, bool) {
idx, ok := memIndexText()
if !ok {
return Message{}, false
}
return renderMemIndex(idx, ""), true
}
// memIndexText lit l'index à injecter : ok=false hors mode mémoire proactif ou
// sans aucune page. Séparé du rendu pour que le bloc figé (chat_projsnap.go)
// déduise son état de la MÊME lecture que le texte qu'il envoie.
func memIndexText() (string, bool) {
if memMode() != MemAlways {
return "", false
}
idx := strings.TrimSpace(MemContent(memIndexFile))
// Un index qui n'a que son en-tête ne dit rien au modèle et coûte du contexte
// à chaque tour : on attend au moins une VRAIE ligne de page. Le test est fait
// sur le début de ligne, parce que l'en-tête cite le format `- [titre](…)` en
// exemple — une simple recherche du motif le prendrait pour une page.
if idx == "" || !hasIndexedPage(idx) {
return "", false
}
return idx, true
}
// renderMemIndex : le message d'index. asOf vide = la forme vivante, à jour à
// chaque tour (texte d'avant PROJ_SNAPSHOT, à l'octet près) ; sinon la copie
// figée, datée, que les <context_update> de Loki complètent ensuite.
func renderMemIndex(idx, asOf string) Message {
head := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) + "\""
tail := " — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained."
if asOf != "" {
head += " as of " + asOf
tail += memIndexFrozenTail
}
return Message{Role: "system", Content: head + tail + "\n\n" + idx}
}
// projectContextPrefix marque le message de contexte projet injecté (description),
// pour le détecter et éviter les doublons.
const projectContextPrefix = "Project context"
// projectContextMessage construit le message système décrivant le projet actif à
// partir de sa description, à injecter UNE FOIS au début de la conversation (et
// après un compactage). ok=false si aucune description : l'IA sait alors juste
// dans quel projet elle est via l'index mémoire, sans laïus. Indépendant du mode
// mémoire — une description reste utile même mémoire coupée.
func projectContextMessage() (Message, bool) {
desc := strings.TrimSpace(projectDesc(activeProjectSlug()))
if desc == "" {
return Message{}, false
}
return renderProjectContext(desc), true
}
// renderProjectContext : le message de description, pour une description non vide.
func renderProjectContext(desc string) Message {
content := projectContextPrefix + " — you are working within the project \"" +
projectName(activeProjectSlug()) + "\". What this project is about (set by the user):\n\n" + desc
return Message{Role: "system", Content: content}
}
// projectSystemMessages renvoie, dans l'ordre, tout ce qui situe l'IA dans son
// projet : description du projet, index mémoire, index des trackers.
//
// Ces messages sont injectés dans la vue ENVOYÉE au modèle, jamais persistés dans
// c.Messages — même traitement que le prompt système personnalisé (voir
// Conversation.generate). Deux conséquences voulues :
// - l'index est TOUJOURS à jour : une page créée au milieu d'une conversation y
// apparaît au tour suivant, sans attendre un compactage ;
// - le compactage ne peut pas les avaler, puisqu'ils ne font pas partie de
// l'historique qu'il résume — donc rien à réinjecter après coup.
//
// Le prix est une invalidation du cache de prompt quand l'index change, c'est-à-
// dire quand une page est créée ou supprimée : exactement les moments où le
// modèle DOIT voir la nouvelle liste. PROJ_SNAPSHOT=on (chat_projsnap.go) évite
// ce prix pour la discussion : bloc figé, changements livrés en <context_update>.
func projectSystemMessages() []Message {
var out []Message
if m, ok := projectContextMessage(); ok {
out = append(out, m)
}
if m, ok := memIndexMessage(); ok {
out = append(out, m)
}
if m, ok := trackerIndexMessage(); ok {
out = append(out, m)
}
return out
}
// hasIndexedPage dit si l'index contient au moins une ligne de page réelle
// (« - [titre](fichier.md) » en début de ligne).
func hasIndexedPage(idx string) bool {
for _, line := range strings.Split(idx, "\n") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, "- [") && strings.Contains(l, "](") {
return true
}
}
return false
}
// isIndexFile indique si `name` est le fichier d'index (à ne jamais indexer
// lui-même : il se listerait en boucle).
func isIndexFile(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), memIndexFile)
}
// indexLineFor construit la ligne d'index d'une page : `- [titre](fichier.md)`.
// Le titre vient de la 1re ligne de la page, à défaut le nom du fichier.
func indexLineFor(name string) string {
title := name
if t := titleOf(MemContent(name)); t != "" {
title = t
}
return "- [" + title + "](" + name + ")"
}
// indexRefFor est le motif qui identifie la ligne d'une page dans l'index :
// `](fichier.md)`. Le nom complet ET la parenthèse fermante évitent qu'un nom en
// matche un autre dont il serait le suffixe (`notes.md` vs `mes-notes.md`).
func indexRefFor(name string) string { return "](" + name + ")" }
// writeIndex écrit le contenu de l'index. Passe par le disque directement plutôt
// que par MemSave : MemSave normalise et pourrait un jour se mettre à indexer,
// ce qui bouclerait.
func writeIndex(body string) {
dir := memoryDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(filepath.Join(dir, memIndexFile), []byte(body), 0o644)
}
// memIndexAdd ajoute la ligne d'une page à MEMORY.md si elle n'y est pas déjà. Si
// une ligne pour ce fichier existe (l'IA a pu y écrire une accroche), on la LAISSE
// telle quelle : l'index appartient au code pour sa structure, au modèle pour sa
// prose.
func memIndexAdd(name string) {
fn, err := memFileName(name)
if err != nil || isIndexFile(fn) {
return
}
ensureIndexSeed()
content := MemContent(memIndexFile)
ref := indexRefFor(fn)
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, ref) {
return // déjà indexée
}
}
writeIndex(strings.TrimRight(content, "\n") + "\n" + indexLineFor(fn) + "\n")
}
// memIndexRemove retire la (les) ligne(s) d'une page de MEMORY.md.
func memIndexRemove(name string) {
fn, err := memFileName(name)
if err != nil || isIndexFile(fn) {
return
}
content := MemContent(memIndexFile)
if content == "" {
return
}
ref := indexRefFor(fn)
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
changed := false
for _, line := range lines {
if strings.Contains(line, ref) {
changed = true
continue
}
out = append(out, line)
}
if !changed {
return
}
writeIndex(strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n")
}
// memIndexRename retire l'ancienne ligne et ajoute la nouvelle.
func memIndexRename(oldName, newName string) {
if oldName != "" {
memIndexRemove(oldName)
}
memIndexAdd(newName)
}
// reconcileMemIndex complète l'index du projet ACTIF : pour chaque page existante
// non référencée dans MEMORY.md, il ajoute sa ligne. ADDITIF — il ne touche ni aux
// lignes ni aux accroches déjà présentes. Indispensable après la migration vers
// les projets : les pages déplacées à la main sur le disque n'ont jamais été
// indexées, puisque l'index n'est alimenté que par la création d'une page.
func reconcileMemIndex() {
pages := MemList()
if len(pages) == 0 {
return
}
ensureIndexSeed()
content := MemContent(memIndexFile)
var add []string
for _, p := range pages {
if isIndexFile(p.Name) {
continue
}
if strings.Contains(content, indexRefFor(p.Name)) {
continue // déjà indexée
}
add = append(add, indexLineFor(p.Name))
}
if len(add) == 0 {
return
}
writeIndex(strings.TrimRight(content, "\n") + "\n" + strings.Join(add, "\n") + "\n")
}
// ensureIndexSeed crée MEMORY.md (avec son en-tête) s'il n'existe pas encore, pour
// que l'ajout d'une première ligne ait un fichier où s'écrire.
func ensureIndexSeed() {
if strings.TrimSpace(MemContent(memIndexFile)) != "" {
return
}
writeIndex(memorySeed(projectName(activeProjectSlug())))
}