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

+19
View File
@@ -368,6 +368,25 @@ Ajoutées par ce fork :
vers une API externe ni vers un autre modèle ; un prompt trop long est d'abord
rejoué sans raisonnement, et un gabarit qui le refuse le suspend pour ce
modèle jusqu'au redémarrage.
- **Bloc projet figé** (clé `PROJ_SNAPSHOT`, **off** par défaut,
`loki config set PROJ_SNAPSHOT on`) : le contexte du projet (description,
index mémoire, trackers, `AGENTS.md`) part en tête du premier message ; sans
la clé il est reconstruit à chaque tour, et une page créée ou une valeur de
tracker notée fait recalculer toute la conversation derrière lui. Avec la clé,
chaque discussion garde une copie datée du bloc, envoyée à l'identique, et les
changements arrivent en tête du message suivant dans un bloc
`<context_update>` : pages ajoutées, modifiées, retirées, nouvelle ligne d'un
tracker, et le texte **complet** d'une description ou d'un `AGENTS.md` modifié
— rien n'est perdu, seul ce petit bloc est à calculer. Une ligne du prompt
système (présente seulement avec la clé) dit au modèle que ces blocs viennent
de Loki et que le plus récent l'emporte. Le bloc est repris tout neuf, et les
anciens `<context_update>` retirés, quand le début du prompt change de toute
façon : compaction, système ou outils modifiés (date, réglages), redémarrage de
Loki, changement de modèle, de preset, de projet, de mode mémoire ou de mode
Code — et dès que les mises à jour accumulées deviennent trop longues. Les
tâches planifiées gardent le bloc à jour à chaque tour ; sans agent, rien du
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
passe de vérification ne voient pas ces blocs.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
+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 -------------------------------------------------------------