Files
Loki/internal/loki/backend_presets.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

439 lines
15 KiB
Go

package loki
import (
"bufio"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// A preset's IDENTITY is its filename (ID, without the .env suffix), which is
// always unique. Its DISPLAY name lives in an optional `# NAME=` line inside the
// file, so several presets can share the same display name without overwriting
// each other (their filenames differ — see uniquePresetID).
type Preset struct {
ID string // filename without .env — stable, unique identity
Name string // display name (# NAME= line, falls back to ID)
Path string
Active bool
}
var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`)
// presetDisplayName extracts the `# NAME=` value from a preset body, falling
// back to `fallback` (the filename id) when absent — keeps old presets working.
func presetDisplayName(content, fallback string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
i := strings.IndexByte(s, '=')
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") {
if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
return v
}
}
}
return fallback
}
// withDisplayName ensures the body carries a `# NAME=<name>` line (replacing an
// existing one, or prepended otherwise).
func withDisplayName(content, name string) string {
line := "# NAME=" + name
if nameLineRe.MatchString(content) {
return nameLineRe.ReplaceAllString(content, line)
}
return line + "\n" + content
}
// configFingerprint réduit une configuration à l'ENSEMBLE TRIÉ de ses
// affectations effectives, puis le hache. Les clés « appareil » (preservedKeys,
// réappliquées par SwitchToPreset) sont ignorées : sans ça, les MEM_MODE /
// CRAWL4AI_URL injectés rendraient la config active différente de TOUS les
// presets, et aucun ne serait jamais détecté comme actif.
func configFingerprint(m map[string]string) string {
pairs := make([]string, 0, len(m))
for k, v := range m {
if isPreservedKey(k) {
continue
}
pairs = append(pairs, k+"="+v)
}
sort.Strings(pairs)
h := sha1.Sum([]byte(strings.Join(pairs, "\n")))
return hex.EncodeToString(h[:])
}
// presetFingerprint est configFingerprint appliqué au contenu d'un preset.
func presetFingerprint(content []byte) string {
return configFingerprint(parseEnv(string(content)))
}
// isPreservedKey reports whether key is one of the device-level preservedKeys.
func isPreservedKey(key string) bool {
for _, k := range preservedKeys {
if key == k {
return true
}
}
return false
}
// ListPresets renvoie tous les presets/*.env, en marquant celui qui correspond
// à la configuration active (clés « appareil » ignorées), triés par nom.
func ListPresets() ([]Preset, error) {
dir := presetsDir()
_ = os.MkdirAll(dir, 0o755)
cur := configFingerprint(ReadConfig())
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := []Preset{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") {
continue
}
p := filepath.Join(dir, e.Name())
b, err := os.ReadFile(p)
if err != nil {
continue
}
id := strings.TrimSuffix(e.Name(), ".env")
out = append(out, Preset{
ID: id,
Name: presetDisplayName(string(b), id),
Path: p,
Active: presetFingerprint(b) == cur,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// uniquePresetID derives a unique filename id from a display name, appending
// " (2)", " (3)"… on collision so duplicate names never overwrite.
func uniquePresetID(name string) (string, error) {
base := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(name))
if base == "" {
base = "preset"
}
cand := base
for n := 2; n < 10000; n++ {
p, err := safePresetPath(cand)
if err != nil {
return "", err
}
if _, err := os.Stat(p); os.IsNotExist(err) {
return cand, nil
}
cand = fmt.Sprintf("%s (%d)", base, n)
}
return "", fmt.Errorf("impossible de générer un nom de fichier unique")
}
// validPresetName accepts any name (spaces, accents, parentheses…) as long as it
// stays a single safe filename: no path separators, no control chars, and not a
// reserved directory entry. Path containment is double-checked in safePresetPath.
func validPresetName(name string) error {
if name == "" {
return fmt.Errorf("nom vide")
}
if name == "." || name == ".." {
return fmt.Errorf("nom réservé")
}
if strings.ContainsAny(name, `/\`+"\x00") {
return fmt.Errorf(`le nom ne peut pas contenir / ni \`)
}
for _, r := range name {
if r < 0x20 {
return fmt.Errorf("le nom contient un caractère de contrôle invalide")
}
}
return nil
}
// safePresetPath validates name and returns its resolved path inside presetsDir.
func safePresetPath(name string) (string, error) {
name = strings.TrimSpace(name)
if err := validPresetName(name); err != nil {
return "", err
}
root, err := filepath.Abs(presetsDir())
if err != nil {
return "", err
}
p := filepath.Join(root, name+".env")
abs, err := filepath.Abs(p)
if err != nil {
return "", err
}
if !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", fmt.Errorf("path invalide")
}
return abs, nil
}
// preservedKeys sont des réglages « appareil » (préférences utilisateur, pas des
// paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça,
// écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait
// l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre.
// HOST y figure depuis la 0.8.4 : c'est un réglage de MACHINE (« le moteur
// est-il joignable depuis le réseau ? », voir sys_network.go), pas un réglage de
// modèle. Sans lui dans cette liste, basculer sur un preset écrit avant cette
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
// premier changement de preset.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
// choix de machine (`loki gpu`) qui doit survivre aux bascules, MAIS un preset
// a le droit de l'imposer — « QWEN 3.6 27B FABLE 2 GPU » réclame `1,0` pour que
// son `--tensor-split 0.965,0.035` ait bien deux cartes en face. Le préserver
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
// modèle se retrouvait entièrement sur une seule carte et mourait sur
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
// applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
// testable sans redémarrer le service (donc sans lancer un vrai llama-server).
func applyPresetFile(target string) error {
src, err := os.ReadFile(target)
if err != nil {
return err
}
next := parseEnv(string(src))
// Ré-applique les réglages appareil par-dessus le preset — sauf ceux que le
// preset revendique explicitement (softPreservedKeys).
cur := ReadConfig()
for _, k := range preservedKeys {
v, ok := cur[k]
if !ok {
continue
}
if softPreservedKeys[k] {
if _, claimed := next[k]; claimed {
continue // le preset a son mot à dire sur cette clé : il gagne
}
}
next[k] = v
}
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
next["BIN"] = cur["BIN"]
}
return WriteConfig(next)
}
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
//
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
// preset : le courant reste.
//
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
// modèle : le preset gagne.
func presetImposesEngine(p string) bool {
p = strings.TrimSpace(p)
if p == "" || engineOwns(p) || prebuiltOwns(p) {
return false
}
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
return false
}
return true
}
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
if err := applyPresetFile(target); err != nil {
return err
}
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
fmt.Println(dim("[info] redémarrage du service..."))
return serviceAction("restart")
}
func cmdSwitch(args []string) error {
list, err := ListPresets()
if err != nil {
return err
}
if len(list) == 0 {
return fmt.Errorf("aucun preset dans %s", presetsDir())
}
fmt.Printf("\n %s (%s)\n\n", cyan("Presets disponibles"), presetsDir())
for i, p := range list {
mark := " "
if p.Active {
mark = green("●") + " actif"
}
fmt.Printf(" %2d) %-30s %s\n", i+1, p.Name, mark)
}
fmt.Println()
choice := ""
if len(args) > 0 {
choice = args[0]
} else {
fmt.Print("Numéro à activer (vide = annuler) : ")
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() {
choice = strings.TrimSpace(sc.Text())
}
}
if choice == "" {
fmt.Println(dim("[info] annulé"))
return nil
}
n, err := strconv.Atoi(choice)
if err != nil || n < 1 || n > len(list) {
return fmt.Errorf("choix invalide")
}
return SwitchToPreset(list[n-1].Path)
}
// newPresetSeedKeys : les SEULES clés reprises de la configuration active quand
// on crée un preset. Ce sont des réglages de MACHINE (quel moteur, où il écoute),
// pas des réglages de modèle.
//
// Issue #17 : le nouveau preset repartait d'une copie COMPLÈTE de la config
// active. Les réglages du modèle précédent (EXTRA_ARGS — --n-cpu-moe,
// --tensor-split, --flash-attn… — mais aussi CTX, NGL, KV_TYPE, MODEL) se
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
//
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
// moteur mis à jour à la première bascule.
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
func newPresetSeed() map[string]string {
cur := ReadConfig()
seed := map[string]string{}
for _, k := range newPresetSeedKeys {
v := cur[k]
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
continue
}
seed[k] = v
}
return seed
}
// SavePreset writes a preset. When id == "" it creates a NEW preset under a
// freshly-generated unique filename (so duplicate display names never clash).
// When id != "" it updates that existing preset in place (filename unchanged —
// only the body and its `# NAME=` line change). Returns the resulting id.
func SavePreset(id, name, content string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("nom requis")
}
content = withDisplayName(content, name)
if id == "" {
newID, err := uniquePresetID(name)
if err != nil {
return "", err
}
p, err := safePresetPath(newID)
if err != nil {
return "", err
}
_ = os.MkdirAll(filepath.Dir(p), 0o755)
return newID, os.WriteFile(p, []byte(content), 0o644)
}
p, err := safePresetPath(id)
if err != nil {
return "", err
}
_ = os.MkdirAll(filepath.Dir(p), 0o755)
return id, os.WriteFile(p, []byte(content), 0o644)
}
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
// installe aussitôt la nouvelle version comme configuration active (mêmes
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
// l'appelant redémarre alors le moteur.
//
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
// preset n'était détecté actif (empreinte différente). Éditer le preset en
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
//
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
// porte l'empreinte de la configuration courante.
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
wasActive := false
if id != "" {
if old, rerr := ReadPreset(id); rerr == nil {
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
}
}
newID, err = SavePreset(id, name, content)
if err != nil || !wasActive {
return newID, false, err
}
p, err := safePresetPath(newID)
if err != nil {
return newID, false, err
}
if err := applyPresetFile(p); err != nil {
return newID, false, err
}
return newID, true, nil
}
// DeletePreset removes a preset by id; refuses if it is the active config.
func DeletePreset(id string) error {
p, err := safePresetPath(id)
if err != nil {
return err
}
target, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("introuvable")
}
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
return fmt.Errorf("preset actif, switche d'abord")
}
if err := os.Remove(p); err != nil {
return err
}
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
return nil
}
// ReadPreset returns the contents of a preset by id (filename).
func ReadPreset(id string) (string, error) {
p, err := safePresetPath(id)
if err != nil {
return "", err
}
b, err := os.ReadFile(p)
if err != nil {
return "", err
}
return string(b), nil
}