Files
Loki/internal/loki/web_sysprompt_test.go
T
MichaelandClaude Opus 5 17f8fa8f34 Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:33 +02:00

135 lines
4.5 KiB
Go

package loki
import (
"os"
"path/filepath"
"testing"
)
// writeTestPreset pose un preset minimal et renvoie son id. Le contenu doit
// différer d'un preset à l'autre : c'est l'empreinte du contenu qui décide
// lequel est « actif » (presetFingerprint).
func writeTestPreset(t *testing.T, id, model string) string {
t.Helper()
dir := presetsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := "# NAME=" + id + "\nMODEL=" + model + "\nCTX=8192\n"
if err := os.WriteFile(filepath.Join(dir, id+".env"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return id
}
// Le prompt système appartient au PRESET : basculer de modèle doit changer la
// consigne, pas la traîner d'un modèle à l'autre. C'était le défaut du prompt
// global — une consigne écrite pour un modèle à raisonnement suivait un petit
// modèle d'instruction, qu'elle dessert.
func TestSysPromptSuitLePresetActif(t *testing.T) {
newProjectHome(t)
a := writeTestPreset(t, "modele-a", "/models/a.gguf")
b := writeTestPreset(t, "modele-b", "/models/b.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if id := activePresetID(); id != a {
t.Fatalf("preset actif %q, attendu %q", id, a)
}
if err := saveSysPrompt("réponds en une phrase"); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "réponds en une phrase" {
t.Fatalf("prompt du preset A = %q", got)
}
if err := applyPresetFile(filepath.Join(presetsDir(), b+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "" {
t.Fatalf("le prompt du preset A a suivi jusqu'au preset B : %q", got)
}
if err := saveSysPrompt("tu es un assistant technique"); err != nil {
t.Fatal(err)
}
// Retour sur A : sa consigne doit être intacte.
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "réponds en une phrase" {
t.Fatalf("le prompt du preset A n'a pas survécu à l'aller-retour : %q", got)
}
}
// Un prompt multiligne doit passer tel quel : c'est le cas courant, et c'est
// précisément ce qu'un fichier .env (lu ligne à ligne) ne saurait porter — d'où
// le stockage en base.
func TestSysPromptMultiligne(t *testing.T) {
newProjectHome(t)
id := writeTestPreset(t, "modele-a", "/models/a.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), id+".env")); err != nil {
t.Fatal(err)
}
prompt := "Tu es concis.\n\nRègles :\n- pas de préambule\n- du français"
if err := saveSysPrompt(prompt); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != prompt {
t.Fatalf("prompt multiligne altéré :\n%q", got)
}
}
// Repli : une installation sans preset identifiable (config bricolée à la main)
// doit garder un champ qui fonctionne, et retrouver son ancien prompt global.
func TestSysPromptReplieSurLeGlobal(t *testing.T) {
newProjectHome(t)
if id := activePresetID(); id != "" {
t.Fatalf("preset actif %q alors qu'aucun n'existe", id)
}
if err := saveSysPrompt("consigne globale"); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "consigne globale" {
t.Fatalf("prompt global = %q", got)
}
// Un preset apparaît et devient actif : tant qu'il n'a pas SA consigne, le
// global continue de s'appliquer — pas de perte silencieuse après migration.
id := writeTestPreset(t, "modele-a", "/models/a.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), id+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "consigne globale" {
t.Fatalf("le prompt global n'est plus le repli : %q", got)
}
}
// Supprimer un preset doit emporter sa consigne : sinon elle ressuscite sur un
// preset recréé plus tard sous le même identifiant.
func TestDeletePresetEmporteSonSysPrompt(t *testing.T) {
newProjectHome(t)
a := writeTestPreset(t, "modele-a", "/models/a.gguf")
b := writeTestPreset(t, "modele-b", "/models/b.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if err := saveSysPrompt("consigne du A"); err != nil {
t.Fatal(err)
}
// On quitte A : un preset actif n'est pas supprimable.
if err := applyPresetFile(filepath.Join(presetsDir(), b+".env")); err != nil {
t.Fatal(err)
}
if err := DeletePreset(a); err != nil {
t.Fatal(err)
}
writeTestPreset(t, a, "/models/a.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "" {
t.Fatalf("la consigne d'un preset supprimé est revenue : %q", got)
}
}