diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go
index 06b8bcc..9e53f48 100644
--- a/internal/loki/llm_client.go
+++ b/internal/loki/llm_client.go
@@ -244,7 +244,10 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
return msgs
}
prefix := strings.Join(parts, "\n\n")
- if len(msgs) > 0 && msgs[0].Role == "system" {
+ // Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le
+ // préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait
+ // plus le sortir du bloc système commun (voir isProjectSystem).
+ if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
existing, _ := msgs[0].Content.(string)
merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
return merged
@@ -287,15 +290,29 @@ func steerSystem(msgs []Message, hint string) []Message {
// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc
// sans risque pour tous, et sans effet sur une conversation déjà normale (payload
// identique, garanti par test).
+//
+// Ce qui dépend du PROJET (description, index mémoire, trackers) ne va PAS dans
+// ce bloc mais en tête du premier message utilisateur (AJEAN 0.15.8). Sur un
+// modèle hybride (Qwen3.5+, couches récurrentes), llama.cpp ne sait reprendre un
+// prompt qu'à un point de sauvegarde, et il n'en pose qu'au DÉBUT des messages
+// utilisateur. Laissé dans le système, le contexte projet faisait diverger le
+// prompt au milieu du bloc : changer de projet recalculait tout. Au début du 1er
+// message user, la divergence tombe pile sur un point de sauvegarde, après le
+// système commun, qui reste en cache.
func normalizeSystemMessages(msgs []Message) []Message {
- var sys []string
+ var sys, proj []string
sawSystem := false
rest := make([]Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == "system" {
if s, ok := m.Content.(string); ok {
sawSystem = true
- if strings.TrimSpace(s) != "" {
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+ if isProjectSystem(m) {
+ proj = append(proj, s)
+ } else {
sys = append(sys, s)
}
continue
@@ -310,6 +327,12 @@ func normalizeSystemMessages(msgs []Message) []Message {
if !sawSystem {
return msgs
}
+ if len(proj) > 0 {
+ if !prependToFirstUser(rest, strings.Join(proj, "\n\n")) {
+ // Pas encore de message user (cas théorique) : on les garde en système.
+ sys = append(sys, proj...)
+ }
+ }
// Des systèmes existaient mais tous vides : on les a retirés (un système vide
// hors position 0 casserait tout autant), sans en réinsérer.
if len(sys) == 0 {
@@ -320,6 +343,44 @@ func normalizeSystemMessages(msgs []Message) []Message {
return append(out, rest...)
}
+// isProjectSystem : message système propre au projet actif (description, index
+// mémoire, trackers), à sortir du bloc système commun (voir
+// normalizeSystemMessages).
+func isProjectSystem(m Message) bool {
+ s, ok := m.Content.(string)
+ if !ok || m.Role != "system" {
+ return false
+ }
+ return strings.HasPrefix(s, projectContextPrefix) ||
+ strings.HasPrefix(s, memIndexPrefix) ||
+ strings.HasPrefix(s, trackerIndexPrefix)
+}
+
+// prependToFirstUser place ctx en tête du premier message user de msgs (modifié
+// en place : msgs est déjà une copie, mais ses Content ne sont PAS recopiés —
+// on remplace donc la valeur, jamais on ne mute une tranche partagée).
+// Renvoie false s'il n'y a aucun message user.
+func prependToFirstUser(msgs []Message, ctx string) bool {
+ block := "\n" + ctx + "\n\n\n"
+ for i, m := range msgs {
+ if m.Role != "user" {
+ continue
+ }
+ switch c := m.Content.(type) {
+ case string:
+ msgs[i].Content = block + c
+ case []any:
+ msgs[i].Content = append([]any{map[string]any{"type": "text", "text": block}}, c...)
+ case []map[string]any:
+ msgs[i].Content = append([]map[string]any{{"type": "text", "text": block}}, c...)
+ default:
+ return false
+ }
+ return true
+ }
+ return false
+}
+
// EnabledTools returns the tools to advertise on the next inference call.
func EnabledTools(caps Caps) []Tool {
tools := []Tool{}
diff --git a/internal/loki/normalize_project_test.go b/internal/loki/normalize_project_test.go
new file mode 100644
index 0000000..41b24e4
--- /dev/null
+++ b/internal/loki/normalize_project_test.go
@@ -0,0 +1,53 @@
+package loki
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// Le contexte PROJET quitte le bloc système pour la tête du 1er message user :
+// le système commun reste identique d'un projet à l'autre (cache de prompt), et
+// l'historique d'origine n'est pas muté.
+func TestNormalizeMovesProjectContextToFirstUser(t *testing.T) {
+ in := []Message{
+ {Role: "system", Content: "Préambule commun"},
+ {Role: "system", Content: projectContextPrefix + " — projet A"},
+ {Role: "system", Content: memIndexPrefix + " for project A"},
+ {Role: "user", Content: "bonjour"},
+ {Role: "assistant", Content: "salut"},
+ {Role: "user", Content: "suite"},
+ }
+ ref := append([]Message(nil), in...)
+ out := normalizeSystemMessages(in)
+ if len(out) != 4 || out[0].Content != "Préambule commun" {
+ t.Fatalf("système commun attendu seul en tête : %#v", out)
+ }
+ first, _ := out[1].Content.(string)
+ if !strings.HasPrefix(first, "\n"+projectContextPrefix) ||
+ !strings.Contains(first, memIndexPrefix) || !strings.HasSuffix(first, "bonjour") {
+ t.Fatalf("contexte projet absent du 1er message user : %q", first)
+ }
+ if out[3].Content != "suite" {
+ t.Fatal("seul le PREMIER message user reçoit le contexte")
+ }
+ if !reflect.DeepEqual(in, ref) {
+ t.Fatal("l'entrée a été mutée (interdit)")
+ }
+}
+
+// Sans prompt de preset, le premier système est un message projet : le préambule
+// ne doit pas l'absorber (il perdrait son préfixe et resterait dans le système).
+func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) {
+ in := []Message{
+ {Role: "system", Content: projectContextPrefix + " — projet A"},
+ {Role: "user", Content: "bonjour"},
+ }
+ out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}))
+ if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) {
+ t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0])
+ }
+ if u, _ := out[1].Content.(string); !strings.Contains(u, projectContextPrefix) {
+ t.Fatalf("contexte projet attendu dans le 1er message user : %#v", out[1])
+ }
+}