Mémoire : un mode par projet, et un quatrième — « recherche »

Repris d'AJEAN 0.13.12.

PAR PROJET
Le mode mémoire était un réglage global de config.env. Il vit désormais
sur le projet (champ mem_mode) : un chantier de code peut couper la
mémoire pendant qu'un projet perso la garde proactive. Un projet sans
réglage propre — tous ceux d'avant — retombe sur l'ancien MEM_MODE global :
rien ne change tant qu'on n'y touche pas. L'API /api/memory et `loki
memory` agissent sur le projet actif ; l'UI le dit, et se recharge déjà au
changement de projet.

AUTO, EN DEUX SAVEURS
- « auto, injectée » (always, le défaut) : l'index des pages est en tête
  de conversation. La consigne dit maintenant d'y lire directement la
  bonne page ; mem_search ne sert plus qu'à chercher par contenu. Avant,
  on injectait l'index ET on exigeait une recherche préalable — un appel
  d'outil par tour pour retrouver ce que le modèle avait sous les yeux.
- « auto, recherche » (nouveau) : rien d'injecté, l'IA cherche avant
  chaque tâche. Contexte plus léger, démarrage plus rapide.
memProactive regroupe les deux là où seul « proactif » compte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:05:08 +02:00
1 parent 4a1054c1b7
commit 2c36fa6d2c
8 files changed
+144 -43

No files matched your search

+48 -22
View File
@@ -377,47 +377,69 @@ func MemDelete(name string) error {
} }
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du // MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// mode agent (shell). Trois modes : // mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne). // - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si // - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée). // 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 type MemMode string
const ( const (
MemOff MemMode = "off" MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand" MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always" MemAlways MemMode = "always"
MemSearchFirst MemMode = "search"
) )
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement // memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le
// actuel). Toute valeur inconnue retombe sur always. // demande). Vrai pour les deux modes auto (injecté ou recherche).
func memMode() MemMode { func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst }
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
// 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": case "off":
return MemOff return MemOff
case "ondemand": case "ondemand":
return MemOnDemand return MemOnDemand
case "search":
return MemSearchFirst
default: // "always", "auto", "" et inconnus default: // "always", "auto", "" et inconnus
return MemAlways return MemAlways
} }
} }
// setMemMode persiste le mode mémoire dans config.env. // memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un
func setMemMode(m MemMode) error { // projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage
return SetConfigKey("MEM_MODE", string(m)) // 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 { func cmdMemory(args []string) error {
sub := "" sub := ""
if len(args) > 0 { if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0])) sub = strings.ToLower(strings.TrimSpace(args[0]))
} }
label := map[MemMode]string{ label := map[MemMode]string{
MemOff: "désactivée (l'IA n'a aucun accès mémoire)", 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)", MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
MemAlways: "auto (l'IA cherche et sauve d'elle-même)", 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 { switch sub {
case "off": case "off":
@@ -428,20 +450,24 @@ func cmdMemory(args []string) error {
if err := setMemMode(MemOnDemand); err != nil { if err := setMemMode(MemOnDemand); err != nil {
return err return err
} }
case "always": case "always", "auto", "inject", "injected":
if err := setMemMode(MemAlways); err != nil { if err := setMemMode(MemAlways); err != nil {
return err return err
} }
case "search", "recherche", "lazy":
if err := setMemMode(MemSearchFirst); err != nil {
return err
}
case "", "status": case "", "status":
m := memMode() 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() pages := MemList()
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir()) fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
return nil return nil
default: default:
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]") return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]")
} }
m := memMode() 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 return nil
} }
+11 -3
View File
@@ -56,7 +56,7 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("\n\n" + rp) 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.") 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, // 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: case MemAlways:
b.WriteString("\nManaging your memory is part of the job, not optional:\n") 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("- 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: 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") 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é // disent pas. Une ligne, pas trois : le budget du préambule est compté
// (TestSystemPromptStaysLean). // (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") 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") 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")
} }
} }
+38
View File
@@ -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é")
}
}
+33
View File
@@ -39,6 +39,9 @@ type Project struct {
// de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi // de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi
// elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion. // elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion.
Desc string `json:"desc,omitempty"` 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 ( const (
@@ -186,6 +189,36 @@ func projectDesc(slug string) string {
// setProjectDesc enregistre la description d'un projet, tronquée à une taille // setProjectDesc enregistre la description d'un projet, tronquée à une taille
// raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des // raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des
// charges. Une chaîne vide efface la description. // 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 { func setProjectDesc(slug, desc string) error {
desc = strings.TrimSpace(desc) desc = strings.TrimSpace(desc)
const max = 2000 const max = 2000
+6 -4
View File
@@ -2584,9 +2584,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="mem-block" class="gated-group"> <div id="mem-block" class="gated-group">
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div> <div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
<div class="slist"> <div class="slist">
<label class="switchrow"><span>mode</span> <label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
<select id="mem-mode" onchange="setMemMode()" class="sctl"> <select id="mem-mode" onchange="setMemMode()" class="sctl">
<option value="always">auto</option> <option value="always">auto, injectée</option>
<option value="search">auto, recherche</option>
<option value="ondemand">sur demande</option> <option value="ondemand">sur demande</option>
<option value="off">désactivée</option> <option value="off">désactivée</option>
</select> </select>
@@ -4542,9 +4543,10 @@ async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked; const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on}); await jpost('/api/agent/compact',{on});
} }
// Mode mémoire (3 états) — indépendant du mode agent. // Mode mémoire (4 états), réglé PAR PROJET — chaque projet garde le sien.
const MEM_DESC={ const MEM_DESC={
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.', always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).', ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.' off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
}; };
+3 -2
View File
@@ -376,9 +376,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="mem-block" class="gated-group"> <div id="mem-block" class="gated-group">
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div> <div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
<div class="slist"> <div class="slist">
<label class="switchrow"><span>mode</span> <label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
<select id="mem-mode" onchange="setMemMode()" class="sctl"> <select id="mem-mode" onchange="setMemMode()" class="sctl">
<option value="always">auto</option> <option value="always">auto, injectée</option>
<option value="search">auto, recherche</option>
<option value="ondemand">sur demande</option> <option value="ondemand">sur demande</option>
<option value="off">désactivée</option> <option value="off">désactivée</option>
</select> </select>
+3 -2
View File
@@ -310,9 +310,10 @@ async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked; const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on}); await jpost('/api/agent/compact',{on});
} }
// Mode mémoire (3 états) — indépendant du mode agent. // Mode mémoire (4 états), réglé PAR PROJET — chaque projet garde le sien.
const MEM_DESC={ const MEM_DESC={
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.', always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).', ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.' off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
}; };
+2 -10
View File
@@ -549,16 +549,8 @@ func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
// On normalise via memMode() en réinjectant la valeur : toute entrée // On normalise via memMode() en réinjectant la valeur : toute entrée
// inconnue retombe sur "always", donc on valide en passant par le parseur. // inconnue retombe sur "always", donc on valide en passant par le parseur.
m := MemAlways // Réglage du PROJET ACTIF (AJEAN 0.13.12).
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) { if err := setMemMode(normalizeMemMode(req.Mode)); err != nil {
case MemOff:
m = MemOff
case MemOnDemand:
m = MemOnDemand
case MemAlways:
m = MemAlways
}
if err := setMemMode(m); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return return
} }