Files
Loki/internal/loki/store.go
T
MichaelandClaude Opus 5 17f8fa8f34 Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:33 +02:00

292 lines
9.7 KiB
Go

package loki
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
bolterrors "go.etcd.io/bbolt/errors"
)
// store.go — l'unique endroit où Loki écrit son état.
//
// Avant, chaque réglage avait son fichier : config.env, webprefs.json,
// conversation.json, .api_key, .link_token, .agent_enabled, model_dirs.json…
// Une douzaine de formats, une douzaine de façons de rater une écriture
// concurrente, et un dossier de données illisible. Tout ça tient désormais dans
// une seule base bbolt — pur Go, un seul fichier, transactionnelle.
//
// Ce qui N'EST PAS en base, et pourquoi : les presets (presets/*.env) et les
// pages de mémoire (memory/*.md) restent des fichiers, parce qu'ils sont faits
// pour être lus, édités et sauvegardés à la main. Les modèles (.gguf) et les
// backends compilés restent des fichiers, évidemment.
// Buckets. Un par nature de donnée : ça garde les itérations bornées et rend le
// contenu de la base lisible au débogage.
const (
bkConfig = "config" // configuration de llama-server (ex-config.env)
bkPrefs = "prefs" // préférences de l'UI web
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
bkChat = "chat" // conversation partagée
bkTasks = "tasks" // tâches planifiées (une entrée JSON par tâche)
// bkRecall : blocs archivés au compactage, adressables par un id court
// (chat_recall.go). Séparé de bkChat parce que sa durée de vie est autre — un
// bloc survit à la suppression de la discussion qui l'a produit, et sa clé est
// une séquence bbolt, pas un identifiant de discussion.
bkRecall = "recall"
// bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé
// « <slug-projet>/<slug-tracker> » — le cloisonnement par projet est dans la clé.
bkTracker = "tracker"
)
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
//
// bbolt pose un verrou EXCLUSIF sur son fichier tant qu'il est ouvert. Or une
// machine installée fait tourner en permanence le service de lien, qui sert le
// tunnel et l'UI : s'il gardait la base ouverte, plus une seule commande ne
// fonctionnerait à côté — « loki status », « loki switch », « loki edit »
// échoueraient toutes sur un délai d'attente, sur la machine même où tout est
// censé marcher. On ouvre donc pour la durée d'une opération, puis on referme.
//
// Le coût est celui d'un open+close sur un fichier de quelques dizaines de Ko,
// négligeable devant le moindre appel au modèle. Le délai d'attente absorbe la
// contention entre process ; dbMu la sérialise à l'intérieur du process.
var dbMu sync.Mutex
func dbPath() string { return filepath.Join(LokiHome(), "loki.db") }
// withDB ouvre la base, exécute fn, puis referme — toujours, même en erreur.
func withDB(fn func(*bolt.DB) error) error {
path := dbPath()
dbMu.Lock()
defer dbMu.Unlock()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 5 * time.Second})
if err != nil {
return fmt.Errorf("base %s inaccessible : %w", path, err)
}
defer d.Close()
return fn(d)
}
// view exécute une lecture. Un bucket encore absent (base neuve) est traité
// comme vide : les buckets ne sont créés QU'À l'écriture, pour qu'une simple
// lecture n'ouvre jamais de transaction d'écriture — elle coûterait un fsync,
// et les lectures sont de loin les plus fréquentes.
func view(bucket string, fn func(b *bolt.Bucket) error) error {
return withDB(func(d *bolt.DB) error {
return d.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucket))
if b == nil {
return nil
}
return fn(b)
})
})
}
// update exécute une écriture, en créant le bucket au besoin.
func update(bucket string, fn func(b *bolt.Bucket) error) error {
cacheBust(bucket)
return withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bucket))
if err != nil {
return err
}
return fn(b)
})
})
}
// getBytesErr lit une valeur en REMONTANT l'erreur d'accès. À réserver aux
// lecteurs pour qui « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas
// dire la même chose — au premier chef les secrets : sans clé enregistrée, l'API
// de pilotage est ouverte, donc une lecture ratée traitée comme « pas de clé »
// ouvrirait l'API au lieu de la fermer. Voir readWebKeyErr.
func getBytesErr(bucket, key string) ([]byte, error) {
var out []byte
err := view(bucket, func(b *bolt.Bucket) error {
if v := b.Get([]byte(key)); v != nil {
out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
}
return nil
})
return out, err
}
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
// face à une erreur d'E/S, et tous ont déjà un défaut. Les lecteurs pour qui
// l'erreur CHANGE la décision prennent getBytesErr.
func getBytes(bucket, key string) []byte {
out, _ := getBytesErr(bucket, key)
return out
}
// putBytes écrit une valeur. Une valeur nil supprime la clé.
func putBytes(bucket, key string, val []byte) error {
return update(bucket, func(b *bolt.Bucket) error {
if val == nil {
return b.Delete([]byte(key))
}
return b.Put([]byte(key), val)
})
}
func getStr(bucket, key string) string { return string(getBytes(bucket, key)) }
// putStr écrit une chaîne ; une chaîne vide supprime la clé, pour que « absent »
// et « vide » ne soient jamais deux états distincts à distinguer.
func putStr(bucket, key, val string) error {
if val == "" {
return putBytes(bucket, key, nil)
}
return putBytes(bucket, key, []byte(val))
}
func getBool(bucket, key string) bool { return getStr(bucket, key) == "1" }
func putBool(bucket, key string, on bool) error {
if !on {
return putBytes(bucket, key, nil)
}
return putStr(bucket, key, "1")
}
// getJSON décode une valeur JSON dans dst. Renvoie false si la clé est absente
// ou illisible — dans les deux cas l'appelant garde son zéro.
func getJSON(bucket, key string, dst any) bool {
b := getBytes(bucket, key)
if len(b) == 0 {
return false
}
return json.Unmarshal(b, dst) == nil
}
func putJSON(bucket, key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return putBytes(bucket, key, b)
}
// --- Cache de lecture ---------------------------------------------------------
//
// La base est rouverte à CHAQUE opération (choix délibéré, voir plus haut), ce
// qui est parfait pour un réglage lu de temps en temps mais coûteux dans le
// chemin chaud : la boucle d'inférence relit le port, la clé, le mode
// raisonnement et le seuil de compactage à chaque itération, et le compactage
// se re-teste après chaque appel d'outil. Un tour agentique un peu fourni
// rouvrait la base une centaine de fois.
//
// cachedKV garde donc le contenu d'un bucket en mémoire, invalidé par :
// - une écriture de CE process (cacheBust, appelé par update/replaceKV) ;
// - un changement de taille ou de date du fichier, qui trahit l'écriture d'un
// AUTRE process (la CLI pendant que le service tourne) ;
// - l'âge, plafonné à une seconde, filet pour le cas limite où deux écritures
// rapprochées laisseraient date et taille inchangées.
//
// Il ne sert PAS aux secrets : eux se lisent directement (voir getBytesErr).
const cacheMaxAge = time.Second
type kvCache struct {
kv map[string]string
when time.Time
mtime time.Time
size int64
}
var (
cacheMu sync.Mutex
caches = map[string]kvCache{}
)
// cacheBust vide le cache d'un bucket après une écriture locale.
func cacheBust(bucket string) {
cacheMu.Lock()
delete(caches, bucket)
cacheMu.Unlock()
}
// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
// l'écriture d'un autre process pour le prix d'un stat.
func dbStamp() (time.Time, int64) {
fi, err := os.Stat(dbPath())
if err != nil {
return time.Time{}, -1
}
return fi.ModTime(), fi.Size()
}
// cachedKV renvoie tout le contenu d'un bucket, depuis le cache quand il est
// encore valable. La carte renvoyée appartient à l'appelant (copie).
func cachedKV(bucket string) map[string]string {
mtime, size := dbStamp()
cacheMu.Lock()
c, ok := caches[bucket]
fresh := ok && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge
cacheMu.Unlock()
if !fresh {
kv := allKV(bucket)
c = kvCache{kv: kv, when: time.Now(), mtime: mtime, size: size}
cacheMu.Lock()
caches[bucket] = c
cacheMu.Unlock()
}
out := make(map[string]string, len(c.kv))
for k, v := range c.kv {
out[k] = v
}
return out
}
// allKV renvoie tout le contenu d'un bucket. Utilisé par la configuration, dont
// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
func allKV(bucket string) map[string]string {
m := map[string]string{}
_ = view(bucket, func(b *bolt.Bucket) error {
return b.ForEach(func(k, v []byte) error {
m[string(k)] = string(v)
return nil
})
})
return m
}
// replaceKV remplace tout le contenu d'un bucket en une seule transaction.
// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
// doit être un mélange de l'ancienne et de la nouvelle.
func replaceKV(bucket string, m map[string]string) error {
cacheBust(bucket)
return withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket([]byte(bucket)); err != nil && !errors.Is(err, bolterrors.ErrBucketNotFound) {
return err
}
b, err := tx.CreateBucket([]byte(bucket))
if err != nil {
return err
}
for k, v := range m {
if v == "" {
continue
}
if err := b.Put([]byte(k), []byte(v)); err != nil {
return err
}
}
return nil
})
})
}