package loki import ( "errors" "fmt" "math" "os" "path/filepath" "regexp" "sort" "strings" ) // La mémoire de loki = des fichiers Markdown plats sous memory/.md. // L'IA y range ce qu'elle veut retenir entre les sessions (préférences, // décisions, procédures, infos projet). Quatre outils : mem_search, mem_read, // mem_add, mem_edit. type MemPage struct { Name string // nom de fichier sans le dossier (ex: "docker-notes.md") Title string // 1re ligne non vide, sans les # } type MemHit struct { File string Title string Snippet string } var memNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // memFileName normalise un nom de page : ajoute .md si absent et valide. func memFileName(name string) (string, error) { name = strings.TrimSpace(name) if name == "" { return "", fmt.Errorf("nom vide") } if !strings.HasSuffix(strings.ToLower(name), ".md") { name += ".md" } if !memNameRe.MatchString(name) { return "", fmt.Errorf("nom invalide (alphanum, ._-)") } return name, nil } // safeMemPath valide et renvoie le chemin disque d'une page mémoire. func safeMemPath(name string) (string, error) { fn, err := memFileName(name) if err != nil { return "", err } root, err := filepath.Abs(memoryDir()) if err != nil { return "", err } abs, err := filepath.Abs(filepath.Join(root, fn)) if err != nil { return "", err } if !strings.HasPrefix(abs, root+string(filepath.Separator)) { return "", fmt.Errorf("path invalide") } return abs, nil } // titleOf renvoie la 1re ligne non vide d'un contenu, sans les # de tête. func titleOf(content string) string { for _, line := range strings.Split(content, "\n") { s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#")) if s != "" { return s } } return "" } // MemList liste les pages mémoire (nom + titre), triées par nom. func MemList() []MemPage { entries, err := os.ReadDir(memoryDir()) if err != nil { return nil } out := []MemPage{} for _, e := range entries { if e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") { continue } // Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle // existe, la cacher ferait croire à une perte) avec un titre qui dit // pourquoi son contenu n'apparaît pas. txt, ok := memPageText(e.Name()) title := titleOf(txt) if !ok { title = "🔒 (chiffré — mémoire verrouillée)" } out = append(out, MemPage{Name: e.Name(), Title: title}) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // MemSearch cherche les termes de la requête dans le nom + le contenu de chaque // page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de // recherche : à compléter par mem_read sur la page la plus pertinente. // // Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot // fréquent écraser une petite page qui matche VRAIMENT la requête : // 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts // de la requête passent devant (chercher « copine Nathan » doit remonter la // page qui a les deux mots, pas celle qui répète « Nathan » 40 fois). // 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot // rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent // (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne // pas, et un match dans le NOM ou le TITRE est fortement bonifié. func MemSearch(query string, limit int) []MemHit { if limit <= 0 || limit > 30 { limit = 8 } terms := uniqueTerms(strings.ToLower(query)) type doc struct { p MemPage name string // minuscules title string // minuscules content string // brut (pour l'extrait) hay string // minuscules : nom + contenu } docs := make([]doc, 0) df := make(map[string]int) // nb de pages contenant chaque terme for _, p := range MemList() { content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul d := doc{ p: p, name: strings.ToLower(p.Name), title: strings.ToLower(p.Title), content: content, hay: strings.ToLower(p.Name + "\n" + content), } docs = append(docs, d) for _, t := range terms { if strings.Contains(d.hay, t) { df[t]++ } } } n := len(docs) type scored struct { hit MemHit matched int // termes distincts trouvés (clé de tri primaire) score float64 // pertinence pondérée (clé secondaire) } var ranked []scored for _, d := range docs { matched := 0 score := 0.0 for _, t := range terms { cnt := strings.Count(d.hay, t) if cnt == 0 { continue } matched++ // IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore. idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1 // Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence. tf := 1.0 + math.Log(float64(cnt)) field := 1.0 if strings.Contains(d.name, t) { field += 4 // le terme est dans le NOM de la page } if strings.Contains(d.title, t) { field += 2 // ... ou dans son TITRE } score += idf * tf * field } if matched == 0 && len(terms) > 0 { continue } ranked = append(ranked, scored{ hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)}, matched: matched, score: score, }) } sort.SliceStable(ranked, func(i, j int) bool { if ranked[i].matched != ranked[j].matched { return ranked[i].matched > ranked[j].matched // couverture d'abord } return ranked[i].score > ranked[j].score }) out := []MemHit{} for i, r := range ranked { if i >= limit { break } out = append(out, r.hit) } return out } // uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé). func uniqueTerms(q string) []string { seen := map[string]bool{} var out []string for _, t := range strings.Fields(q) { if !seen[t] { seen[t] = true out = append(out, t) } } return out } // snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme. func snippetAround(content string, terms []string) string { flat := strings.Join(strings.Fields(content), " ") low := strings.ToLower(flat) idx := -1 for _, t := range terms { if i := strings.Index(low, t); i >= 0 && (idx < 0 || i < idx) { idx = i } } if idx < 0 { if len(flat) > 160 { return flat[:160] + "…" } return flat } start := idx - 60 if start < 0 { start = 0 } end := idx + 100 if end > len(flat) { end = len(flat) } s := flat[start:end] if start > 0 { s = "…" + s } if end < len(flat) { s += "…" } return s } // MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du // numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500). func MemRead(name string, offset, limit int) (string, error) { b, err := memReadPage(name) if err != nil { if errors.Is(err, errMemLocked) { return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name) } return "", fmt.Errorf("page '%s' introuvable", name) } lines := strings.Split(string(b), "\n") if offset <= 0 { offset = 1 } if limit <= 0 || limit > 500 { limit = 500 } var out strings.Builder for i := offset - 1; i < len(lines) && i < offset-1+limit; i++ { fmt.Fprintf(&out, "%d\t%s\n", i+1, lines[i]) } return strings.TrimRight(out.String(), "\n"), nil } // MemAdd crée une nouvelle page mémoire. Refuse d'écraser une page existante // (utiliser mem_edit pour modifier). func MemAdd(name, content string) error { p, err := safeMemPath(name) if err != nil { return err } if _, err := os.Stat(p); err == nil { return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier") } body := strings.TrimRight(content, "\n") + "\n" if err := writeMemFile(name, []byte(body)); err != nil { return err } // L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y // apparaît toujours, même si le modèle oublie de l'y écrire. memIndexAdd(name) return nil } // MemEdit remplace oldText par newText dans une page. oldText doit apparaître // EXACTEMENT une fois (sinon erreur), pour une édition sans ambiguïté. // errAlreadyApplied signale une édition dont le résultat est DÉJÀ en place : // ce n'est pas un échec, la page est dans l'état demandé. var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification") func MemEdit(name, oldText, newText string) error { b, err := memReadPage(name) if err != nil { if errors.Is(err, errMemLocked) { return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name) } return fmt.Errorf("page '%s' introuvable", name) } content := string(b) n := strings.Count(content, oldText) if oldText == "" { return fmt.Errorf("old vide") } if n == 0 { // Déjà remplacé (le modèle rejoue souvent la même édition) : ce n'est pas // une erreur, la page est dans l'état demandé. if newText != "" && strings.Contains(content, newText) { return errAlreadyApplied } return fmt.Errorf("old introuvable dans la page") } if n > 1 { return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n) } updated := strings.Replace(content, oldText, newText, 1) return writeMemFile(name, []byte(updated)) } // MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par // l'éditeur web. func MemContent(name string) string { b, err := memReadPage(name) if err != nil { return "" } return string(b) } // MemSave écrit (crée ou écrase) une page mémoire. Si old est fourni et diffère // du nouveau nom, l'ancienne page est renommée (supprimée). Utilisé par l'éditeur // web. func MemSave(name, old, content string) error { if old != "" { if oldFn, e1 := memFileName(old); e1 == nil { if newFn, e2 := memFileName(name); e2 == nil && oldFn != newFn { if od, e3 := safeMemPath(old); e3 == nil { _ = os.Remove(od) } } } } body := strings.TrimRight(content, "\n") + "\n" if err := writeMemFile(name, []byte(body)); err != nil { return err } // Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée, // nouvelle ajoutée). Une simple sauvegarde n'ajoute que si la ligne manquait. memIndexRename(old, name) return nil } // MemDelete supprime une page mémoire. func MemDelete(name string) error { p, err := safeMemPath(name) if err != nil { return err } if _, err := os.Stat(p); err != nil { return fmt.Errorf("introuvable") } if err := os.Remove(p); err != nil { return err } memIndexRemove(name) return nil } // MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du // 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 : 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" MemSearchFirst MemMode = "search" ) // 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 } } // 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"]) } // 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/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": if err := setMemMode(MemOff); err != nil { return err } case "ondemand", "on-demand", "demand", "manual", "manuel": if err := setMemMode(MemOnDemand); err != nil { return err } 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 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|search|status]") } m := memMode() fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m]) return nil }