mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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>
474 lines
14 KiB
Go
474 lines
14 KiB
Go
package loki
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// La mémoire de loki = des fichiers Markdown plats sous memory/<nom>.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
|
|
}
|