mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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>
262 lines
9.6 KiB
Go
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())))
|
|
}
|