diff --git a/internal/loki/chat_memory.go b/internal/loki/chat_memory.go index 61ac290..30b2d0c 100644 --- a/internal/loki/chat_memory.go +++ b/internal/loki/chat_memory.go @@ -377,47 +377,69 @@ func MemDelete(name string) error { } // MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du -// mode agent (shell). Trois modes : -// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne). -// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si +// mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes : +// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne). +// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si // l'utilisateur le demande explicitement (pas de recherche/écriture spontanée). -// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même). +// - MemAlways : proactif, INDEX INJECTÉ — l'index MEMORY.md (titres + +// accroches) est en tête de conversation, l'IA lit directement la bonne page. +// - MemSearchFirst : proactif, RECHERCHE D'ABORD — rien n'est injecté, l'IA +// appelle mem_search avant toute tâche. Prompt léger, démarrage plus rapide. type MemMode string const ( - MemOff MemMode = "off" - MemOnDemand MemMode = "ondemand" - MemAlways MemMode = "always" + MemOff MemMode = "off" + MemOnDemand MemMode = "ondemand" + MemAlways MemMode = "always" + MemSearchFirst MemMode = "search" ) -// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement -// actuel). Toute valeur inconnue retombe sur always. -func memMode() MemMode { - switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) { +// memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le +// demande). Vrai pour les deux modes auto (injecté ou recherche). +func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst } + +// normalizeMemMode ramène une valeur de config à un mode connu. Défaut = always +// (préserve le comportement historique). "auto" est un alias d'always. +func normalizeMemMode(v string) MemMode { + switch strings.ToLower(strings.TrimSpace(v)) { case "off": return MemOff case "ondemand": return MemOnDemand + case "search": + return MemSearchFirst default: // "always", "auto", "" et inconnus return MemAlways } } -// setMemMode persiste le mode mémoire dans config.env. -func setMemMode(m MemMode) error { - return SetConfigKey("MEM_MODE", string(m)) +// memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un +// projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage +// global MEM_MODE de config.env — always par défaut. Rien ne change donc pour +// les projets existants tant qu'on ne touche pas à leur mode. +func memMode() MemMode { + if pm := projectMemMode(activeProjectSlug()); pm != "" { + return normalizeMemMode(pm) + } + return normalizeMemMode(ReadConfig()["MEM_MODE"]) } -// cmdMemory : loki memory [off|ondemand|always|status] +// setMemMode persiste le mode mémoire du PROJET ACTIF. +func setMemMode(m MemMode) error { + return setProjectMemMode(activeProjectSlug(), string(m)) +} + +// cmdMemory : loki memory [off|ondemand|always|search|status] func cmdMemory(args []string) error { sub := "" if len(args) > 0 { sub = strings.ToLower(strings.TrimSpace(args[0])) } label := map[MemMode]string{ - MemOff: "désactivée (l'IA n'a aucun accès mémoire)", - MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)", - MemAlways: "auto (l'IA cherche et sauve d'elle-même)", + MemOff: "désactivée (l'IA n'a aucun accès mémoire)", + MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)", + MemAlways: "auto/injectée (index en tête de conversation, l'IA lit directement la bonne page)", + MemSearchFirst: "auto/recherche (rien d'injecté, l'IA cherche avant toute tâche — prompt léger)", } switch sub { case "off": @@ -428,20 +450,24 @@ func cmdMemory(args []string) error { if err := setMemMode(MemOnDemand); err != nil { return err } - case "always": + case "always", "auto", "inject", "injected": if err := setMemMode(MemAlways); err != nil { return err } + case "search", "recherche", "lazy": + if err := setMemMode(MemSearchFirst); err != nil { + return err + } case "", "status": m := memMode() - fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m]) + fmt.Printf("%s projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m]) pages := MemList() fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir()) return nil default: - return fmt.Errorf("usage: loki memory [off|ondemand|always|status]") + return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]") } m := memMode() - fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m]) + fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m]) return nil } diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index b4faf09..c580f5c 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -56,7 +56,7 @@ func baseSystemPrompt(caps Caps) string { b.WriteString("\n\n" + rp) } } - if caps.Mem == MemAlways { + if memProactive(caps.Mem) { b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.") } // PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête, @@ -76,7 +76,15 @@ func baseSystemPrompt(caps Caps) string { case MemAlways: b.WriteString("\nManaging your memory is part of the job, not optional:\n") b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n") - b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n") + // Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la + // recherche devient facultative — l'IA lit directement la bonne page. + b.WriteString("- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n") + case MemSearchFirst: + // Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher + // elle-même, sinon elle ignore ce que sa mémoire contient. + b.WriteString("\nManaging your memory is part of the job, not optional:\n") + b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n") + b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n") case MemOnDemand: b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n") } @@ -96,7 +104,7 @@ func baseSystemPrompt(caps Caps) string { // disent pas. Une ligne, pas trois : le budget du préambule est compté // (TestSystemPromptStaysLean). b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n") - if caps.Mem == MemAlways { + if caps.Mem == MemSearchFirst { b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n") } } diff --git a/internal/loki/mem_mode_project_test.go b/internal/loki/mem_mode_project_test.go new file mode 100644 index 0000000..1ff1938 --- /dev/null +++ b/internal/loki/mem_mode_project_test.go @@ -0,0 +1,38 @@ +package loki + +import "testing" + +// Chaque projet garde son propre mode mémoire ; un projet sans réglage retombe +// sur l'ancien global MEM_MODE. +func TestMemModePerProject(t *testing.T) { + testHome(t) + ensureDefaultProject() + if err := SetConfigKey("MEM_MODE", "ondemand"); err != nil { + t.Fatal(err) + } + if memMode() != MemOnDemand { + t.Fatalf("sans réglage propre, repli global attendu, got %q", memMode()) + } + b, err := createProject("Chantier") + if err != nil { + t.Fatal(err) + } + if err := setActiveProject(b.Slug); err != nil { + t.Fatal(err) + } + if err := setMemMode(MemSearchFirst); err != nil { + t.Fatal(err) + } + if memMode() != MemSearchFirst { + t.Fatalf("mode du projet actif attendu, got %q", memMode()) + } + if err := setActiveProject(defaultProjectSlug); err != nil { + t.Fatal(err) + } + if memMode() != MemOnDemand { + t.Fatalf("l'autre projet ne doit pas hériter du réglage, got %q", memMode()) + } + if setProjectMemMode(b.Slug, "n'importe quoi") == nil { + t.Fatal("un mode inconnu doit être refusé") + } +} diff --git a/internal/loki/projects.go b/internal/loki/projects.go index 98ad61a..c92a375 100644 --- a/internal/loki/projects.go +++ b/internal/loki/projects.go @@ -39,6 +39,9 @@ type Project struct { // de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi // elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion. Desc string `json:"desc,omitempty"` + // MemMode = mode mémoire PROPRE à ce projet (off/ondemand/always/search). + // Vide = pas encore réglé → repli sur l'ancien global MEM_MODE (voir memMode). + MemMode string `json:"mem_mode,omitempty"` } const ( @@ -186,6 +189,36 @@ func projectDesc(slug string) string { // setProjectDesc enregistre la description d'un projet, tronquée à une taille // raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des // charges. Une chaîne vide efface la description. +// projectMemMode renvoie le mode mémoire propre à un projet ("" si non réglé, ce +// qui déclenche le repli global dans memMode). +func projectMemMode(slug string) string { + for _, p := range listProjects() { + if p.Slug == slug { + return p.MemMode + } + } + return "" +} + +// setProjectMemMode enregistre le mode mémoire d'un projet. Une valeur vide +// efface le réglage propre (retour au repli global). +func setProjectMemMode(slug, mode string) error { + mode = strings.ToLower(strings.TrimSpace(mode)) + switch mode { + case "", "off", "ondemand", "always", "search": + default: + return fmt.Errorf("mode mémoire invalide: %s", mode) + } + list := listProjects() + for i := range list { + if list[i].Slug == slug { + list[i].MemMode = mode + return saveProjects(list) + } + } + return fmt.Errorf("projet introuvable") +} + func setProjectDesc(slug, desc string) error { desc = strings.TrimSpace(desc) const max = 2000 diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index e418c67..1adec20 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2584,9 +2584,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Mémoire?
-