From 4932999d5c6c840fb48e9385744e9c4c70d9d627 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 08:44:46 +0200 Subject: [PATCH] =?UTF-8?q?Contexte=20:=20PROJ=5FSNAPSHOT=20fige=20le=20bl?= =?UTF-8?q?oc=20projet=20par=20discussion,=20ses=20changements=20arrivent?= =?UTF-8?q?=20en=20mise=20=C3=A0=20jour,=20en=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 » 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 --- README.md | 19 + internal/loki/chat_compact.go | 4 + internal/loki/chat_conversation.go | 43 +- internal/loki/chat_export.go | 6 +- internal/loki/chat_projsnap.go | 672 ++++++++++++++++++++++++++++ internal/loki/chat_projsnap_test.go | 590 ++++++++++++++++++++++++ internal/loki/chat_sessions.go | 3 + internal/loki/chat_tools.go | 7 + internal/loki/code_verify.go | 27 +- internal/loki/llm_client.go | 24 +- internal/loki/mem_index.go | 47 +- internal/loki/sys_service.go | 4 + internal/loki/tracker.go | 47 +- 13 files changed, 1444 insertions(+), 49 deletions(-) create mode 100644 internal/loki/chat_projsnap.go create mode 100644 internal/loki/chat_projsnap_test.go diff --git a/README.md b/README.md index d49cef5..ba9b020 100644 --- a/README.md +++ b/README.md @@ -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 + `` : 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 `` 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//`) : les pièces jointes diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index d4358a1..dbbfcf4 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -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) { diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index b5a942f..c98972f 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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 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 diff --git a/internal/loki/chat_export.go b/internal/loki/chat_export.go index 70444d3..306fbf7 100644 --- a/internal/loki/chat_export.go +++ b/internal/loki/chat_export.go @@ -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, "", " ") } diff --git a/internal/loki/chat_projsnap.go b/internal/loki/chat_projsnap.go new file mode 100644 index 0000000..36e5041 --- /dev/null +++ b/internal/loki/chat_projsnap.go @@ -0,0 +1,672 @@ +package loki + +// chat_projsnap.go — PROJ_SNAPSHOT : le bloc projet FIGÉ par discussion, et ses +// changements livrés en 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 » et que +// les 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 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 blocks from Loki update it." + trackerFrozenHead = " — your trackers, each with its latest point at that time; later 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 = ` block at the start of a user message is written by Loki, not by the user: it updates , 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, "") + 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 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 . 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 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 . +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 +} diff --git a/internal/loki/chat_projsnap_test.go b/internal/loki/chat_projsnap_test.go new file mode 100644 index 0000000..5fd8186 --- /dev/null +++ b/internal/loki/chat_projsnap_test.go @@ -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, "")] } + 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) + } +} diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index a75c597..a1ab0e7 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -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 == "" { diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index 904b095..331861a 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -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() } diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index 62dd2ad..6441244 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -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 } } diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index bc5fad7..f5ab273 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -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 block (opened by the chat template) // arrives inline in `content`, terminated by a literal . 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 } } diff --git a/internal/loki/mem_index.go b/internal/loki/mem_index.go index f8ea4bc..3f4e032 100644 --- a/internal/loki/mem_index.go +++ b/internal/loki/mem_index.go @@ -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 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 . func projectSystemMessages() []Message { var out []Message if m, ok := projectContextMessage(); ok { diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 9914c99..d10545e 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -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 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"}, diff --git a/internal/loki/tracker.go b/internal/loki/tracker.go index 3617d74..3911d44 100644 --- a/internal/loki/tracker.go +++ b/internal/loki/tracker.go @@ -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 +// 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 -------------------------------------------------------------