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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 08:44:46 +02:00
1 parent 7c9aa04fd9
commit 4932999d5c
13 files changed
+1444 -49

No files matched your search

+4
View File
@@ -468,6 +468,10 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
}
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : ce sont des données de
// Loki, pas la conversation, et le bloc projet est repris tout neuf après la
// compaction. Sans la clé, rien à retirer.
forSummary = stripContextUpdates(forSummary)
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
+32 -11
View File
@@ -67,6 +67,10 @@ type Conversation struct {
// persisté : un rechargement repart du seul CtxUsed, comme avant.
ctxUsedLen int
genPeak int
// ProjSnap : bloc projet figé de la discussion et dernier état annoncé au
// modèle (PROJ_SNAPSHOT, chat_projsnap.go). nil = pas d'instantané : le
// prochain tour en prend un. Toujours nil sans la clé (JSON inchangé).
ProjSnap *projSnapshot `json:"proj_snap,omitempty"`
Generating bool `json:"-"`
cancel context.CancelFunc // annule la génération en cours (/stop)
@@ -182,6 +186,8 @@ func LoadConversation() {
}
conv.mu.Lock()
defer conv.mu.Unlock()
// Unmarshal ne remet pas à zéro un champ absent du JSON.
conv.ProjSnap = nil
_ = json.Unmarshal(b, conv)
// Guérit les conversations d'avant le passage des captures en éphémère : un
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
@@ -212,6 +218,10 @@ func (c *Conversation) loadFrom(id string, b []byte) {
}
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.ctxUsedLen, c.genPeak = 0, 0
// Bloc figé de l'ancienne discussion : json.Unmarshal ne remet pas à zéro un
// champ absent, une discussion neuve (ou enregistrée sans lui) l'aurait hérité
// — avec la description et l'index d'un autre projet.
c.ProjSnap = nil
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 {
_ = json.Unmarshal(b, c)
@@ -397,6 +407,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
if caps.Agent {
compacted = remindReadMemPages(compacted, msgs)
}
// PROJ_SNAPSHOT : le début du prompt est réécrit, le bloc projet sera donc
// repris tout neuf au prochain tour (ProjSnap = nil). Les mises à jour déjà
// livrées le suivraient dans la queue gardée telle quelle, une vieille valeur
// après la fraîche : retirées ici, elles n'apprennent rien que le bloc neuf
// ne dise. Sans la clé, rien à retirer.
compacted = stripContextUpdates(compacted)
overhead := ctxUsed - estimateTokens(msgs)
if overhead < 0 {
overhead = 0
@@ -405,6 +421,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
c.mu.Lock()
if c.epoch == epoch {
c.Messages = compacted
c.ProjSnap = nil
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
c.ctxUsedLen = len(compacted)
}
@@ -797,15 +814,14 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
// pas : le modèle se mettait à écrire des <tool_call> en clair.
final := msgs
if caps.Agent {
final = append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
//
// Assemblage dans turnView (chat_projsnap.go) : avec PROJ_SNAPSHOT, le bloc
// projet est celui, figé, de la discussion, et ses changements partent en
// tête de ce message — après la compaction de début de tour, sous c.mu.
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent)
runCtx := ctx
if snapTurn != nil {
runCtx = withProjSnapTurn(ctx, snapTurn)
}
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
@@ -825,8 +841,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
// pas de vérification du mode Code avant sa réponse.
asked := false
sent, tools := prepareTurn(final, caps)
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
@@ -927,6 +942,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
if newBase != nil {
c.Messages = newBase
}
// Bloc projet rafraîchi par une compaction en cours de tour
// (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie.
if s := snapTurn.takeRefreshed(); s != nil {
c.ProjSnap = s
}
c.Messages = append(c.Messages, extra...)
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
@@ -1080,6 +1100,7 @@ func (c *Conversation) Reset() {
c.Seq = 0
c.CtxUsed = 0
c.ctxUsedLen, c.genPeak = 0, 0
c.ProjSnap = nil
c.epoch++
c.Generating = false
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
+4 -2
View File
@@ -103,8 +103,10 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
Version: Version,
ExportedAt: time.Now().Format(time.RFC3339),
CtxUsed: ctxUsed,
Messages: stripInlineImages(msgs),
Log: filterLog(log, o),
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : l'export rend la
// conversation, pas les données que Loki y a glissées.
Messages: stripInlineImages(stripContextUpdates(msgs)),
Log: filterLog(log, o),
}
return json.MarshalIndent(p, "", " ")
}
+672
View File
@@ -0,0 +1,672 @@
package loki
// chat_projsnap.go — PROJ_SNAPSHOT : le bloc projet FIGÉ par discussion, et ses
// changements livrés en <context_update> dans le message suivant.
//
// Pourquoi. Le contexte du projet (description, index mémoire, trackers,
// consignes du dépôt) part en tête du PREMIER message utilisateur
// (normalizeSystemMessages). Reconstruit à chaque tour, il change dès qu'une page
// est créée ou qu'un tracker reçoit un point — et tout ce qui le suit dans le
// prompt est recalculé : 10 à 45k tokens, des dizaines de secondes sur un 27B,
// des minutes sur un MoE déchargé. Un modèle qui note une valeur à chaque tour
// paie ce prix à chaque tour.
//
// Avec la clé (off par défaut) :
// - la discussion garde une copie figée du bloc (ProjSnap), envoyée à l'octet
// près d'un tour à l'autre. Ses en-têtes disent « à jour au <date> » et que
// les <context_update> de Loki les complètent ;
// - un changement arrive en tête du NOUVEAU message utilisateur, rangé tel quel
// dans l'historique (le tour suivant renvoie les mêmes octets). Le moteur
// pose un point de reprise au début de chaque message utilisateur : seul ce
// petit bloc est à calculer ;
// - rien n'est perdu : lignes ajoutées, modifiées, retirées pour les données
// faites de lignes (pages, trackers), texte COMPLET pour la prose
// (description, AGENTS.md). Une ligne fixe du système dit que ces blocs
// viennent de Loki et que le plus récent l'emporte.
//
// Le bloc est rafraîchi — copie neuve, blocs <context_update> retirés de tout
// l'historique — seulement quand le début du prompt change de toute façon :
// compaction (début, fin de tour, manuelle, en cours de tour, réduction forcée),
// système ou outils modifiés (date du jour, réglages, mode), cache froid
// (redémarrage de Loki, changement de modèle, de preset ou de réglage moteur),
// projet, nom, mode mémoire, mode Code ou dépôt changés, textes des en-têtes
// changés. Et quand les mises à jour accumulées dépassent un seuil : au-delà, le
// modèle aurait trop à recoller.
//
// Hors discussion : une tâche planifiée garde le bloc vivant (elle repart de zéro
// à chaque fois), et sans agent le modèle brut ne reçoit rien du projet — ni bloc,
// ni mise à jour.
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// projSnapEnabled : la clé PROJ_SNAPSHOT demande-t-elle le bloc figé ? Jamais
// pour un preset externe (API OpenAI-compatible) : ses requêtes restent celles
// d'avant, quel que soit le réglage.
func projSnapEnabled(cfg map[string]string) bool {
if isExternalConfig(cfg) {
return false
}
switch strings.ToLower(strings.TrimSpace(cfg["PROJ_SNAPSHOT"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// Textes propres au bloc figé. Leur empreinte entre dans la clé de validité :
// une mise à jour de Loki qui les reformule rafraîchit les instantanés persistés.
const (
projSnapFormat = "1"
memIndexFrozenTail = " Later <context_update> blocks from Loki update it."
trackerFrozenHead = " — your trackers, each with its latest point at that time; later <context_update> blocks from Loki replace these lines. " +
"To say which trackers exist or give a latest value, use this list with those updates — do NOT call tracker with no arguments to list them. " +
"Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n"
ctxUpdOpen = `<context_update from="loki"`
ctxUpdClose = "</context_update>"
// projSnapSystemLine : la ligne fixe du prompt système, clé posée seulement.
projSnapSystemLine = "A <context_update from=\"loki\"> block at the start of a user message is written by Loki, not by the user: it updates <project_context>, and the most recent one wins.\n"
)
// projSnapBoot : identifiant de ce lancement de Loki. Après un redémarrage, le
// cache du moteur est froid : garder un vieil instantané n'y gagnerait rien.
var projSnapBoot = strconv.FormatInt(time.Now().UnixNano(), 36)
// projSnapNow : l'horodatage « à jour au » des en-têtes et des mises à jour.
func projSnapNow() string { return time.Now().Format("2006-01-02 15:04") }
// projState : ce que le modèle sait du projet, section par section — au moment
// de l'instantané, puis après chaque mise à jour annoncée. Structuré, jamais un
// simple hash : c'est contre lui qu'on calcule la mise à jour suivante, y
// compris après un redémarrage.
type projState struct {
Code string `json:"code,omitempty"` // consignes du dépôt, message complet
Desc string `json:"desc,omitempty"` // description du projet
MemOn bool `json:"mem_on,omitempty"` // index mémoire présent
MemProse string `json:"mem_prose,omitempty"` // lignes de l'index qui ne sont pas des pages
Mem map[string]string `json:"mem,omitempty"` // fichier → ligne de page
Trk map[string]string `json:"trk,omitempty"` // slug → « nom — valeur (date) »
}
// projSnapshot : le bloc figé d'une discussion. Persisté avec elle (omitempty :
// absent tant que la clé n'a jamais servi, et ignoré d'un Loki plus ancien).
type projSnapshot struct {
At int64 `json:"at"` // instant de l'instantané (ms)
Msgs []Message `json:"msgs,omitempty"` // le bloc tel qu'il part, à l'octet près
State projState `json:"state"` // dernier état annoncé au modèle
Key string `json:"key"` // validité : lancement, réglages, projet, mode…
SysHash string `json:"sys_hash"` // système + outils du dernier tour
SnapTok int `json:"snap_tok,omitempty"`
DeltaTok int `json:"delta_tok,omitempty"` // mises à jour livrées depuis
}
// projLive : le bloc vivant, rendu au format figé, avec son état. memOrder /
// trkOrder : ordre d'affichage, pour des mises à jour stables.
type projLive struct {
msgs []Message
state projState
memMsg Message
trkMsg Message
memOrder []string
trkOrder []string
}
// projSnapCapture lit le contexte projet UNE fois et en tire le bloc (même
// ordre que l'assemblage d'un tour : consignes du dépôt, description, index
// mémoire, trackers) et son état.
func projSnapCapture(caps Caps, asOf string) projLive {
var lv projLive
if m, ok := codeInstructionsMessage(caps); ok {
lv.msgs = append(lv.msgs, m)
lv.state.Code, _ = m.Content.(string)
}
if desc := strings.TrimSpace(projectDesc(activeProjectSlug())); desc != "" {
lv.msgs = append(lv.msgs, renderProjectContext(desc))
lv.state.Desc = desc
}
if idx, ok := memIndexText(); ok {
lv.memMsg = renderMemIndex(idx, asOf)
lv.msgs = append(lv.msgs, lv.memMsg)
lv.state.MemOn = true
lv.state.MemProse, lv.state.Mem, lv.memOrder = splitMemIndex(idx)
}
if list, ok := trackerIndexList(); ok {
lv.trkMsg = renderTrackerIndex(list, asOf)
lv.msgs = append(lv.msgs, lv.trkMsg)
lv.state.Trk = map[string]string{}
for _, m := range list {
lv.state.Trk[m.Slug] = trackerIndexLine(m)
lv.trkOrder = append(lv.trkOrder, m.Slug)
}
}
return lv
}
// splitMemIndex sépare les lignes de page (clé = fichier entre « ]( » et « ) »)
// du reste de l'index. Une ligne de page en double part avec la prose : ainsi
// tout changement la concernant fait renvoyer l'index entier, sans perte.
func splitMemIndex(idx string) (prose string, pages map[string]string, order []string) {
pages = map[string]string{}
var other []string
for _, line := range strings.Split(idx, "\n") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, "- [") {
if i := strings.Index(l, "]("); i >= 0 {
if j := strings.Index(l[i+2:], ")"); j > 0 {
key := l[i+2 : i+2+j]
if _, dup := pages[key]; !dup {
pages[key] = l
order = append(order, key)
continue
}
}
}
}
other = append(other, line)
}
return strings.TrimSpace(strings.Join(other, "\n")), pages, order
}
func newProjSnapshot(lv projLive, key, sysHash string) *projSnapshot {
tok := 0
for _, m := range lv.msgs {
tok += len(msgText(m)) / 4
}
return &projSnapshot{At: time.Now().UnixMilli(), Msgs: lv.msgs, State: lv.state, Key: key, SysHash: sysHash, SnapTok: tok}
}
// projSnapDeltaLimit : au-delà de ce volume de mises à jour depuis l'instantané
// (en tokens estimés), on rafraîchit plutôt que d'en empiler d'autres : environ
// un quart du bloc, entre 400 et 1500 tokens.
func projSnapDeltaLimit(snapTok int) int {
return min(1500, max(400, snapTok/4))
}
// projSnapKey : tout ce qui, en changeant, rend l'instantané faux ou inutile.
// Les réglages pris en compte sont ceux qui touchent au moteur (modèle, preset,
// arguments : un changement le relance, le cache est froid) ; les boutons de
// conversation qui ne le relancent pas en sont exclus.
func projSnapKey(caps Caps, cfg map[string]string) string {
h := fnv.New64a()
w := func(s string) {
h.Write([]byte(s))
h.Write([]byte{0})
}
w(projSnapFormat)
w(projSnapBoot)
keys := make([]string, 0, len(cfg))
for k := range cfg {
if !projSnapIgnoredKeys[k] {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
w(k)
w(cfg[k])
}
slug := activeProjectSlug()
w(slug)
w(projectName(slug))
w(string(memMode()))
w(strconv.FormatBool(caps.Code))
if caps.Code {
d, _ := gitRepoDir("")
w(d)
w(agentCwd())
}
for _, s := range []string{memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
w(s)
}
return fmt.Sprintf("%016x", h.Sum64())
}
// projSnapIgnoredKeys : réglages changés en cours de discussion sans relancer le
// moteur ni toucher au bloc. Les compter rafraîchirait pour rien.
var projSnapIgnoredKeys = map[string]bool{
"PROJ_SNAPSHOT": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
"COMPACT": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true,
}
// projSnapSysHash : empreinte du bloc système commun et des outils. S'ils
// changent, tout le prompt est recalculé de toute façon : rafraîchir est gratuit.
func projSnapSysHash(sent []Message, tools []Tool) string {
h := fnv.New64a()
for _, m := range sent {
if m.Role != "system" {
break
}
if isProjectSystem(m) {
continue
}
s, _ := m.Content.(string)
h.Write([]byte(s))
h.Write([]byte{0})
}
b, _ := json.Marshal(tools)
h.Write(b)
return fmt.Sprintf("%016x", h.Sum64())
}
// projDelta : la mise à jour à annoncer entre l'état déjà connu du modèle et le
// bloc vivant. "" = rien de changé. Les sections faites de lignes changent ligne
// à ligne (une ligne de tracker remplace la précédente) ; la prose, ou une
// section qui apparaît, repart en entier.
func projDelta(old projState, lv projLive, asOf string) string {
cur := lv.state
var b strings.Builder
full := func(m Message) {
b.WriteString("Current version, replaces any earlier one — " + msgText(m) + "\n")
}
if old.Code != cur.Code {
if cur.Code == "" {
b.WriteString("Repository instructions: removed.\n")
} else {
full(Message{Content: cur.Code})
}
}
if old.Desc != cur.Desc {
if cur.Desc == "" {
b.WriteString("Project description: removed.\n")
} else {
full(renderProjectContext(cur.Desc))
}
}
switch {
case !cur.MemOn && old.MemOn:
b.WriteString("Memory index: no pages any more.\n")
case cur.MemOn && (!old.MemOn || old.MemProse != cur.MemProse):
full(lv.memMsg)
case cur.MemOn:
var lines []string
for _, k := range lv.memOrder {
l := strings.TrimPrefix(cur.Mem[k], "- ")
switch prev, ok := old.Mem[k]; {
case !ok:
lines = append(lines, "+ "+l)
case prev != cur.Mem[k]:
lines = append(lines, "~ "+l)
}
}
for _, k := range sortedMissing(old.Mem, cur.Mem) {
lines = append(lines, "- "+k+" (deleted)")
}
if len(lines) > 0 {
b.WriteString("Memory index changes:\n" + strings.Join(lines, "\n") + "\n")
}
}
switch {
case len(cur.Trk) == 0 && len(old.Trk) > 0:
b.WriteString("Trackers: none left.\n")
case len(cur.Trk) > 0 && len(old.Trk) == 0:
full(lv.trkMsg)
case len(cur.Trk) > 0:
var lines []string
for _, k := range lv.trkOrder {
switch prev, ok := old.Trk[k]; {
case !ok:
lines = append(lines, "+ "+cur.Trk[k])
case prev != cur.Trk[k]:
lines = append(lines, "~ "+cur.Trk[k])
}
}
for _, k := range sortedMissing(old.Trk, cur.Trk) {
lines = append(lines, "- "+old.Trk[k]+" (deleted)")
}
if len(lines) > 0 {
b.WriteString("Tracker changes (latest point):\n" + strings.Join(lines, "\n") + "\n")
}
}
if b.Len() == 0 {
return ""
}
// Un titre de page qui contiendrait la balise fermante couperait le bloc au
// mauvais endroit au moment de le retirer.
body := strings.ReplaceAll(b.String(), ctxUpdClose, "</context-update>")
return ctxUpdOpen + ` at="` + asOf + `">` + "\n" + body + ctxUpdClose + "\n\n"
}
// sortedMissing : clés de old absentes de cur, triées.
func sortedMissing(old, cur map[string]string) []string {
var out []string
for k := range old {
if _, ok := cur[k]; !ok {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
// --- blocs <context_update> dans l'historique -----------------------------
// stripCtxUpdText retire le(s) bloc(s) posé(s) en TÊTE du texte. Seulement en
// tête : un message qui en parle plus loin reste intact.
func stripCtxUpdText(s string) (string, bool) {
stripped := false
for strings.HasPrefix(s, ctxUpdOpen) {
i := strings.Index(s, ctxUpdClose)
if i < 0 {
break
}
s = strings.TrimPrefix(s[i+len(ctxUpdClose):], "\n\n")
stripped = true
}
return s, stripped
}
// withoutCtxUpdate : le message sans son bloc de tête, ok=false s'il n'en avait
// pas (message rendu tel quel). Contenu texte ou multimodal ([]any, []map).
func withoutCtxUpdate(m Message) (Message, bool) {
if m.Role != "user" {
return m, false
}
switch c := m.Content.(type) {
case string:
if s, ok := stripCtxUpdText(c); ok {
m.Content = s
return m, true
}
case []any:
if len(c) > 0 {
if p, ok := c[0].(map[string]any); ok && p["type"] == "text" {
if t, _ := p["text"].(string); t != "" {
if s, ok := stripCtxUpdText(t); ok {
out := append([]any(nil), c[1:]...)
if s != "" {
q := map[string]any{}
for k, v := range p {
q[k] = v
}
q["text"] = s
out = append([]any{q}, out...)
}
m.Content = out
return m, true
}
}
}
}
case []map[string]any:
if len(c) > 0 && c[0]["type"] == "text" {
if t, _ := c[0]["text"].(string); t != "" {
if s, ok := stripCtxUpdText(t); ok {
out := append([]map[string]any(nil), c[1:]...)
if s != "" {
q := map[string]any{}
for k, v := range c[0] {
q[k] = v
}
q["text"] = s
out = append([]map[string]any{q}, out...)
}
m.Content = out
return m, true
}
}
}
}
return m, false
}
// stripContextUpdates rend l'historique sans aucun bloc <context_update>. Sans
// bloc, c'est la MÊME tranche (rien recopié) : le cas de toutes les discussions
// sans la clé.
func stripContextUpdates(msgs []Message) []Message {
var out []Message
for i, m := range msgs {
if s, ok := withoutCtxUpdate(m); ok {
if out == nil {
out = append([]Message(nil), msgs...)
}
out[i] = s
}
}
if out == nil {
return msgs
}
return out
}
func hasContextUpdates(msgs []Message) bool {
for _, m := range msgs {
if _, ok := withoutCtxUpdate(m); ok {
return true
}
}
return false
}
// prependCtxUpdate pose le bloc en tête d'un message utilisateur, comme
// prependToFirstUser : texte simple, ou une partie texte de plus en multimodal.
func prependCtxUpdate(m Message, block string) (Message, bool) {
if m.Role != "user" {
return m, false
}
switch c := m.Content.(type) {
case string:
m.Content = block + c
case []any:
m.Content = append([]any{map[string]any{"type": "text", "text": block}}, c...)
case []map[string]any:
m.Content = append([]map[string]any{{"type": "text", "text": block}}, c...)
default:
return m, false
}
return m, true
}
// replaceProjectHead remplace le bloc projet de tête (hors rappel des pages
// lues, qui appartient à l'historique) par block, à la même place.
func replaceProjectHead(msgs []Message, block []Message) []Message {
end := 0
for end < len(msgs) && msgs[end].Role == "system" {
end++
}
out := make([]Message, 0, len(msgs)+len(block))
inserted := false
for _, m := range msgs[:end] {
reminder := false
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
reminder = true
}
if isProjectSystem(m) && !reminder {
if !inserted {
out = append(out, block...)
inserted = true
}
continue
}
if reminder && !inserted {
out = append(out, block...)
inserted = true
}
out = append(out, m)
}
if !inserted {
out = append(out, block...)
}
return append(out, msgs[end:]...)
}
// --- assemblage d'un tour de la discussion --------------------------------
// turnView assemble la séquence envoyée pour un tour de la discussion (generate,
// runBuilderTurn) : prompt du preset, consignes du dépôt, contexte projet, puis
// l'historique. inject=false : modèle brut (agent off), rien du projet.
//
// Sans PROJ_SNAPSHOT : exactement l'assemblage d'avant, contexte vivant. Seul
// ajout, sans effet tant que la clé n'a jamais servi : une discussion qui porte
// encore un instantané ou des blocs d'une époque où la clé était posée en est
// nettoyée — un vieux <context_update> placé après le bloc vivant se lirait
// comme plus récent que lui.
//
// Avec la clé : instantané pris, gardé ou rafraîchi ; mise à jour éventuelle
// posée en tête du dernier message utilisateur, dans c.Messages ET dans msgs
// (sous c.mu, si l'epoch n'a pas changé). pt sert au rafraîchissement en cours
// de tour (runChatTools) ; nil sans la clé.
func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
cfg := ReadConfig()
on := projSnapEnabled(cfg)
if !inject {
// Sans agent : ni bloc, ni mise à jour. Des blocs restés d'un tour avec
// agent sont retirés de la vue envoyée (l'historique les garde : l'agent
// revenu, le système change et le bloc est rafraîchi).
sent, tools = prepareTurn(stripContextUpdates(msgs), caps)
return sent, tools, nil
}
if !on {
c.mu.Lock()
if c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
c.ProjSnap = nil
c.Messages = stripContextUpdates(c.Messages)
}
c.mu.Unlock()
msgs = stripContextUpdates(msgs)
final := append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
sent, tools = prepareTurn(final, caps)
return sent, tools, nil
}
// Lectures (disque, base, git) hors du verrou de la conversation.
asOf := projSnapNow()
live := projSnapCapture(caps, asOf)
key := projSnapKey(caps, cfg)
// Même calcul que prepareTurn, en deux temps : l'empreinte du système et des
// outils décide du rafraîchissement avant l'assemblage. Les outils ne sont
// calculés qu'une fois (connexions MCP).
tools = EnabledTools(caps)
var spMsgs []Message
if sp := readSysPrompt(); sp != "" {
spMsgs = []Message{{Role: "system", Content: sp}}
}
sysHash := projSnapSysHash(InjectSkills(spMsgs, caps, tools), tools)
block := live.msgs
c.mu.Lock()
if c.epoch == epoch {
refresh, delta := c.projSnapApplyLocked(live, key, sysHash, asOf)
if refresh {
msgs = stripContextUpdates(msgs)
}
if delta != "" {
if n := len(msgs); n > 0 {
if m, ok := prependCtxUpdate(msgs[n-1], delta); ok {
msgs = append(msgs[:n-1:n-1], m)
}
}
}
block = c.ProjSnap.Msgs
}
c.mu.Unlock()
final := append(append(append([]Message(nil), spMsgs...), block...), msgs...)
sent = InjectSkills(final, caps, tools)
return sent, tools, &projSnapTurn{caps: caps, key: key, sysHash: sysHash}
}
// projSnapApplyLocked décide pour ce tour, c.mu tenu : rafraîchir (instantané
// neuf, blocs retirés de l'historique) ou annoncer une mise à jour, posée en tête
// du dernier message utilisateur de c.Messages. Renvoie ce qui a été fait, pour
// que l'appelant l'applique à sa propre copie.
func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf string) (refresh bool, delta string) {
snap := c.ProjSnap
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
tok := 0
if !refresh {
delta = projDelta(snap.State, live, asOf)
tok = len(delta) / 4
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
refresh = true
}
}
if refresh {
c.Messages = stripContextUpdates(c.Messages)
c.ProjSnap = newProjSnapshot(live, key, sysHash)
return true, ""
}
if delta == "" {
return false, ""
}
n := len(c.Messages)
if n == 0 {
return false, ""
}
m, ok := prependCtxUpdate(c.Messages[n-1], delta)
if !ok {
// Pas de message utilisateur où la poser (cas théorique) : l'état
// annoncé ne bouge pas, elle partira au tour suivant.
return false, ""
}
c.Messages[n-1] = m
snap.State = live.state
snap.DeltaTok += tok
return false, delta
}
// --- rafraîchissement en cours de tour -------------------------------------
// projSnapTurn accompagne un tour de discussion avec la clé posée. runChatTools
// le récupère dans le contexte et s'en sert quand une compaction ou une
// réduction réécrit l'historique en plein tour : le début du prompt change de
// toute façon, le bloc est donc remplacé par le vivant (et les mises à jour
// retirées), sans perdre celles qu'aurait avalées le résumé. L'appelant range
// ensuite refreshed comme nouvel instantané : le tour suivant renvoie les mêmes
// octets, pas de recalcul de plus.
type projSnapTurn struct {
caps Caps
key string
sysHash string
mu sync.Mutex
refreshed *projSnapshot
}
type projSnapCtxKey struct{}
func withProjSnapTurn(ctx context.Context, pt *projSnapTurn) context.Context {
return context.WithValue(ctx, projSnapCtxKey{}, pt)
}
func projSnapTurnFrom(ctx context.Context) *projSnapTurn {
pt, _ := ctx.Value(projSnapCtxKey{}).(*projSnapTurn)
return pt
}
// refresh : historique en cours de tour, bloc vivant à la place du figé, sans
// aucun <context_update>.
func (pt *projSnapTurn) refresh(msgs []Message) []Message {
live := projSnapCapture(pt.caps, projSnapNow())
out := replaceProjectHead(stripContextUpdates(msgs), live.msgs)
pt.mu.Lock()
pt.refreshed = newProjSnapshot(live, pt.key, pt.sysHash)
pt.mu.Unlock()
return out
}
// takeRefreshed : l'instantané posé en cours de tour, nil s'il n'y en a pas eu.
func (pt *projSnapTurn) takeRefreshed() *projSnapshot {
if pt == nil {
return nil
}
pt.mu.Lock()
defer pt.mu.Unlock()
s := pt.refreshed
pt.refreshed = nil
return s
}
+590
View File
@@ -0,0 +1,590 @@
package loki
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
)
// projSnapSetup : base et projet neufs, moteur de comédie qui répond « ok. ».
// on = PROJ_SNAPSHOT posée.
func projSnapSetup(t *testing.T, on bool) *moteurEcho {
t.Helper()
withWorkspace(t)
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
setProjectOverride("")
t.Cleanup(func() {
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
setProjectOverride("")
})
ensureDefaultProject()
freshTplProbe(t)
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
t.Fatal(err)
}
if on {
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
t.Fatal(err)
}
}
m := &moteurEcho{reply: func(int, string) (int, string) {
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
}}
m.start(t)
return m
}
// playTurn joue un tour utilisateur complet sur c et rend le corps de la PREMIÈRE
// requête de ce tour.
func playTurn(t *testing.T, m *moteurEcho, c *Conversation, caps Caps, text string) string {
t.Helper()
before := len(m.all())
c.mu.Lock()
c.Messages = append(c.Messages, um(text))
c.mu.Unlock()
c.generate(context.Background(), caps, 0.7, c.epoch)
all := m.all()
if len(all) <= before {
t.Fatalf("aucune requête pour %q", text)
}
return all[before]
}
// userContents : le texte des messages user d'une requête, dans l'ordre.
func userContents(t *testing.T, body string) []string {
t.Helper()
var out []string
for _, m := range requestMessages(t, body) {
if m["role"] == "user" {
s, _ := m["content"].(string)
out = append(out, s)
}
}
return out
}
func systemContent(t *testing.T, body string) string {
t.Helper()
for _, m := range requestMessages(t, body) {
if m["role"] == "system" {
s, _ := m["content"].(string)
return s
}
}
return ""
}
// oldAssembly : l'assemblage de generate d'avant PROJ_SNAPSHOT, recopié tel quel.
func oldAssembly(caps Caps, msgs []Message) []Message {
final := msgs
if caps.Agent {
final = append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
}
sent, _ := prepareTurn(final, caps)
return normalizeSystemMessages(sent)
}
func jsonValue(t *testing.T, v any) any {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
var out any
if err := json.Unmarshal(b, &out); err != nil {
t.Fatal(err)
}
return out
}
// Clé absente : la requête est celle d'avant, à l'octet près — bloc vivant qui
// suit la page créée, aucune ligne système de plus, rien de persisté en plus.
func TestProjSnapDefautRienNeChange(t *testing.T) {
m := projSnapSetup(t, false)
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
for i, text := range []string{"bonjour", "et ensuite ?"} {
if i == 1 {
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
t.Fatal(err)
}
}
c.mu.Lock()
want := oldAssembly(caps, append(append([]Message(nil), c.Messages...), um(text)))
c.mu.Unlock()
body := playTurn(t, m, c, caps, text)
var got struct {
Messages any `json:"messages"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.Messages, jsonValue(t, want)) {
t.Fatalf("tour %d : requête différente de l'assemblage d'avant\nreçu %v\nattendu %v", i, got.Messages, jsonValue(t, want))
}
if strings.Contains(body, "context_update") || strings.Contains(body, "as of") {
t.Fatalf("tour %d : trace du bloc figé sans la clé :\n%s", i, body)
}
}
if us := userContents(t, m.all()[1]); !strings.Contains(us[0], "](docker.md)") {
t.Fatalf("sans la clé, le bloc vivant doit montrer la nouvelle page : %q", us[0])
}
b, _ := json.Marshal(c)
if c.ProjSnap != nil || strings.Contains(string(b), "proj_snap") {
t.Fatalf("instantané persisté sans la clé : %s", b)
}
}
// La première page d'un projet vide arrive en mise à jour : le premier message
// reste identique, la mise à jour est rangée dans l'historique et renvoyée telle
// quelle ensuite, la suivante n'annonce que la page ajoutée.
func TestProjSnapPageAjouteeEnMiseAJour(t *testing.T) {
m := projSnapSetup(t, true)
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
b1 := playTurn(t, m, c, caps, "bonjour")
if !strings.Contains(systemContent(t, b1), projSnapSystemLine) {
t.Fatal("ligne fixe du système absente avec la clé")
}
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
b2 := playTurn(t, m, c, caps, "et le NAS ?")
u1, u2 := userContents(t, b1), userContents(t, b2)
if u2[0] != u1[0] {
t.Fatalf("premier message changé :\navant %q\naprès %q", u1[0], u2[0])
}
last := u2[len(u2)-1]
if !strings.HasPrefix(last, ctxUpdOpen) || !strings.Contains(last, "](nas.md)") || !strings.HasSuffix(last, "et le NAS ?") {
t.Fatalf("mise à jour attendue en tête du nouveau message : %q", last)
}
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
t.Fatal(err)
}
b3 := playTurn(t, m, c, caps, "et docker ?")
u3 := userContents(t, b3)
if u3[0] != u1[0] || u3[1] != u2[1] {
t.Fatalf("messages déjà envoyés modifiés :\n%q\n%q", u3[0], u3[1])
}
last = u3[len(u3)-1]
if !strings.Contains(last, "+ [Docker](docker.md)") || strings.Contains(last, "nas.md") {
t.Fatalf("seule la page ajoutée doit être annoncée : %q", last)
}
// Sans changement : rien de posé.
b4 := playTurn(t, m, c, caps, "merci")
if u4 := userContents(t, b4); u4[len(u4)-1] != "merci" {
t.Fatalf("mise à jour vide posée : %q", u4[len(u4)-1])
}
// Persisté et relu : l'état annoncé survit.
raw, _ := json.Marshal(c)
c2 := newTestConv()
c2.loadFrom("x", raw)
if c2.ProjSnap == nil || c2.ProjSnap.State.Mem["docker.md"] == "" {
t.Fatalf("instantané non relu : %+v", c2.ProjSnap)
}
}
// Une nouvelle valeur de tracker remplace sa ligne (« ~ ») ; un tracker supprimé
// est annoncé comme tel.
func TestProjSnapTrackerLigneRemplacee(t *testing.T) {
m := projSnapSetup(t, true)
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
t.Fatal(err)
}
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
b1 := playTurn(t, m, c, caps, "salut")
if u := userContents(t, b1); !strings.Contains(u[0], "Trackers (tracker tool) as of ") || strings.Contains(u[0], "answer straight from this") {
t.Fatalf("en-tête figé attendu : %q", u[0])
}
if _, err := trackerAdd("poids", "2026-10-03", "73 kg"); err != nil {
t.Fatal(err)
}
b2 := playTurn(t, m, c, caps, "mon poids ?")
u := userContents(t, b2)
if u[0] != userContents(t, b1)[0] {
t.Fatal("premier message changé")
}
if !strings.Contains(u[len(u)-1], "~ poids — 73 kg (2026-10-03)") {
t.Fatalf("ligne de tracker attendue : %q", u[len(u)-1])
}
}
// Une compaction (début, fin, manuelle) reprend le bloc tout neuf au tour suivant
// et retire les mises à jour de la queue gardée : une vieille valeur ne doit pas
// suivre la fraîche.
func TestProjSnapCompactionRafraichit(t *testing.T) {
m := projSnapSetup(t, true)
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
t.Fatal(err)
}
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
playTurn(t, m, c, caps, "salut")
if _, err := trackerAdd("poids", "2026-10-02", "72.8 kg"); err != nil {
t.Fatal(err)
}
playTurn(t, m, c, caps, "note-le")
if !hasContextUpdates(c.Messages) {
t.Fatal("mise à jour attendue dans l'historique")
}
for i := 0; i < 10; i++ {
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
}
// Le message avec la mise à jour en dernier : il tombe dans la queue gardée.
c.Messages = append(c.Messages, am("vu"), um(ctxUpdOpen+` at="x">`+"\n~ poids — 72.4 kg\n"+ctxUpdClose+"\n\nencore"))
if _, changed := c.compactAndPublish(context.Background(), c.epoch, "test", append([]Message(nil), c.Messages...), 50000, caps); !changed {
t.Fatal("compaction sans effet")
}
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
t.Fatalf("après compaction : instantané %v, mises à jour restantes %v", c.ProjSnap != nil, hasContextUpdates(c.Messages))
}
b := playTurn(t, m, c, caps, "et maintenant ?")
if strings.Contains(strings.Join(userContents(t, b), " "), ctxUpdOpen) {
t.Fatalf("mise à jour après le bloc neuf : %s", b)
}
if u := userContents(t, b); !strings.Contains(u[0], "72.8 kg") {
t.Fatalf("bloc neuf attendu avec la dernière valeur : %q", u[0])
}
}
// Compaction EN COURS de tour : le bloc vivant remplace le figé dans la requête
// rejouée (la mise à jour avalée par le résumé n'est pas perdue), et c'est lui
// que le tour suivant renvoie.
func TestProjSnapCompactionEnTour(t *testing.T) {
m := projSnapSetup(t, true)
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
playTurn(t, m, c, caps, "salut")
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
for i := 0; i < 12; i++ {
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
}
c.Messages = append(c.Messages, am("vu"))
n := len(m.all())
m.mu.Lock()
m.reply = func(k int, _ string) (int, string) {
if k == n {
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
}
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
}
m.mu.Unlock()
playTurn(t, m, c, caps, "et le NAS ?")
all := m.all()
first, retry := all[n], all[n+1]
if uf := userContents(t, first); !strings.HasPrefix(uf[len(uf)-1], ctxUpdOpen) {
t.Fatal("mise à jour attendue dans la requête refusée")
}
u := userContents(t, retry)
if strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
t.Fatalf("mise à jour restée après la compaction en cours de tour : %q", u)
}
if !strings.Contains(u[0], "](nas.md)") {
t.Fatalf("la page doit être dans le bloc rafraîchi : %q", u[0])
}
if c.ProjSnap == nil || c.ProjSnap.State.Mem["nas.md"] == "" || hasContextUpdates(c.Messages) {
t.Fatalf("instantané rafraîchi non rangé : %+v", c.ProjSnap)
}
next := playTurn(t, m, c, caps, "merci")
head := func(s string) string { return s[:strings.Index(s, "</project_context>")] }
if head(userContents(t, next)[0]) != head(u[0]) {
t.Fatal("le tour suivant doit renvoyer le bloc rafraîchi à l'identique")
}
}
// Changer de projet, de discussion ou repartir à zéro ne garde pas l'instantané.
func TestProjSnapProjetEtDiscussion(t *testing.T) {
m := projSnapSetup(t, true)
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
playTurn(t, m, c, caps, "salut")
p, err := createProject("Jardin")
if err != nil {
t.Fatal(err)
}
if err := setProjectDesc(p.Slug, "Le potager."); err != nil {
t.Fatal(err)
}
if err := setActiveProject(p.Slug); err != nil {
t.Fatal(err)
}
b := playTurn(t, m, c, caps, "et le jardin ?")
u := userContents(t, b)
if !strings.Contains(u[0], "Le potager.") || strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
t.Fatalf("changement de projet : bloc neuf attendu, sans mise à jour : %q", u[0])
}
if c.ProjSnap == nil {
t.Fatal("instantané attendu")
}
// loadFrom : fil neuf, ou enregistré sans le champ.
c.loadFrom("neuf", nil)
if c.ProjSnap != nil {
t.Fatal("fil neuf : instantané hérité")
}
c.ProjSnap = &projSnapshot{Key: "x"}
c.loadFrom("ancien", []byte(`{"messages":[{"role":"user","content":"vieux"}]}`))
if c.ProjSnap != nil {
t.Fatal("fil enregistré sans instantané : hérité")
}
c.ProjSnap = &projSnapshot{Key: "x"}
c.Reset()
if c.ProjSnap != nil {
t.Fatal("Reset garde l'instantané")
}
}
// Sans agent : ni bloc ni mise à jour, et les blocs restés d'avant ne partent pas.
func TestProjSnapSansAgent(t *testing.T) {
m := projSnapSetup(t, true)
c := newTestConv()
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "salut")
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "le NAS ?")
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
t.Fatal(err)
}
before := c.ProjSnap.State
b := playTurn(t, m, c, Caps{}, "réponds sans outils")
if strings.Contains(b, "context_update") || strings.Contains(b, "project_context") {
t.Fatalf("agent off : contexte projet envoyé : %s", b)
}
if last := c.Messages[len(c.Messages)-2]; msgText(last) != "réponds sans outils" {
t.Fatalf("agent off : message modifié : %q", msgText(last))
}
if !reflect.DeepEqual(c.ProjSnap.State, before) {
t.Fatal("agent off : état annoncé modifié")
}
}
// Clé retirée après usage : l'instantané et les blocs sont nettoyés, retour au
// bloc vivant sans vieille mise à jour derrière lui.
func TestProjSnapCleRetiree(t *testing.T) {
m := projSnapSetup(t, true)
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
playTurn(t, m, c, caps, "salut")
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
playTurn(t, m, c, caps, "le NAS ?")
if err := SetConfigKey("PROJ_SNAPSHOT", ""); err != nil {
t.Fatal(err)
}
b := playTurn(t, m, c, caps, "et après ?")
if strings.Contains(b, "context_update") || strings.Contains(b, "as of") {
t.Fatalf("restes du bloc figé : %s", b)
}
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
t.Fatal("historique non nettoyé")
}
}
// Rafraîchissement : clé de validité (redémarrage, réglage moteur), système ou
// outils changés, seuil d'accumulation.
func TestProjSnapApplyRafraichit(t *testing.T) {
projSnapSetup(t, true)
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
caps := Caps{Agent: true, Mem: MemAlways}
live := projSnapCapture(caps, "t0")
c := newTestConv()
c.Messages = []Message{um("a")}
if r, _ := c.projSnapApplyLocked(live, "k", "s", "t0"); !r {
t.Fatal("premier tour : instantané attendu")
}
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
t.Fatal(err)
}
live2 := projSnapCapture(caps, "t1")
c.Messages = append(c.Messages, am("b"), um("c"))
if r, d := c.projSnapApplyLocked(live2, "k", "s", "t1"); r || d == "" {
t.Fatalf("mise à jour attendue : refresh=%v delta=%q", r, d)
}
for _, tc := range []struct{ name, key, sys string }{{"clé", "k2", "s"}, {"système", "k", "s2"}} {
snap := *c.ProjSnap
c.Messages = append(c.Messages, am("d"), um("e"))
if r, _ := c.projSnapApplyLocked(live, tc.key, tc.sys, "t2"); !r {
t.Fatalf("%s changé : rafraîchissement attendu", tc.name)
}
if hasContextUpdates(c.Messages) {
t.Fatalf("%s : mises à jour restées après rafraîchissement", tc.name)
}
c.ProjSnap = &snap
}
// Seuil : les mises à jour accumulées dépassent la limite.
c.ProjSnap.Key, c.ProjSnap.SysHash = "k", "s"
c.ProjSnap.DeltaTok = projSnapDeltaLimit(c.ProjSnap.SnapTok)
c.ProjSnap.State = live.state
if r, _ := c.projSnapApplyLocked(live2, "k", "s", "t3"); !r {
t.Fatal("seuil dépassé : rafraîchissement attendu")
}
// La clé suit le lancement de Loki.
k1 := projSnapKey(caps, ReadConfig())
old := projSnapBoot
projSnapBoot = "autre"
defer func() { projSnapBoot = old }()
if projSnapKey(caps, ReadConfig()) == k1 {
t.Fatal("redémarrage : clé inchangée")
}
projSnapBoot = old
if err := SetConfigKey("REASONING_EFFORT", "high"); err != nil {
t.Fatal(err)
}
if projSnapKey(caps, ReadConfig()) != k1 {
t.Fatal("un réglage de conversation ne doit pas rafraîchir")
}
if err := SetConfigKey("MODEL", "/models/autre.gguf"); err != nil {
t.Fatal(err)
}
if projSnapKey(caps, ReadConfig()) == k1 {
t.Fatal("changement de modèle : clé inchangée")
}
}
// La prose repart en entier ; les sections faites de lignes, ligne à ligne.
func TestProjDeltaParType(t *testing.T) {
projSnapSetup(t, true)
old := projState{
Code: "Repository instructions (AGENTS.md) — follow them:\n\nmake test", Desc: "Ancienne description.",
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md)", "b.md": "- [B](b.md)"},
Trk: map[string]string{"poids": "poids — 72 kg (2026-10-01)", "pas": "pas — 8000 (2026-10-01)"},
}
lv := projLive{
state: projState{
Code: "Repository instructions (AGENTS.md) — follow them:\n\ngo test ./...", Desc: "Nouvelle description,\nsur deux lignes.",
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md) — accroche", "c.md": "- [C](c.md)"},
Trk: map[string]string{"poids": "poids — 73 kg (2026-10-03)", "eau": "eau — 2 L (2026-10-03)"},
},
memOrder: []string{"a.md", "c.md"},
trkOrder: []string{"poids", "eau"},
}
d := projDelta(old, lv, "2026-10-04 09:00")
for _, want := range []string{
ctxUpdOpen + ` at="2026-10-04 09:00">`,
"go test ./...",
"Nouvelle description,\nsur deux lignes.",
"~ [A](a.md) — accroche", "+ [C](c.md)", "- b.md (deleted)",
"~ poids — 73 kg (2026-10-03)", "+ eau — 2 L (2026-10-03)", "- pas — 8000 (2026-10-01) (deleted)",
} {
if !strings.Contains(d, want) {
t.Fatalf("manque %q dans :\n%s", want, d)
}
}
if strings.Contains(d, "make test") || strings.Contains(d, "Ancienne") {
t.Fatalf("l'ancien texte ne doit pas repartir :\n%s", d)
}
// En-tête de l'index modifié : l'index repart en entier.
lv.state.MemProse = "# Index (revu)"
lv.memMsg = Message{Content: "Project memory index … # Index (revu)"}
if d := projDelta(old, lv, "t"); !strings.Contains(d, "Current version, replaces any earlier one — Project memory index") {
t.Fatalf("prose de l'index modifiée : index complet attendu :\n%s", d)
}
if projDelta(old, projLive{state: old}, "t") != "" {
t.Fatal("rien de changé : mise à jour vide attendue")
}
}
// Les blocs sont retirés partout où le texte de l'utilisateur est lu : tâche du
// vérificateur, titre, export, entrée du résumeur ; en texte et en multimodal.
func TestProjSnapRetireDesLecteurs(t *testing.T) {
testHome(t)
blk := ctxUpdOpen + ` at="t">` + "\n+ [A](a.md)\n" + ctxUpdClose + "\n\n"
c := newTestConv()
c.Messages = []Message{um(blk + "corrige le build"), am("ok")}
if got := c.lastUserText(); got != "corrige le build" {
t.Fatalf("tâche du vérificateur : %q", got)
}
if got := convSummary(c.Messages); got != "corrige le build" {
t.Fatalf("titre : %q", got)
}
out, err := c.ExportJSON(exportOpts{})
if err != nil || strings.Contains(string(out), "context_update") {
t.Fatalf("export : %v %s", err, out)
}
multi := []Message{
{Role: "user", Content: []any{map[string]any{"type": "text", "text": blk}, map[string]any{"type": "text", "text": "vois l'image"}}},
{Role: "user", Content: []map[string]any{{"type": "text", "text": blk + "suite"}}},
}
got := stripContextUpdates(multi)
if hasContextUpdates(got) || len(got[0].Content.([]any)) != 1 || got[1].Content.([]map[string]any)[0]["text"] != "suite" {
t.Fatalf("multimodal : %+v", got)
}
if hasContextUpdates(multi) == false {
t.Fatal("l'original ne doit pas être modifié")
}
// Un texte qui en parle plus loin n'est pas touché.
plain := []Message{um("explique " + blk)}
if s := stripContextUpdates(plain); msgText(s[0]) != msgText(plain[0]) {
t.Fatal("bloc hors tête retiré")
}
// Mise à jour posée puis retirée : retour exact au texte.
for _, m0 := range []Message{um("x"), {Role: "user", Content: []any{map[string]any{"type": "text", "text": "vois"}}}} {
m1, ok := prependCtxUpdate(m0, blk)
if !ok {
t.Fatal("pose refusée")
}
m2, _ := withoutCtxUpdate(m1)
if !reflect.DeepEqual(jsonValue(t, m2), jsonValue(t, m0)) {
t.Fatalf("aller-retour : %+v", m2)
}
}
}
// Valeurs de la clé ; un preset externe n'est jamais concerné.
func TestProjSnapEnabled(t *testing.T) {
for v, want := range map[string]bool{"": false, "off": false, "0": false, "on": true, "1": true, "oui": true, " ON ": true} {
if got := projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": v}); got != want {
t.Fatalf("%q : %v", v, got)
}
}
if projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": "on", extKeyFlag: "1"}) {
t.Fatal("preset externe : jamais de bloc figé")
}
}
// replaceProjectHead garde le rappel des pages lues, après le bloc neuf.
func TestReplaceProjectHead(t *testing.T) {
msgs := []Message{
{Role: "system", Content: "base"},
{Role: "system", Content: projectContextPrefix + " — vieux"},
{Role: "system", Content: memIndexPrefix + " vieux"},
{Role: "system", Content: memReminderPrefix + " a.md"},
um("q"),
}
block := []Message{{Role: "system", Content: memIndexPrefix + " neuf"}}
out := replaceProjectHead(msgs, block)
var got []string
for _, m := range out {
got = append(got, msgText(m))
}
want := []string{"base", memIndexPrefix + " neuf", memReminderPrefix + " a.md", "q"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%v", got)
}
// Bloc vide au départ : inséré après le système commun.
out = replaceProjectHead([]Message{{Role: "system", Content: "base"}, um("q")}, block)
if msgText(out[1]) != memIndexPrefix+" neuf" || msgText(out[2]) != "q" {
t.Fatalf("%+v", out)
}
}
+3
View File
@@ -171,6 +171,9 @@ func convSummary(msgs []Message) string {
if m.Role != "user" || isLokiInjected(m) {
continue
}
// Le titre vient de ce qu'a écrit l'utilisateur, pas d'une mise à jour
// du projet posée en tête par Loki (PROJ_SNAPSHOT).
m, _ = withoutCtxUpdate(m)
s, _ := m.Content.(string)
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if s == "" {
+7
View File
@@ -127,6 +127,13 @@ func baseSystemPrompt(caps Caps, tools []Tool) string {
if caps.Agent {
b.WriteString(mcpPromptLine(tools))
}
// PROJ_SNAPSHOT (chat_projsnap.go) : le bloc projet est figé, ses changements
// arrivent en tête des messages suivants. Une ligne fixe, seulement avec la
// clé, pour qu'ils ne passent ni pour la parole de l'utilisateur ni pour
// moins récents que le bloc.
if caps.Agent && projSnapEnabled(ReadConfig()) {
b.WriteString(projSnapSystemLine)
}
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
return b.String()
}
+16 -11
View File
@@ -156,13 +156,13 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
// Même contexte que le tour de build : projet (description, index mémoire,
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
// du premier message utilisateur — tout le cache de prompt à refaire.
final := append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
// du premier message utilisateur — tout le cache de prompt à refaire. Même
// assemblage aussi (turnView) : avec PROJ_SNAPSHOT, le bloc figé de la
// discussion, et ses changements en tête de la consigne.
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, true)
runCtx := ctx
if snapTurn != nil {
runCtx = withProjSnapTurn(ctx, snapTurn)
}
var content strings.Builder
sawUsage := false
@@ -171,8 +171,7 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
// raisonnement de la réponse finale (REASONING_ECHO).
var newBase []Message
var echo *ReasoningEcho
sent, tools := prepareTurn(final, caps)
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
sawUsage = true
}
@@ -206,6 +205,9 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
if newBase != nil {
c.Messages = newBase
}
if s := snapTurn.takeRefreshed(); s != nil {
c.ProjSnap = s // même règle que generate
}
c.Messages = append(c.Messages, extra...)
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
@@ -300,7 +302,9 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
}
}
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
// lastUserText retrouve le dernier message utilisateur du fil (la tâche), sans
// la mise à jour du projet que Loki a pu poser en tête (PROJ_SNAPSHOT) : le
// vérificateur la prendrait pour une partie de la demande.
func (c *Conversation) lastUserText() string {
c.mu.Lock()
defer c.mu.Unlock()
@@ -309,7 +313,8 @@ func (c *Conversation) lastUserText() string {
if c.Messages[i].Role != "user" || isLokiInjected(c.Messages[i]) {
continue
}
if s, ok := c.Messages[i].Content.(string); ok {
m, _ := withoutCtxUpdate(c.Messages[i])
if s, ok := m.Content.(string); ok {
return s
}
}
+20 -4
View File
@@ -1130,6 +1130,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// que décrit le préambule, à l'octet près.
func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
var extra []Message
// Bloc projet figé de la discussion (PROJ_SNAPSHOT, chat_projsnap.go) : nil
// hors tour de discussion avec la clé. Masqué pour la suite du contexte —
// un sous-agent lancé par un outil a son propre fil, pas ce bloc.
snapTurn := projSnapTurnFrom(ctx)
if snapTurn != nil {
ctx = withProjSnapTurn(ctx, nil)
}
// newHistory publie l'historique réécrit en cours de tour (compaction,
// réduction). Le début du prompt change de toute façon : avec la clé, le
// bloc figé y est remplacé par le vivant et les mises à jour retirées.
newHistory := func() {
if snapTurn != nil {
messages = snapTurn.refresh(messages)
}
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
}
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
// in streaming mode: the model's <think> block (opened by the chat template)
// arrives inline in `content`, terminated by a literal </think>. When
@@ -1431,7 +1447,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// secours ne survit pas à la fin du tour et le prompt re-déborde au
// message suivant.
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
continue
}
}
@@ -1444,7 +1460,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
logCompact("réduction", overflowTokens(msg), messages, c, changed)
messages = c
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
continue
}
}
@@ -2433,7 +2449,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec
// les messages du tour et dupliquerait tout.
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
}
}
continue
@@ -2477,7 +2493,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
cb(StreamEvent{DropReasoning: true})
messages = c
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
continue
}
}
+37 -10
View File
@@ -33,20 +33,42 @@ const memIndexPrefix = "Project memory index"
// 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) {
if memMode() != MemAlways {
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 Message{}, false
return "", false
}
content := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) +
"\" — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained.\n\n" + idx
return Message{Role: "system", Content: content}, true
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),
@@ -59,14 +81,18 @@ const projectContextPrefix = "Project context"
// 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) {
slug := activeProjectSlug()
desc := strings.TrimSpace(projectDesc(slug))
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(slug) + "\". What this project is about (set by the user):\n\n" + desc
return Message{Role: "system", Content: content}, true
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
@@ -82,7 +108,8 @@ func projectContextMessage() (Message, bool) {
//
// 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.
// 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 {
+4
View File
@@ -102,6 +102,10 @@ var configTemplate = []struct{ key, help string }{
"les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " +
"plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " +
"Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"},
{"PROJ_SNAPSHOT", "on = figer le bloc projet (description, index mémoire, trackers, AGENTS.md) par discussion et livrer " +
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
+36 -11
View File
@@ -498,23 +498,48 @@ const trackerIndexPrefix = "Trackers (tracker tool)"
// la dernière valeur — SANS appeler l'outil : elle ne l'appelle que pour fouiller
// l'historique ou pour modifier. Quelques lignes, jamais le contenu.
func trackerIndexMessage() (Message, bool) {
if memMode() != MemAlways {
list, ok := trackerIndexList()
if !ok {
return Message{}, false
}
return renderTrackerIndex(list, ""), true
}
// trackerIndexList : les trackers à annoncer, ok=false hors mode mémoire
// proactif ou s'il n'y en a aucun. Séparé du rendu pour le bloc figé
// (chat_projsnap.go), qui en tire aussi son état ligne à ligne.
func trackerIndexList() ([]trackerMeta, bool) {
if memMode() != MemAlways {
return nil, false
}
list := trackerList()
if len(list) == 0 {
return Message{}, false
return list, len(list) > 0
}
// trackerIndexLine : la ligne d'un tracker, « nom — dernière valeur (date) ».
func trackerIndexLine(m trackerMeta) string {
last := ""
if m.last != nil {
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
}
return m.Name + last
}
// renderTrackerIndex : le message d'index des trackers. asOf vide = la forme
// vivante (texte d'avant PROJ_SNAPSHOT, à l'octet près). Sinon la copie figée :
// datée, et sans « réponds directement d'ici », puisque des <context_update>
// plus récents peuvent en remplacer des lignes.
func renderTrackerIndex(list []trackerMeta, asOf string) Message {
var b strings.Builder
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
for _, m := range list {
last := ""
if m.last != nil {
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
}
fmt.Fprintf(&b, "- %s%s\n", m.Name, last)
if asOf == "" {
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
} else {
b.WriteString(trackerIndexPrefix + " as of " + asOf + trackerFrozenHead)
}
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}, true
for _, m := range list {
fmt.Fprintf(&b, "- %s\n", trackerIndexLine(m))
}
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}
}
// --- outil modèle -------------------------------------------------------------