mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Repris d'AJEAN 0.14.0 (chat_mem_pinned.go et ses tests, tels quels). Le compactage résume le torse, résultats de mem_read compris. Une page qui portait TOUTES les règles d'une tâche se retrouvait réduite à trois mots dans le résumé, et le modèle, après compactage, les oubliait. Le rappel ne recopie rien : il LISTE les pages lues (bornées aux 24 plus récentes) et invite à les relire si elles concernent la tâche. Il s'accumule d'un compactage à l'autre — l'ancien rappel est relu comme source, puisque le mem_read d'origine a disparu. Posé seulement en mode agent (sans lui, pas de mem_read pour y donner suite). Comme le contexte projet, le rappel est sorti du bloc système commun vers le premier message utilisateur (isProjectSystem) : il est propre à la conversation et casserait sinon le cache du système partagé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
6.0 KiB
Go
164 lines
6.0 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage.
|
|
//
|
|
// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats
|
|
// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES
|
|
// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le
|
|
// résumé — et le modèle, après compactage, « oubliait » les règles et partait en
|
|
// vrille.
|
|
//
|
|
// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On
|
|
// injecte simplement, en tête de la conversation compactée, un petit rappel qui
|
|
// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si
|
|
// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le
|
|
// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un
|
|
// mem_read de distance. Le comportement de compactage est par ailleurs inchangé.
|
|
//
|
|
// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du
|
|
// compactage précédent est relu comme source, donc une page lue tôt (dont le
|
|
// mem_read d'origine a depuis été résumé) reste listée.
|
|
|
|
// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le
|
|
// relire comme source au compactage suivant, et pour le retirer avant reconstruction).
|
|
const memReminderPrefix = "[MEMORY PAGES REMINDER]"
|
|
|
|
// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui
|
|
// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES
|
|
// (les plus susceptibles de concerner la tâche en cours).
|
|
const memReminderMaxNames = 24
|
|
|
|
// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la
|
|
// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux
|
|
// balayées pour survivre aux compactages successifs :
|
|
// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via
|
|
// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ;
|
|
// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine
|
|
// résumé, c'est la seule trace restante des noms).
|
|
func collectReadPageNames(msgs []Message) []string {
|
|
seen := map[string]bool{}
|
|
var order []string
|
|
add := func(name string) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || seen[name] {
|
|
return
|
|
}
|
|
seen[name] = true
|
|
order = append(order, name)
|
|
}
|
|
// tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne
|
|
// porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant).
|
|
readCall := map[string]string{}
|
|
for _, m := range msgs {
|
|
for _, tc := range m.ToolCalls {
|
|
if tc.Function.Name != "mem_read" || tc.ID == "" {
|
|
continue
|
|
}
|
|
var a map[string]any
|
|
if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil {
|
|
if f, _ := a["file"].(string); f != "" {
|
|
readCall[tc.ID] = f
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, m := range msgs {
|
|
switch m.Role {
|
|
case "system":
|
|
for _, n := range parseReminderNames(msgText(m)) {
|
|
add(n)
|
|
}
|
|
case "tool":
|
|
name, ok := readCall[m.ToolCallID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") {
|
|
continue // lecture ratée : rien à rappeler
|
|
}
|
|
add(name)
|
|
}
|
|
}
|
|
return order
|
|
}
|
|
|
|
// parseReminderNames relit la liste de noms d'un message de rappel produit par
|
|
// buildReminderMessage. Renvoie nil si ce n'est pas un rappel.
|
|
func parseReminderNames(text string) []string {
|
|
if !strings.HasPrefix(text, memReminderPrefix) {
|
|
return nil
|
|
}
|
|
// Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md`
|
|
// (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de
|
|
// relecture est sur la ou les lignes SUIVANTES, donc hors de la liste.
|
|
line := text
|
|
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
i := strings.LastIndex(line, ": ")
|
|
if i < 0 {
|
|
return nil
|
|
}
|
|
list := line[i+2:]
|
|
var out []string
|
|
for _, part := range strings.Split(list, ",") {
|
|
if n := strings.TrimSpace(part); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si
|
|
// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes).
|
|
func buildReminderMessage(names []string) (Message, bool) {
|
|
if len(names) == 0 {
|
|
return Message{}, false
|
|
}
|
|
if len(names) > memReminderMaxNames {
|
|
names = names[len(names)-memReminderMaxNames:] // les plus récentes
|
|
}
|
|
content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.",
|
|
memReminderPrefix, strings.Join(names, ", "))
|
|
return Message{Role: "system", Content: content}, true
|
|
}
|
|
|
|
// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction).
|
|
func stripReminder(msgs []Message) []Message {
|
|
out := make([]Message, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
if m.Role == "system" {
|
|
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source),
|
|
// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On
|
|
// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête
|
|
// protégée) pour repartir d'un rappel propre et à jour.
|
|
//
|
|
// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée.
|
|
// Sans page lue, no-op.
|
|
func remindReadMemPages(compacted, source []Message) []Message {
|
|
if memMode() == MemOff {
|
|
return compacted
|
|
}
|
|
names := collectReadPageNames(source)
|
|
compacted = stripReminder(compacted)
|
|
if m, ok := buildReminderMessage(names); ok {
|
|
return append([]Message{m}, compacted...)
|
|
}
|
|
return compacted
|
|
}
|