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

451 lines
14 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"`
}
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.
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)
}