Files
Loki/internal/loki/projects.go
T
MichaelandClaude Opus 5.5 2c36fa6d2c 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>
2026-10-02 23:05:08 +02:00

484 lines
15 KiB
Go

package loki
// projects.go — les PROJETS. Un projet cloisonne une mémoire (un dossier de pages
// .md + un index MEMORY.md, façon Claude Code) ET son propre jeu de discussions.
// Fini la mémoire globale fourre-tout : un projet par chantier, chacun avec sa
// mémoire et ses conversations indépendantes.
//
// Sur disque : memory/<slug>/*.md (+ MEMORY.md). Le `slug` est l'identité STABLE
// (il ne bouge jamais, même à un renommage) ; le `Name` est le libellé affiché.
// memoryDir() (run.go) pointe sur le dossier du projet ACTIF, donc TOUT le code
// mémoire existant (chat_memory.go, l'index, les outils mem_*) hérite du
// cloisonnement sans le savoir — c'est la seule couture à toucher.
//
// Les discussions portent un champ Project (convMeta) et sont filtrées à
// l'affichage ; les tâches planifiées portent le projet qu'elles visent.
//
// Le registre (liste + projet actif) vit dans le bucket state : ce sont des noms
// de projets et un slug, pas des données de conversation.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// Project décrit un projet. CreatedAt sert au tri (plus ancien d'abord : le projet
// « Générale » historique reste en tête).
type Project struct {
Slug string `json:"slug"`
Name string `json:"name"`
CreatedAt int64 `json:"created_at"`
// Desc = description libre de ce à quoi sert le projet. Fournie À L'IA en tête
// de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi
// elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion.
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 (
defaultProjectSlug = "generale"
defaultProjectName = "Générale"
stProjectsKey = "projects" // bkState : []Project (JSON)
stActiveProjectKey = "active_project" // bkState : slug du projet actif
)
// projectsRoot est le dossier racine de la mémoire, qui contient un sous-dossier
// par projet (memory/<slug>/...). C'est l'ancien dossier `memory` plat, promu en
// racine de projets.
func projectsRoot() string { return filepath.Join(LokiHome(), "memory") }
// projectMemoryDir renvoie le dossier mémoire d'un projet donné (par slug).
func projectMemoryDir(slug string) string {
if slug == "" {
slug = defaultProjectSlug
}
return filepath.Join(projectsRoot(), slug)
}
// --- projet actif (cache RAM + override pour les tâches) ----------------------
var (
activeProjMu sync.Mutex
activeProjCache string // "" = pas encore lu ; sinon slug mémorisé
projOverrideMu sync.Mutex
projOverride string // non vide = force le projet actif (exécution d'une tâche)
)
// activeProjectSlug renvoie le slug du projet actif. Priorité : override
// d'exécution de tâche (RAM), puis cache, puis la valeur persistée, puis le
// défaut. Lecture PURE : ne crée jamais de dossier (l'amorçage se fait dans
// ensureDefaultProject au démarrage).
func activeProjectSlug() string {
projOverrideMu.Lock()
o := projOverride
projOverrideMu.Unlock()
if o != "" {
return o
}
activeProjMu.Lock()
defer activeProjMu.Unlock()
if activeProjCache != "" {
return activeProjCache
}
s := strings.TrimSpace(getStr(bkState, stActiveProjectKey))
if s == "" {
s = defaultProjectSlug
}
activeProjCache = s
return s
}
// setActiveProject persiste le projet actif et rafraîchit le cache.
func setActiveProject(slug string) error {
slug = strings.TrimSpace(slug)
if slug == "" {
return fmt.Errorf("slug vide")
}
if err := putStr(bkState, stActiveProjectKey, slug); err != nil {
return err
}
activeProjMu.Lock()
activeProjCache = slug
activeProjMu.Unlock()
return nil
}
// setProjectOverride force (ou libère avec "") le projet actif VU PAR memoryDir(),
// le temps de l'exécution d'une tâche planifiée sur un projet précis. Sans effet
// sur le projet actif persisté ni sur l'UI. Sûr car une seule inférence tourne à
// la fois (verrou de génération), donc pas de course avec un tour utilisateur.
func setProjectOverride(slug string) {
projOverrideMu.Lock()
projOverride = slug
projOverrideMu.Unlock()
}
// withProject exécute fn en forçant temporairement le projet vu par memoryDir().
// Séquentiel et bref : sûr tant qu'aucune génération ne tourne (les appelants sont
// des actions utilisateur, gardées en amont).
func withProject(slug string, fn func()) {
setProjectOverride(slug)
defer setProjectOverride("")
fn()
}
// --- registre -----------------------------------------------------------------
// listProjects renvoie les projets connus, le plus ancien d'abord. Garantit au
// moins le projet par défaut (base neuve).
func listProjects() []Project {
var out []Project
if b := getBytes(bkState, stProjectsKey); len(b) > 0 {
_ = json.Unmarshal(b, &out)
}
if len(out) == 0 {
out = []Project{{Slug: defaultProjectSlug, Name: defaultProjectName, CreatedAt: time.Now().UnixMilli()}}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out
}
func saveProjects(list []Project) error {
b, err := json.Marshal(list)
if err != nil {
return err
}
return putBytes(bkState, stProjectsKey, b)
}
// projectExists indique si un slug correspond à un projet connu.
func projectExists(slug string) bool {
for _, p := range listProjects() {
if p.Slug == slug {
return true
}
}
return false
}
// projectName renvoie le libellé affiché d'un slug (le slug lui-même à défaut).
func projectName(slug string) string {
for _, p := range listProjects() {
if p.Slug == slug {
return p.Name
}
}
return slug
}
// projectDesc renvoie la description d'un projet (vide si aucune / introuvable).
func projectDesc(slug string) string {
for _, p := range listProjects() {
if p.Slug == slug {
return p.Desc
}
}
return ""
}
// setProjectDesc enregistre la description d'un projet, tronquée à une taille
// raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des
// 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 {
desc = strings.TrimSpace(desc)
const max = 2000
if len([]rune(desc)) > max {
desc = strings.TrimSpace(string([]rune(desc)[:max]))
}
list := listProjects()
found := false
for i := range list {
if list[i].Slug == slug {
list[i].Desc = desc
found = true
break
}
}
if !found {
return fmt.Errorf("projet introuvable")
}
return saveProjects(list)
}
var identSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
// slugifyIdent réduit un nom à un slug de fichier sûr (minuscules, alphanum + tirets).
// Les accents courants sont translittérés pour ne pas tout perdre : « Réseau NAS »
// donne « reseau-nas » et non « r-seau-nas ».
func slugifyIdent(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
repl := map[rune]string{'à': "a", 'â': "a", 'ä': "a", 'á': "a", 'ã': "a",
'é': "e", 'è': "e", 'ê': "e", 'ë': "e", 'î': "i", 'ï': "i", 'í': "i",
'ô': "o", 'ö': "o", 'ó': "o", 'õ': "o", 'ù': "u", 'û': "u", 'ü': "u", 'ú': "u",
'ç': "c", 'ñ': "n"}
var b strings.Builder
for _, r := range s {
if v, ok := repl[r]; ok {
b.WriteString(v)
} else {
b.WriteRune(r)
}
}
slug := identSlugRe.ReplaceAllString(b.String(), "-")
slug = strings.Trim(slug, "-")
if len(slug) > 40 {
slug = strings.Trim(slug[:40], "-")
}
return slug
}
// memorySeed est le contenu initial de l'index MEMORY.md d'un nouveau projet.
func memorySeed(name string) string {
return "# Index mémoire — " + name + "\n\n" +
"Une ligne par page de ce projet (`- [titre](fichier.md) — accroche`). " +
"Tenu à jour automatiquement à la création et à la suppression d'une page ; " +
"l'accroche après le titre, elle, est libre.\n"
}
// createProject crée un projet à partir d'un nom d'affichage : slug unique, dossier
// mémoire, index MEMORY.md amorcé. Renvoie le projet créé.
func createProject(name string) (Project, error) {
name = strings.TrimSpace(name)
if name == "" {
return Project{}, fmt.Errorf("nom vide")
}
if len([]rune(name)) > 60 {
name = string([]rune(name)[:60])
}
base := slugifyIdent(name)
if base == "" {
base = "projet"
}
// Slug unique : suffixe -2, -3… en cas de collision.
list := listProjects()
taken := map[string]bool{}
for _, p := range list {
taken[p.Slug] = true
}
slug := base
for i := 2; taken[slug]; i++ {
slug = fmt.Sprintf("%s-%d", base, i)
}
dir := projectMemoryDir(slug)
if err := os.MkdirAll(dir, 0o755); err != nil {
return Project{}, err
}
idx := filepath.Join(dir, memIndexFile)
if _, err := os.Stat(idx); os.IsNotExist(err) {
_ = os.WriteFile(idx, []byte(memorySeed(name)), 0o600)
}
p := Project{Slug: slug, Name: name, CreatedAt: time.Now().UnixMilli()}
list = append(list, p)
if err := saveProjects(list); err != nil {
return Project{}, err
}
return p, nil
}
// renameProject change le LIBELLÉ d'un projet. Le slug, les chemins disque et les
// discussions ne bougent pas : renommer ne doit rien casser.
func renameProject(slug, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("nom vide")
}
if len([]rune(name)) > 60 {
name = string([]rune(name)[:60])
}
list := listProjects()
found := false
for i := range list {
if list[i].Slug == slug {
list[i].Name = name
found = true
break
}
}
if !found {
return fmt.Errorf("projet introuvable")
}
return saveProjects(list)
}
// deleteProject supprime un projet : son dossier mémoire, ses trackers ET ses
// discussions. Refuse de supprimer le dernier projet restant. Si c'était le projet
// actif, on rebascule sur un autre.
func deleteProject(slug string) error {
list := listProjects()
if len(list) <= 1 {
return fmt.Errorf("impossible de supprimer le dernier projet")
}
kept := make([]Project, 0, len(list))
found := false
for _, p := range list {
if p.Slug == slug {
found = true
continue
}
kept = append(kept, p)
}
if !found {
return fmt.Errorf("projet introuvable")
}
// Discussions du projet.
for _, m := range convIndexForProject(slug) {
_ = convDelete(m.ID)
}
// Trackers du projet.
for _, k := range trackerKeysForProject(slug) {
_ = putBytes(bkTracker, k, nil)
}
// Dossier disque du projet.
_ = os.RemoveAll(projectMemoryDir(slug))
if err := saveProjects(kept); err != nil {
return err
}
if activeProjectSlug() == slug {
_ = setActiveProject(kept[0].Slug)
}
return nil
}
// moveMemPage déplace une page mémoire (.md) d'un projet vers un autre, puis met à
// jour les DEUX index MEMORY.md. Best-effort sur les index : reconcileMemIndex
// répare au pire au démarrage suivant.
func moveMemPage(name, fromSlug, toSlug string) error {
if strings.TrimSpace(fromSlug) == strings.TrimSpace(toSlug) {
return fmt.Errorf("déjà dans ce projet")
}
if !projectExists(toSlug) {
return fmt.Errorf("projet cible introuvable")
}
fn, err := memFileName(name)
if err != nil {
return err
}
if isIndexFile(fn) {
return fmt.Errorf("l'index du projet n'est pas déplaçable")
}
src := filepath.Join(projectMemoryDir(fromSlug), fn)
dst := filepath.Join(projectMemoryDir(toSlug), fn)
raw, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("page introuvable")
}
if _, err := os.Stat(dst); err == nil {
return fmt.Errorf("une page du même nom existe déjà dans le projet cible")
}
if err := os.MkdirAll(projectMemoryDir(toSlug), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, raw, 0o600); err != nil {
return err
}
_ = os.Remove(src)
withProject(fromSlug, func() { memIndexRemove(fn) })
withProject(toSlug, func() { memIndexAdd(fn) })
return nil
}
// ensureDefaultProject amorce le projet « Générale » et MIGRE l'existant (mémoire
// plate + discussions sans projet) au premier démarrage de cette version.
// Idempotent : à chaque démarrage suivant, il ne fait plus rien.
func ensureDefaultProject() {
// 1. Le registre PERSISTÉ contient-il le projet par défaut ? On lit le brut (pas
// listProjects, qui synthétise toujours un défaut) pour savoir s'il faut écrire.
var stored []Project
if b := getBytes(bkState, stProjectsKey); len(b) > 0 {
_ = json.Unmarshal(b, &stored)
}
hasDefault := false
for _, p := range stored {
if p.Slug == defaultProjectSlug {
hasDefault = true
break
}
}
if !hasDefault {
// Base neuve OU migration : on inscrit « Générale » en tête et on persiste.
stored = append([]Project{{Slug: defaultProjectSlug, Name: defaultProjectName, CreatedAt: time.Now().UnixMilli()}}, stored...)
_ = saveProjects(stored)
}
genMem := projectMemoryDir(defaultProjectSlug)
_ = os.MkdirAll(genMem, 0o755)
// 2. Migration de la mémoire plate historique (memory/*.md) vers le dossier du
// projet Générale. Les pages plates vivaient à la RACINE de memory/, qui
// accueille désormais les sous-dossiers de projet : on ne déplace donc que les
// FICHIERS restés à la racine — les sous-dossiers sont déjà des projets.
if entries, err := os.ReadDir(projectsRoot()); err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
src := filepath.Join(projectsRoot(), e.Name())
dst := filepath.Join(genMem, e.Name())
if _, err := os.Stat(dst); os.IsNotExist(err) {
_ = os.Rename(src, dst)
}
}
}
// 3. Index MEMORY.md du projet Générale s'il manque.
idx := filepath.Join(genMem, memIndexFile)
if _, err := os.Stat(idx); os.IsNotExist(err) {
_ = os.WriteFile(idx, []byte(memorySeed(defaultProjectName)), 0o600)
}
// 4. Discussions et tâches historiques sans projet → Générale.
tagOrphanConversations(defaultProjectSlug)
tagOrphanTasks(defaultProjectSlug)
// 5. Projet actif par défaut.
if strings.TrimSpace(getStr(bkState, stActiveProjectKey)) == "" {
_ = setActiveProject(defaultProjectSlug)
}
// 6. Index de Générale : les pages migrées (déplacées sur le disque) n'ont jamais
// été indexées — l'index n'est alimenté que par la création d'une page. On le
// complète depuis les pages réelles. On force temporairement le projet vu par
// memoryDir sur Générale (sûr au démarrage, aucune génération en cours).
withProject(defaultProjectSlug, reconcileMemIndex)
}