Files
Loki/internal/loki/projects_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

258 lines
7.9 KiB
Go

package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// newProjectHome monte un LOKI_HOME neuf et repose les caches de projet : le slug
// actif est mémorisé en RAM (activeProjCache), donc sans ça un test hériterait du
// projet du précédent alors qu'il croit partir d'une base vierge.
func newProjectHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
setProjectOverride("")
t.Cleanup(func() {
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
setProjectOverride("")
})
return home
}
// La migration doit être SILENCIEUSE et sans perte : une installation d'avant les
// projets a ses pages à plat dans memory/, elles doivent se retrouver dans
// « Générale » — et y être INDEXÉES, ce que rien ne fait autrement (l'index n'est
// alimenté que par la création d'une page).
func TestEnsureDefaultProjectMigreLaMemoirePlate(t *testing.T) {
home := newProjectHome(t)
flat := filepath.Join(home, "memory")
if err := os.MkdirAll(flat, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(flat, "docker.md"), []byte("# Notes Docker\n\ncontenu"), 0o644); err != nil {
t.Fatal(err)
}
ensureDefaultProject()
if activeProjectSlug() != defaultProjectSlug {
t.Fatalf("projet actif %q après migration, attendu %q", activeProjectSlug(), defaultProjectSlug)
}
moved := filepath.Join(flat, defaultProjectSlug, "docker.md")
if _, err := os.Stat(moved); err != nil {
t.Fatalf("la page plate n'a pas été déplacée dans le projet : %v", err)
}
if _, err := os.Stat(filepath.Join(flat, "docker.md")); err == nil {
t.Fatal("la page est restée à la racine : elle serait invisible ET dupliquée")
}
idx := MemContent(memIndexFile)
if !strings.Contains(idx, "](docker.md)") {
t.Fatalf("page migrée absente de l'index — elle ne serait jamais proposée à l'IA :\n%s", idx)
}
if !strings.Contains(idx, "Notes Docker") {
t.Fatalf("l'index doit porter le TITRE de la page, pas seulement son nom :\n%s", idx)
}
}
// Idempotence : le démarrage passe par ensureDefaultProject à chaque fois. Un
// second passage ne doit ni redupliquer une ligne d'index, ni créer un doublon
// de projet.
func TestEnsureDefaultProjectEstIdempotent(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
if err := MemAdd("notes.md", "# Notes\n\nx"); err != nil {
t.Fatal(err)
}
before := MemContent(memIndexFile)
ensureDefaultProject()
if after := MemContent(memIndexFile); after != before {
t.Fatalf("l'index a changé au 2e passage :\navant %q\naprès %q", before, after)
}
if n := len(listProjects()); n != 1 {
t.Fatalf("%d projets après deux amorçages, attendu 1", n)
}
}
// Le cloisonnement est la promesse centrale : une page écrite dans un projet ne
// doit pas exister dans un autre.
func TestMemoireCloisonneeParProjet(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
if err := MemAdd("generale.md", "# Page de Générale\n"); err != nil {
t.Fatal(err)
}
p, err := createProject("Serveur NAS")
if err != nil {
t.Fatal(err)
}
if err := setActiveProject(p.Slug); err != nil {
t.Fatal(err)
}
if err := MemAdd("nas.md", "# Page du NAS\n"); err != nil {
t.Fatal(err)
}
names := func() []string {
var out []string
for _, m := range MemList() {
out = append(out, m.Name)
}
return out
}
got := strings.Join(names(), " ")
if strings.Contains(got, "generale.md") {
t.Fatalf("la page de Générale est visible depuis le projet NAS : %s", got)
}
if !strings.Contains(got, "nas.md") {
t.Fatalf("la page du projet NAS est absente de son propre projet : %s", got)
}
if err := setActiveProject(defaultProjectSlug); err != nil {
t.Fatal(err)
}
if got := strings.Join(names(), " "); strings.Contains(got, "nas.md") {
t.Fatalf("la page du NAS fuit dans Générale : %s", got)
}
}
// Renommer ne doit RIEN casser : le slug est l'identité, il ne bouge pas, donc
// les pages restent là où elles sont.
func TestRenameProjectGardeLeSlugEtLesPages(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
p, err := createProject("Chantier")
if err != nil {
t.Fatal(err)
}
if err := setActiveProject(p.Slug); err != nil {
t.Fatal(err)
}
if err := MemAdd("plan.md", "# Plan\n"); err != nil {
t.Fatal(err)
}
if err := renameProject(p.Slug, "Chantier rénové"); err != nil {
t.Fatal(err)
}
if projectName(p.Slug) != "Chantier rénové" {
t.Fatalf("libellé non mis à jour : %q", projectName(p.Slug))
}
if activeProjectSlug() != p.Slug {
t.Fatalf("le slug a bougé au renommage : %q", activeProjectSlug())
}
if len(MemList()) != 2 { // la page + l'index
t.Fatalf("pages perdues au renommage : %d", len(MemList()))
}
}
// Le dernier projet n'est pas supprimable : sans lui, il n'y a plus de mémoire du
// tout et aucun endroit où retomber.
func TestDeleteProjectRefuseLeDernier(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
if err := deleteProject(defaultProjectSlug); err == nil {
t.Fatal("la suppression du dernier projet a été acceptée")
}
}
// Supprimer le projet ACTIF doit rebasculer sur un autre, sinon l'application
// pointe sur un dossier qui n'existe plus.
func TestDeleteProjectActifRebascule(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
p, err := createProject("Temporaire")
if err != nil {
t.Fatal(err)
}
if err := setActiveProject(p.Slug); err != nil {
t.Fatal(err)
}
if err := deleteProject(p.Slug); err != nil {
t.Fatal(err)
}
if activeProjectSlug() == p.Slug {
t.Fatal("le projet actif est resté sur le projet supprimé")
}
if projectExists(p.Slug) {
t.Fatal("le projet supprimé figure encore au registre")
}
if _, err := os.Stat(projectMemoryDir(p.Slug)); err == nil {
t.Fatal("le dossier mémoire du projet supprimé existe encore")
}
}
// L'override sert aux tâches planifiées : il doit dévier memoryDir SANS toucher
// au projet persisté, sinon lancer une tâche changerait le projet à l'écran.
func TestProjectOverrideNeChangePasLActif(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
p, err := createProject("Veille")
if err != nil {
t.Fatal(err)
}
seen := ""
withProject(p.Slug, func() { seen = memoryDir() })
if seen != projectMemoryDir(p.Slug) {
t.Fatalf("memoryDir n'a pas suivi l'override : %q", seen)
}
if activeProjectSlug() != defaultProjectSlug {
t.Fatalf("l'override a changé le projet actif : %q", activeProjectSlug())
}
if memoryDir() != projectMemoryDir(defaultProjectSlug) {
t.Fatal("memoryDir est resté sur le projet de l'override après sa libération")
}
}
// slugifyIdent porte l'identité d'un projet ET d'un tracker : les accents doivent
// être translittérés, pas jetés — « Réseau » qui devient « r-seau » est illisible
// dans un chemin de fichier.
func TestSlugifyIdent(t *testing.T) {
cases := map[string]string{
"Serveur NAS": "serveur-nas",
"Réseau & Wifi": "reseau-wifi",
" Déjà vu ": "deja-vu",
"Poids (kg)": "poids-kg",
"": "",
}
for in, want := range cases {
if got := slugifyIdent(in); got != want {
t.Errorf("slugifyIdent(%q) = %q, attendu %q", in, got, want)
}
}
}
// Une description non vide doit atteindre l'IA ; une description vide ne doit pas
// produire un message de contexte creux.
func TestProjectContextMessage(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
if _, ok := projectContextMessage(); ok {
t.Fatal("un message de contexte est produit alors qu'aucune description n'existe")
}
if err := setProjectDesc(defaultProjectSlug, "administration du NAS Unraid"); err != nil {
t.Fatal(err)
}
m, ok := projectContextMessage()
if !ok {
t.Fatal("aucun message de contexte malgré une description")
}
s, _ := m.Content.(string)
if !strings.HasPrefix(s, projectContextPrefix) {
t.Fatalf("préfixe manquant, le message ne serait pas reconnaissable : %q", s)
}
if !strings.Contains(s, "NAS Unraid") {
t.Fatalf("la description n'est pas dans le message : %q", s)
}
}