From 17f8fa8f3459da2429d7d9f5ba7b2318c92282ee Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 7 Sep 2026 22:34:33 +0200 Subject: [PATCH] =?UTF-8?q?M=C3=A9moire=20longue=20(recall),=20projets=20e?= =?UTF-8?q?t=20trackers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/backend_presets.go | 9 +- internal/loki/chat_compact.go | 231 +++++++- internal/loki/chat_compact_test.go | 13 +- internal/loki/chat_memory.go | 22 +- internal/loki/chat_prompt_size_test.go | 14 +- internal/loki/chat_recall.go | 315 ++++++++++ internal/loki/chat_recall_test.go | 223 +++++++ internal/loki/chat_sessions.go | 84 ++- internal/loki/llm_client.go | 55 +- internal/loki/mem_index.go | 234 ++++++++ internal/loki/mem_index_test.go | 152 +++++ internal/loki/projects.go | 450 ++++++++++++++ internal/loki/projects_test.go | 257 ++++++++ internal/loki/run.go | 14 +- internal/loki/store.go | 8 + internal/loki/tasks.go | 33 ++ internal/loki/tasks_run.go | 19 +- internal/loki/tracker.go | 586 +++++++++++++++++++ internal/loki/tracker_test.go | 291 +++++++++ internal/loki/ui/src/js/06-settings.js | 13 +- internal/loki/ui/src/js/08-chat-render.js | 4 + internal/loki/ui/src/js/21-tasks.js | 22 +- internal/loki/ui/src/js/22-settings-modal.js | 8 + internal/loki/ui/src/js/25-projects.js | 184 ++++++ internal/loki/ui/src/js/26-tracker.js | 226 +++++++ internal/loki/web_projects.go | 137 +++++ internal/loki/web_server.go | 13 +- internal/loki/web_sysprompt.go | 52 +- internal/loki/web_sysprompt_test.go | 134 +++++ internal/loki/web_tasks.go | 11 + internal/loki/web_tracker.go | 107 ++++ 31 files changed, 3859 insertions(+), 62 deletions(-) create mode 100644 internal/loki/chat_recall.go create mode 100644 internal/loki/chat_recall_test.go create mode 100644 internal/loki/mem_index.go create mode 100644 internal/loki/mem_index_test.go create mode 100644 internal/loki/projects.go create mode 100644 internal/loki/projects_test.go create mode 100644 internal/loki/tracker.go create mode 100644 internal/loki/tracker_test.go create mode 100644 internal/loki/ui/src/js/25-projects.js create mode 100644 internal/loki/ui/src/js/26-tracker.js create mode 100644 internal/loki/web_projects.go create mode 100644 internal/loki/web_sysprompt_test.go create mode 100644 internal/loki/web_tracker.go diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 15ffe46..97fdfcb 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -414,7 +414,14 @@ func DeletePreset(id string) error { if presetFingerprint(target) == configFingerprint(ReadConfig()) { return fmt.Errorf("preset actif, switche d'abord") } - return os.Remove(p) + 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). diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index cd41c37..f2050c6 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -34,7 +34,10 @@ const ( // Budget de la queue : fraction de la fenêtre gardée intacte (tours récents). // Plus la queue est petite, plus on compacte de torse d'un coup → le contexte // retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent). - compactTailFrac = 0.25 + // Descendu de 0.25 à 0.20 avec l'archivage recall : compacter plus large ne + // coûte plus de perte (les gros blocs restent rappelables), donc autant faire + // des compactions franches et rares plutôt que fréquentes et molles. + compactTailFrac = 0.20 // Un résultat d'outil du torse plus long que ça est remplacé par un marqueur // dans le torse DÉGRAISSÉ (repli si le résumé échoue). compactToolPruneLen = 200 @@ -77,11 +80,47 @@ func ctxWindow() int { return 32768 } +// compactSummaryBudget renvoie le budget en tokens du résumé, adapté à la +// fenêtre. Assez pour PORTER LE FIL (la demande, les faits déjà trouvés, les +// décisions, l'état d'avancement) sans jamais le perdre — c'était la plainte de +// fond : à 700 tokens fixes, tout le passé d'une longue conversation était +// écrasé en un demi-paragraphe et le modèle « perdait le fil ». Le résumé reste +// de toute façon minuscule devant le torse compacté (souvent des dizaines de +// milliers de tokens), donc la réduction demeure massive. Bornes : 700 (petites +// fenêtres) à 1600 (grandes), ~4 % de la fenêtre entre les deux. +func compactSummaryBudget() int { + n := ctxWindow() * 4 / 100 + if n < 700 { + n = 700 + } + if n > 1600 { + n = 1600 + } + return n +} + +// imageLostMarker remplace la partie `image_url` d'un message multimodal dans +// msgText. Toute la chaîne de compaction passe par msgText — estimation de +// contexte, choix des messages à résumer, transcript donné au résumeur — et +// msgText n'extrayait QUE les parties `text`. Un message qui porte une capture +// (screenshotImageMessage) ne pesait donc que sa légende (« Capture … »), et le +// résumeur ne savait même pas qu'une image avait été montrée : au compactage +// suivant l'image disparaissait SANS AUCUNE trace. Observé en amont comme cause +// probable de boucles de raisonnement — le modèle a bien regardé l'image, ne +// peut plus s'y référer, et redérive à l'aveugle en texte. +// +// Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend +// sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il +// doit reprendre une capture plutôt que deviner. +const imageLostMarker = " [image — not kept in the conversation past this point; take the screenshot again if you still need to see it]" + // msgText extrait le texte d'un message (Content est `any`, en pratique string // ou nil quand l'assistant n'a que des tool_calls). Un message multimodal // (userMessageContent : parties texte + image quand la vision est active) porte // un tableau de parties ; on en recolle les segments `text` pour que l'estimation -// de contexte et le transcript de compaction ne partent pas d'un texte vide. +// de contexte et le transcript de compaction ne partent pas d'un texte vide, et +// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans +// quoi elle reste invisible à toute la chaîne de compaction. func msgText(m Message) string { switch v := m.Content.(type) { case string: @@ -89,20 +128,30 @@ func msgText(m Message) string { case []map[string]any: var b strings.Builder for _, part := range v { - if part["type"] == "text" { + switch part["type"] { + case "text": if s, ok := part["text"].(string); ok { b.WriteString(s) } + case "image_url": + b.WriteString(imageLostMarker) } } return b.String() case []any: // même contenu relu depuis le JSON persisté (map générique) var b strings.Builder for _, p := range v { - if part, ok := p.(map[string]any); ok && part["type"] == "text" { + part, ok := p.(map[string]any) + if !ok { + continue + } + switch part["type"] { + case "text": if s, ok := part["text"].(string); ok { b.WriteString(s) } + case "image_url": + b.WriteString(imageLostMarker) } } return b.String() @@ -181,8 +230,13 @@ func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens in // l'historique compacté et true s'il a effectivement changé. // compactBounds calcule les frontières head/tail pour un historique donné et un // budget de queue (en tokens). Fonction pure (pas d'IO) → testable : -// - head : nb de messages protégés en tête = messages système + 1er message -// utilisateur (il ancre l'objectif). Un message user est une frontière sûre. +// - head : nb de messages protégés en tête = messages système, et RIEN d'autre. +// Le tout premier message utilisateur y était épinglé pour « ancrer +// l'objectif » ; c'était contre-productif. Après compaction, le modèle voyait +// deux demandes explicites — celle du DÉBUT de la conversation, épinglée, et +// celle réinjectée par `pending` — et répondait volontiers à l'ancienne, en +// abandonnant la tâche en cours. La demande courante est déjà garantie par +// `pending` ; le premier message, lui, part au résumé comme le reste. // - tailStart : index de début de la queue protégée. On remonte depuis la fin // jusqu'à remplir le budget, puis on recule jusqu'à une frontière SÛRE : // un message `user`, ou un `assistant` (qui, s'il porte des tool_calls, part @@ -204,9 +258,6 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) { for head < len(msgs) && msgs[head].Role == "system" { head++ } - if head < len(msgs) && msgs[head].Role == "user" { - head++ - } tailStart = len(msgs) acc := 0 for i := len(msgs) - 1; i >= head; i-- { @@ -238,13 +289,46 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, torso := msgs[head:tailStart] - // 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un + // 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc + // du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et + // le repli le remplacent par une référence à cet id, que le modèle pourra + // rappeler avec recall(id). C'est ce qui rend le compactage sûr même + // agressif — rien n'est perdu, seulement déplacé hors du contexte. + // On n'archive QUE si le modèle a réellement ses outils (mode agent) : sans + // eux il ne pourrait pas rappeler, autant garder un résumé propre sans ids. + // Un échec d'écriture (base absente, tests) laisse archived[i] nil → repli + // transparent sur le comportement classique (troncature/marqueur sans id). + archived := make([]*recallEntry, len(torso)) + var index []recallEntry + if caps.Agent && compactEnabled() { + names := torsoToolNames(torso) + for i, m := range torso { + if !recallEligible(m) { + continue + } + content := msgText(m) + label := recallLabel(m.Role, names[m.ToolCallID], content) + id, aerr := archiveRecallBlock(label, m.Role, content) + if aerr != nil { + continue + } + archived[i] = &recallEntry{id: id, label: label} + index = append(index, recallEntry{id: id, label: label}) + } + } + + // 4. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un // marqueur. On travaille sur une copie pour ne pas muter l'historique amont. // ⚠️ Ce torse dégraissé ne sert QUE de repli si le résumé échoue — surtout - // PAS d'entrée au résumeur, cf. juste en dessous. + // PAS d'entrée au résumeur, cf. juste en dessous. Un bloc archivé pointe vers + // son id (récupérable par recall) au lieu d'être effacé aveuglément. pruned := make([]Message, len(torso)) for i, m := range torso { pruned[i] = m + if e := archived[i]; e != nil { + pruned[i].Content = recallMarker(e.id, e.label) + continue + } if m.Role == "tool" { if t := msgText(m); len(t) > compactToolPruneLen { pruned[i].Content = compactPrunedMarker @@ -252,7 +336,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, } } - // 4. Résumé du torse par le modèle local (un seul appel). + // 5. Résumé du torse par le modèle local (un seul appel). // // Le résumé se fait sur le torse ORIGINAL, pas sur le dégraissé. C'était LE // bug de fond : on effaçait tous les résultats d'outils PUIS on demandait un @@ -262,12 +346,26 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // que des outils avaient tourné. À chaque compactage, l'IA repartait donc // d'une recherche vide et recommençait à zéro : elle ne s'arrêtait jamais. // - // Les résultats d'outils sont seulement RACCOURCIS (leur début, qui porte - // l'essentiel : titre, en-tête, premières lignes) pour que la transcription - // reste bornée. Les faits survivent, le volume reste maîtrisé. + // Un bloc archivé est réduit à sa TÊTE (assez pour le résumer fidèlement). + // On ne montre PAS l'id recall au résumeur : sa seule tâche est d'écrire de + // la prose. Lui exposer « recall:rN » l'incite à répondre juste par l'id + // (résumé dégénéré) ; l'index des ids est de toute façon ajouté séparément + // et de façon déterministe par compactSummaryUserMsg. + // Les résultats d'outils non archivés sont seulement RACCOURCIS (leur début, + // qui porte l'essentiel : titre, en-tête, premières lignes) pour que la + // transcription reste bornée. Les faits survivent, le volume reste maîtrisé. forSummary := make([]Message, len(torso)) for i, m := range torso { forSummary[i] = m + if archived[i] != nil { + r := []rune(msgText(m)) + headTxt := string(r) + if len(r) > recallSummaryHeadLen { + headTxt = string(r[:recallSummaryHeadLen]) + "…" + } + forSummary[i].Content = headTxt + "\n[…reste de ce bloc omis ici]" + continue + } if m.Role == "tool" { if r := []rune(msgText(m)); len(r) > compactToolSummaryLen { forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]" @@ -276,15 +374,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, } summary, err := summarizeTranscript(ctx, renderTranscript(forSummary)) var mid []Message - if err != nil || strings.TrimSpace(summary) == "" { + if err != nil || summaryLooksEmpty(summary) { + // Résumé raté (erreur, vide, ou juste une référence recall) → on garde le + // torse dégraissé, qui porte au moins les têtes de blocs et les marqueurs + // recall : bien plus informatif qu'un résumé dégénéré. mid = pruned } else { // Le résumé est injecté comme un tour utilisateur→assistant (jamais un // message `system` au milieu : certains gabarits, ex. Qwen, exigent que le // system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix). + // Le message porte aussi la CONSCIENCE de la compaction et l'index des ids + // rappelables (voir compactSummaryUserMsg). mid = []Message{ - {Role: "user", Content: compactSummaryPrefix + " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary}, - {Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, without redoing work that is already done."}, + {Role: "user", Content: compactSummaryUserMsg(summary, index)}, + {Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."}, } } @@ -327,6 +430,94 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, return out, true } +// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement +// de références « recall:rN » (un petit modèle, mal aiguillé, répond parfois par +// un id au lieu d'écrire de la vraie prose). Dans ce cas on préfère le repli +// dégraissé. Seuil à 40 caractères de VRAI texte (hors jetons recall). +func summaryLooksEmpty(s string) bool { + s = strings.TrimSpace(s) + if len([]rune(s)) < 40 { + return true + } + var real strings.Builder + for _, f := range strings.Fields(s) { + low := strings.ToLower(strings.Trim(f, ".,;:()[]-•*")) + if strings.HasPrefix(low, "recall:r") || strings.HasPrefix(low, "recall(") { + continue + } + real.WriteString(f) + real.WriteString(" ") + } + return len([]rune(strings.TrimSpace(real.String()))) < 40 +} + +// recallEntry associe un id d'archive à son libellé court, pour l'index injecté +// dans le message de compaction. +type recallEntry struct{ id, label string } + +// recallEligible dit si un message du torse mérite d'être archivé sous un id : +// un bloc assez gros (le résumé ne le porterait pas fidèlement) d'un rôle réel, +// et qui n'est pas déjà le résumé d'une compaction précédente (on ne ré-archive +// pas un résumé). +func recallEligible(m Message) bool { + switch m.Role { + case "tool", "user", "assistant": + default: + return false + } + t := msgText(m) + if len([]rune(t)) <= recallArchiveMinLen { + return false + } + return !strings.HasPrefix(t, compactSummaryPrefix) +} + +// torsoToolNames associe chaque ToolCallID du torse au nom de l'outil qui l'a +// produit (l'assistant porte les tool_calls, le résultat ne porte que l'id), +// pour donner un libellé lisible aux blocs archivés (« web_read: … »). +func torsoToolNames(torso []Message) map[string]string { + names := map[string]string{} + for _, m := range torso { + for _, tc := range m.ToolCalls { + if tc.ID != "" { + names[tc.ID] = tc.Function.Name + } + } + } + return names +} + +// recallMarker remplace un bloc archivé dans le torse dégraissé (repli). Il cite +// l'id récupérable et interdit explicitement de relancer l'outil pour le refaire. +func recallMarker(id, label string) string { + return fmt.Sprintf("[Block archived to save context — full content retrievable with recall(\"%s\") (%s). Do NOT re-run a tool to fetch it back.]", id, label) +} + +// compactSummaryUserMsg construit le message `user` synthétique qui porte le +// résumé. Il rend le modèle CONSCIENT que la conversation a été compactée (il le +// sait aussi bien que nous), et lui donne l'index des blocs rappelables. L'index +// ne liste QUE les ids créés à cette passe : sa taille reste bornée, une +// conversation infinie ne gonfle jamais cette section (les vieux blocs restent +// joignables par recall_search). +func compactSummaryUserMsg(summary string, index []recallEntry) string { + var b strings.Builder + b.WriteString(compactSummaryPrefix) + if len(index) > 0 { + b.WriteString(" The earlier turns of this conversation were summarized to save context, but nothing is lost: any block referenced below (or in the summary) as recall:rN can be brought back verbatim with the recall(id) tool, and recall_search(\"keywords\") finds older archived blocks not listed here. Here is the summary:\n\n") + } else { + b.WriteString(" The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n") + } + b.WriteString(summary) + if len(index) > 0 { + b.WriteString("\n\nArchived blocks you can bring back with recall(id):\n") + for _, e := range index { + fmt.Fprintf(&b, "- %s — %s\n", e.id, e.label) + } + b.WriteString("Only recall a block when you actually need its full content.") + } + return b.String() +} + // renderTranscript sérialise le torse en texte lisible pour le résumeur. func renderTranscript(msgs []Message) string { var b strings.Builder @@ -383,7 +574,7 @@ Summarize densely and faithfully, keeping ONLY the essentials: - Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time - Decisions made and established facts - STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step -Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 300 words MAX — this is a compression summary, not a report. +Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference. Write the summary in the SAME language as the conversation.` payload := map[string]any{ @@ -396,7 +587,7 @@ Write the summary in the SAME language as the conversation.` "temperature": 0.2, // Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un // résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour. - "max_tokens": 700, + "max_tokens": compactSummaryBudget(), // Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un // modèle hybride gaspille tout le budget en (résumé vide). llama.cpp // passe ces kwargs au gabarit Jinja (--jinja). diff --git a/internal/loki/chat_compact_test.go b/internal/loki/chat_compact_test.go index 2f263b6..82438e9 100644 --- a/internal/loki/chat_compact_test.go +++ b/internal/loki/chat_compact_test.go @@ -20,10 +20,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) { um("q2"), am("r2"), um("q3"), am("r3"), } - // budget minuscule → queue = juste le dernier tour, tête = system + 1er user. + // budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes. + // Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer + // l'objectif », mais après compaction le modèle voyait deux demandes (celle du + // début, épinglée, et la courante réinjectée par `pending`) et répondait + // volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest. head, tail := compactBounds(msgs, 1) - if head != 2 { // system + premier user - t.Fatalf("head = %d, attendu 2", head) + if head != 1 { // le message système, et lui seul + t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head) + } + if msgs[head].Role != "user" { + t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role) } // Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin). if r := msgs[tail].Role; r != "user" && r != "assistant" { diff --git a/internal/loki/chat_memory.go b/internal/loki/chat_memory.go index 117617f..8d14760 100644 --- a/internal/loki/chat_memory.go +++ b/internal/loki/chat_memory.go @@ -282,7 +282,13 @@ func MemAdd(name, content string) error { return err } body := strings.TrimRight(content, "\n") + "\n" - return os.WriteFile(p, []byte(body), 0o644) + if err := os.WriteFile(p, []byte(body), 0o644); 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 @@ -355,7 +361,13 @@ func MemSave(name, old, content string) error { return err } body := strings.TrimRight(content, "\n") + "\n" - return os.WriteFile(p, []byte(body), 0o644) + if err := os.WriteFile(p, []byte(body), 0o644); 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. @@ -367,7 +379,11 @@ func MemDelete(name string) error { if _, err := os.Stat(p); err != nil { return fmt.Errorf("introuvable") } - return os.Remove(p) + 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 diff --git a/internal/loki/chat_prompt_size_test.go b/internal/loki/chat_prompt_size_test.go index 24e5720..522054b 100644 --- a/internal/loki/chat_prompt_size_test.go +++ b/internal/loki/chat_prompt_size_test.go @@ -17,7 +17,19 @@ import ( // // Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans // dépendance à un tokenizer. -const promptCharBudget = 7500 // ~1875 tokens, tout allumé +// +// Le budget n'a été relevé que pour de VRAIS outils nouveaux, jamais pour des +// lignes de prompt — et chaque palier dit lequel : +// +// 7500 → 7900 : recall + recall_search (chat_recall.go), ~720 car. Le compactage +// cesse d'être destructif : le modèle ne relance plus les lectures +// et recherches déjà faites. Il récupère bien plus qu'il ne coûte. +// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre +// actions — quatre outils auraient coûté quatre fois ça. +// +// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne +// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt. +const promptCharBudget = 8600 // ~2150 tokens, tout allumé func TestSystemPromptStaysLean(t *testing.T) { caps := Caps{Agent: true, Internet: true, Mem: MemAlways} diff --git a/internal/loki/chat_recall.go b/internal/loki/chat_recall.go new file mode 100644 index 0000000..efa07d9 --- /dev/null +++ b/internal/loki/chat_recall.go @@ -0,0 +1,315 @@ +package loki + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + bolt "go.etcd.io/bbolt" +) + +// chat_recall.go — mémoire longue de la conversation. +// +// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé +// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt +// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le +// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id +// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le +// rappeler VERBATIM quand il en a besoin : +// +// recall(id) → ramène le bloc exact, tel qu'il était. +// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très +// ancien et disparu du résumé courant. +// +// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée +// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours. +// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au +// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le +// résumé courant : sa taille reste plate même sur une conversation infinie, +// pendant que l'archive grossit tranquillement sur disque (bon marché). +// +// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou +// un diff produit tôt dans la session n'est plus perdu au compactage suivant. + +const ( + // Un bloc du torse plus long que ça (en caractères) est archivé et remplacé + // par son id. En dessous, le résumé le porte très bien tout seul : pas la peine + // de créer un id pour trois lignes. + recallArchiveMinLen = 800 + // Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de + // quoi le résumer fidèlement, le reste étant récupérable par recall. + recallSummaryHeadLen = 400 + // Nombre de résultats par défaut / max pour recall_search. + recallSearchDefault = 5 + recallSearchMax = 20 +) + +// recallBlock est un bloc archivé, adressable par son id. +type recallBlock struct { + ID string `json:"id"` // "r" + Seq uint64 `json:"seq"` // séquence brute (tri, clé) + Label string `json:"label"` // libellé court lisible (rôle + première ligne) + Role string `json:"role"` // rôle d'origine (tool/user/assistant) + Content string `json:"content"` // contenu VERBATIM + Created int64 `json:"created"` // unix, pour un éventuel ménage futur +} + +// recallKey encode une séquence en clé bbolt triable (big-endian). +func recallKey(seq uint64) []byte { + k := make([]byte, 8) + binary.BigEndian.PutUint64(k, seq) + return k +} + +// recallIDToSeq lit "r" → n. Tolère un id sans préfixe et les espaces : le +// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 "). +func recallIDToSeq(id string) (uint64, bool) { + s := strings.TrimSpace(id) + s = strings.TrimPrefix(s, "r") + s = strings.TrimPrefix(s, "R") + n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64) + if err != nil { + return 0, false + } + return n, true +} + +// archiveRecallBlock enregistre un bloc et renvoie son id ("r"). L'id est +// alloué par NextSequence dans la transaction d'écriture : monotone, sans +// collision, même si deux compactages se croisent. En cas d'échec d'écriture +// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et +// l'appelant se rabat sur le compactage classique (troncature sans id). +func archiveRecallBlock(label, role, content string) (string, error) { + var id string + err := withDB(func(d *bolt.DB) error { + return d.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(bkRecall)) + if err != nil { + return err + } + seq, err := b.NextSequence() + if err != nil { + return err + } + blk := recallBlock{ + ID: "r" + strconv.FormatUint(seq, 10), + Seq: seq, + Label: label, + Role: role, + Content: content, + Created: time.Now().Unix(), + } + id = blk.ID + raw, err := json.Marshal(blk) + if err != nil { + return err + } + return b.Put(recallKey(seq), raw) + }) + }) + if err != nil { + return "", err + } + cacheBust(bkRecall) + return id, nil +} + +// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent. +func recallGet(id string) (recallBlock, bool) { + seq, ok := recallIDToSeq(id) + if !ok { + return recallBlock{}, false + } + var blk recallBlock + found := false + _ = view(bkRecall, func(b *bolt.Bucket) error { + v := b.Get(recallKey(seq)) + if v == nil { + return nil + } + found = json.Unmarshal(v, &blk) == nil + return nil + }) + return blk, found +} + +// recallSearch cherche dans toute l'archive par recouvrement de mots-clés. +// Score = nombre de termes distincts de la requête présents dans (libellé + +// contenu), le libellé comptant double (un mot du titre est plus signifiant). +// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du +// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante). +func recallSearch(query string, limit int) []recallBlock { + terms := recallTokens(query) + if len(terms) == 0 { + return nil + } + if limit <= 0 { + limit = recallSearchDefault + } + if limit > recallSearchMax { + limit = recallSearchMax + } + type scored struct { + blk recallBlock + score int + } + var hits []scored + _ = view(bkRecall, func(b *bolt.Bucket) error { + return b.ForEach(func(_, v []byte) error { + var blk recallBlock + if json.Unmarshal(v, &blk) != nil { + return nil + } + label := strings.ToLower(blk.Label) + body := strings.ToLower(blk.Content) + score := 0 + for t := range terms { + if strings.Contains(label, t) { + score += 2 + } + if strings.Contains(body, t) { + score++ + } + } + if score > 0 { + hits = append(hits, scored{blk, score}) + } + return nil + }) + }) + sort.Slice(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return hits[i].blk.Seq > hits[j].blk.Seq + }) + if len(hits) > limit { + hits = hits[:limit] + } + out := make([]recallBlock, len(hits)) + for i, h := range hits { + out[i] = h.blk + } + return out +} + +// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2 +// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre. +func recallTokens(s string) map[string]struct{} { + out := map[string]struct{}{} + for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ') + }) { + if len([]rune(w)) >= 2 { + out[w] = struct{}{} + } + } + return out +} + +// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son +// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne. +func recallLabel(role, toolName, content string) string { + head := "" + for _, line := range strings.Split(content, "\n") { + if s := strings.TrimSpace(line); s != "" { + head = s + break + } + } + if r := []rune(head); len(r) > 70 { + head = strings.TrimSpace(string(r[:70])) + "…" + } + tag := role + if role == "tool" && toolName != "" { + tag = toolName + } + if head == "" { + return tag + } + return tag + ": " + head +} + +// toolRecall ramène un bloc archivé pour le modèle (outil recall). +func toolRecall(args map[string]any) string { + id, _ := args["id"].(string) + if strings.TrimSpace(id) == "" { + return "[erreur] id manquant" + } + blk, ok := recallGet(id) + if !ok { + return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id) + } + return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content) +} + +// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search). +func toolRecallSearch(args map[string]any) string { + q, _ := args["query"].(string) + if strings.TrimSpace(q) == "" { + return "[erreur] query manquante" + } + lim := 0 + if v, ok := args["limit"].(float64); ok { + lim = int(v) + } + hits := recallSearch(q, lim) + if len(hits) == 0 { + return "[aucun bloc archivé ne correspond]" + } + var b strings.Builder + b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n") + for _, h := range hits { + fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160)) + } + return strings.TrimRight(b.String(), "\n") +} + +// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche. +func recallSnippet(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if r := []rune(s); len(r) > n { + return strings.TrimSpace(string(r[:n])) + "…" + } + return s +} + +// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent. +func recallTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "recall", + Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."}, + }, + "required": []string{"id"}, + }, + }, + } +} + +func recallSearchTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "recall_search", + Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string", "description": "Keywords describing the block."}, + "limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)}, + }, + "required": []string{"query"}, + }, + }, + } +} diff --git a/internal/loki/chat_recall_test.go b/internal/loki/chat_recall_test.go new file mode 100644 index 0000000..1071ce6 --- /dev/null +++ b/internal/loki/chat_recall_test.go @@ -0,0 +1,223 @@ +package loki + +import ( + "strings" + "testing" +) + +// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen, +// pour qu'il soit archivé plutôt que simplement porté par le résumé. +func bigBlock(marker string) string { + return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40) +} + +// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall. +// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est +// l'exactitude — un gros code ou un diff ne survit pas à une reformulation. +func TestRecallRendVerbatim(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + content := bigBlock("MARQUEUR-UNIQUE-42") + id, err := archiveRecallBlock("web_read: page", "tool", content) + if err != nil { + t.Fatalf("archivage impossible : %v", err) + } + if !strings.HasPrefix(id, "r") { + t.Fatalf("id mal formé : %q", id) + } + blk, ok := recallGet(id) + if !ok { + t.Fatalf("bloc %q introuvable juste après son archivage", id) + } + if blk.Content != content { + t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim") + } + // Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu. + if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") { + t.Fatalf("l'outil recall ne rend pas le bloc : %q", got) + } +} + +// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne +// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ; +// ce test verrouille qu'on s'en sert bien. +func TestRecallIDsUniquesEtMonotones(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + vus := map[string]bool{} + var last uint64 + for i := 0; i < 5; i++ { + id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu")) + if err != nil { + t.Fatalf("archivage %d : %v", i, err) + } + if vus[id] { + t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id) + } + vus[id] = true + seq, ok := recallIDToSeq(id) + if !ok { + t.Fatalf("id illisible : %q", id) + } + if i > 0 && seq <= last { + t.Fatalf("séquence non monotone : %d après %d", seq, last) + } + last = seq + } +} + +// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans +// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le +// modèle croire que son bloc a disparu. +func TestRecallIDToSeqTolerant(t *testing.T) { + for _, in := range []string{"r7", "R7", " r7 ", "7"} { + if seq, ok := recallIDToSeq(in); !ok || seq != 7 { + t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok) + } + } + for _, in := range []string{"", "rr", "abc", "r-1"} { + if _, ok := recallIDToSeq(in); ok { + t.Fatalf("%q accepté alors qu'il est invalide", in) + } + } +} + +// recall_search est l'issue de secours quand l'id n'est plus dans le résumé +// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre +// son id pour que le modèle puisse enchaîner sur recall(id). +func TestRecallSearchRetrouveParMotsCles(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil { + t.Fatal(err) + } + wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3")) + if err != nil { + t.Fatal(err) + } + hits := recallSearch("train horaires", 0) + if len(hits) == 0 { + t.Fatal("aucun résultat pour des mots-clés pourtant présents") + } + if hits[0].ID != wanted { + t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted) + } + out := toolRecallSearch(map[string]any{"query": "train horaires"}) + if !strings.Contains(out, wanted) { + t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out) + } + if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 { + t.Fatalf("%d bloc(s) remontent pour un terme absent", n) + } +} + +// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et +// l'historique compacté doit porter son id. Sans llama-server en test, le résumé +// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où +// l'ancien code effaçait le contenu sans laisser d'adresse. +func TestCompactArchiveLesGrosBlocs(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + page := func(n int) Message { + return tm(bigBlock("page numéro " + string(rune('a'+n)))) + } + msgs := []Message{um("cherche les horaires du train pour Lyon")} + for i := 0; i < 10; i++ { + msgs = append(msgs, atc("web_read"), page(i)) + } + // caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on + // n'archive délibérément rien. + out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true}) + if !changed { + t.Fatal("aucune compaction alors que le torse est plein de grosses pages") + } + joined := "" + for _, m := range out { + joined += msgText(m) + "\n" + } + if !strings.Contains(joined, "recall(") { + t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined) + } + if strings.Contains(joined, compactPrunedMarker) { + t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable") + } + // Et le contenu doit être réellement récupérable, pas seulement cité. + hits := recallSearch("page numéro", 0) + if len(hits) == 0 { + t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive") + } + if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") { + t.Fatal("le bloc archivé ne contient pas le texte d'origine") + } +} + +// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids +// qu'il ne peut pas utiliser, et un résumé pollué de références mortes. +func TestCompactNArchivePasSansAgent(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + msgs := []Message{um("question")} + for i := 0; i < 10; i++ { + msgs = append(msgs, atc("web_read"), tm(bigBlock("page"))) + } + out, _ := compactMessages(t.Context(), msgs, Caps{}) + for _, m := range out { + if strings.Contains(msgText(m), "recall(") { + t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni") + } + } + if len(recallSearch("page", 0)) != 0 { + t.Fatal("des blocs ont été archivés hors mode agent") + } +} + +// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le +// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on +// ré-archiverait à chaque passe en faisant enfler l'archive pour rien. +func TestRecallEligible(t *testing.T) { + gros := strings.Repeat("x", recallArchiveMinLen+1) + if !recallEligible(tm(gros)) { + t.Fatal("un gros résultat d'outil doit être archivé") + } + if recallEligible(tm("court")) { + t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte") + } + if recallEligible(Message{Role: "system", Content: gros}) { + t.Fatal("un message système n'a rien à faire dans l'archive") + } + if recallEligible(um(compactSummaryPrefix + " " + gros)) { + t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe") + } +} + +// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois +// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est +// remplacer tout le passé de la conversation par une référence vide. +func TestSummaryLooksEmpty(t *testing.T) { + vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif." + if summaryLooksEmpty(vrai) { + t.Fatalf("un vrai résumé est rejeté : %q", vrai) + } + for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} { + if !summaryLooksEmpty(degenere) { + t.Fatalf("résumé dégénéré accepté : %q", degenere) + } + } +} + +// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné +// à la passe courante : sinon une conversation infinie ferait enfler cette +// section jusqu'à annuler le bénéfice de la compaction. +func TestCompactSummaryUserMsg(t *testing.T) { + sans := compactSummaryUserMsg("résumé quelconque", nil) + if !strings.HasPrefix(sans, compactSummaryPrefix) { + t.Fatal("le message de compaction doit être reconnaissable à son préfixe") + } + if strings.Contains(sans, "recall(") { + t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé") + } + avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}}) + if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") { + t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec) + } +} diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index b31c963..73533f6 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -53,10 +53,52 @@ type convMeta struct { Created int64 `json:"created"` Updated int64 `json:"updated"` Turns int `json:"turns"` + // Project = slug du projet auquel la discussion appartient (projects.go). Vide + // sur les discussions d'avant les projets : ensureDefaultProject les rattache à + // « Générale » au premier démarrage, et convIndexForProject traite un champ vide + // comme le projet par défaut pour que rien ne disparaisse entre-temps. + Project string `json:"project,omitempty"` } func convKey(id string) string { return "conv:" + id } +// convProjectOf renvoie le projet d'une discussion, en traitant l'absence comme +// le projet par défaut : une discussion sans projet ne doit jamais devenir +// invisible. +func convProjectOf(m convMeta) string { + if s := strings.TrimSpace(m.Project); s != "" { + return s + } + return defaultProjectSlug +} + +// convIndexForProject filtre l'index sur un projet. +func convIndexForProject(slug string) []convMeta { + var out []convMeta + for _, m := range convIndex() { + if convProjectOf(m) == slug { + out = append(out, m) + } + } + return out +} + +// tagOrphanConversations rattache au projet donné les discussions qui n'en ont +// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines). +func tagOrphanConversations(slug string) { + idx := convIndex() + changed := false + for i := range idx { + if strings.TrimSpace(idx[i].Project) == "" { + idx[i].Project = slug + changed = true + } + } + if changed { + convIndexSave(idx) + } +} + func convIndex() []convMeta { var idx []convMeta if b := getBytes(bkChat, ckIndex); len(b) > 0 { @@ -90,7 +132,7 @@ func convEnsureActive() string { } _ = putStr(bkChat, ckActive, id) now := time.Now().Unix() - convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now})) + convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()})) return id } @@ -116,7 +158,7 @@ func convTouchMeta(id string, title string, turns int) { convIndexSave(idx) return } - convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns})) + convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()})) } // convSummary lit le premier message utilisateur pour en faire un titre. Sans @@ -142,8 +184,40 @@ func convSummary(msgs []Message) string { return "" } -// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI). -func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() } +// ConvList renvoie les discussions DU PROJET ACTIF et l'identifiant de l'active +// (pour l'UI). Le cloisonnement est ici : une discussion d'un autre projet n'a +// aucune raison d'apparaître dans la barre latérale. +func ConvList() ([]convMeta, string) { + return convIndexForProject(activeProjectSlug()), convEnsureActive() +} + +// projectSwitch bascule sur un autre projet ET ouvre une de ses discussions : la +// plus récemment modifiée, ou une neuve s'il n'en a aucune. Sans ça, changer de +// projet laisserait à l'écran une discussion qui n'appartient plus à la liste +// affichée — et la mémoire (memoryDir) aurait changé sous ses pieds. +func projectSwitch(slug string) error { + if !projectExists(slug) { + return fmt.Errorf("projet introuvable") + } + convOpMu.Lock() + defer convOpMu.Unlock() + if slug == activeProjectSlug() { + return nil + } + conv.persist() // fige la discussion du projet qu'on quitte + if err := setActiveProject(slug); err != nil { + return err + } + list := convIndexForProject(slug) + if len(list) == 0 { + convCreate() // convIndex trie par date de modification : voir convIndex + return nil + } + // convIndex rend la plus récemment modifiée en tête. + _ = putStr(bkChat, ckActive, list[0].ID) + conv.loadFrom(getBytes(bkChat, convKey(list[0].ID))) + return nil +} // convSwitch bascule sur une autre discussion : la courante est enregistrée, // la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients @@ -179,7 +253,7 @@ func convSwitch(id string) error { func convCreate() string { id := newConvID() now := time.Now().Unix() - convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now})) + convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()})) _ = putStr(bkChat, ckActive, id) conv.loadFrom(nil) return id diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 3927941..3524cf7 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -329,6 +329,10 @@ func EnabledTools(caps Caps) []Tool { } if caps.Agent { tools = append(tools, bashTool(), writeTool(), editTool()) + // Mémoire longue de la conversation (chat_recall.go) : le compactage + // n'archive des blocs QUE quand le mode agent est actif — annoncer recall + // sans lui donnerait un outil qui ne peut rien trouver. + tools = append(tools, recallTool(), recallSearchTool()) } if caps.Code { tools = append(tools, readTool(), grepTool(), globTool(), askTool(), @@ -338,6 +342,9 @@ func EnabledTools(caps Caps) []Tool { // que le mode mémoire n'est pas « off » (que l'agent soit actif ou non). if caps.Mem != MemOff { tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool()) + // Trackers : 3e type de mémoire (données datées). Même axe que les pages — + // un seul outil pour les quatre actions, cf. trackerTool. + tools = append(tools, trackerTool()) } // Outils web : seulement si le mode agent ET l'accès internet sont actifs. // caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go), @@ -697,9 +704,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps repeatCount := map[string]int{} // Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un // appel d'outil dans leur puis émettent le token de fin SANS l'émettre - // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge - // explicite au lieu d'afficher « pas de réponse ». - nudged := false + // (ni réponse, ni tool_call). On relance alors le tour avec un nudge explicite + // au lieu d'afficher « pas de réponse ». Le premier suffit la plupart du temps ; + // un second, plus direct, rattrape le cas où le modèle re-décrit le même plan + // en boucle sans jamais l'exécuter — une simple reformulation de « agis » ne + // suffit alors plus. Borné à maxNudges : un modèle vraiment bloqué doit finir + // par rendre la main plutôt que faire attendre. + const maxNudges = 2 + nudgeCount := 0 // Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule // relance par tour, comme le nudge. patternRetried := false @@ -919,10 +931,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if cur := toolCalls[0]; cur != nil { key := "command" switch cur.Function.Name { - case "mem_search", "web_search": + case "mem_search", "web_search", "recall_search": key = "query" case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff": key = "file" + case "recall": + key = "id" case "web_open", "web_read", "web_grep": key = "url" case "grep", "glob": @@ -1103,10 +1117,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // nothing while a slow shell command runs and looks frozen. label := "" switch tc.Function.Name { - case "mem_search", "web_search": + case "mem_search", "web_search", "recall_search": label, _ = args["query"].(string) case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff": label, _ = args["file"].(string) + case "recall": + label, _ = args["id"].(string) + case "tracker": + // Libellé lisible : « nom » ou « action nom », pas un dump d'arguments. + label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"])) case "bash", "bash_bg": label, _ = args["command"].(string) case "grep", "glob": @@ -1152,6 +1171,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps continue } switch tc.Function.Name { + case "recall": + result = toolRecall(args) + case "recall_search": + result = toolRecallSearch(args) + case "tracker": + result = toolTracker(args) case "mem_search": lim := 0 if v, ok := args["limit"].(float64); ok { @@ -1368,18 +1393,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // instead of leaving the user staring at a silent, finished chat. if strings.TrimSpace(assistantContent.String()) == "" { // Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) : - // si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec - // une consigne impérative au lieu d'afficher « pas de réponse ». - if len(tools) > 0 && !disableTools && !nudged { - nudged = true + // si un modèle « pense sans agir » malgré tout, on le relance avec une + // consigne impérative au lieu d'afficher « pas de réponse ». + if len(tools) > 0 && !disableTools && nudgeCount < maxNudges { + nudgeCount++ // Le raisonnement de ce tour avorté ne mène à rien : on demande à // l'UI de l'effacer avant de relancer, pour ne pas afficher deux // blocs de réflexion successifs. cb(StreamEvent{DropReasoning: true}) - messages = append(messages, Message{ - Role: "user", - Content: "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.", - }) + nudge := "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act." + if nudgeCount > 1 { + // Le premier nudge n'a pas suffi : le modèle re-décrit le même + // plan sans l'exécuter. Second nudge plus impératif, où on lui + // interdit explicitement de re-raisonner. + nudge = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant." + } + messages = append(messages, Message{Role: "user", Content: nudge}) continue } cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) diff --git a/internal/loki/mem_index.go b/internal/loki/mem_index.go new file mode 100644 index 0000000..f8ea4bc --- /dev/null +++ b/internal/loki/mem_index.go @@ -0,0 +1,234 @@ +package loki + +// mem_index.go — maintien AUTOMATIQUE de l'index MEMORY.md du projet, par le CODE +// (pas par le modèle). À chaque création / suppression / renommage d'une page, on +// ajoute ou retire sa ligne `- [titre](fichier.md)` dans MEMORY.md. L'index +// reflète donc TOUJOURS l'état réel des pages, quel que soit le modèle — un petit +// modèle qui « oublie » de tenir son index ne peut plus le désynchroniser. Le +// modèle garde une seule liberté : enrichir l'accroche après le titre, qu'on ne +// touche jamais. +// +// L'index est ensuite INJECTÉ comme un message système au début de la +// conversation (et après un compactage) plutôt que recopié dans le prompt à +// chaque tour : le modèle sait d'emblée quelles pages existent, sans payer la +// liste à chaque échange ni avoir à lancer une recherche pour la découvrir. +// +// Best-effort partout : si l'index est illisible, on n'échoue pas — il sera +// re-synchronisé au prochain passage (reconcileMemIndex). + +import ( + "os" + "path/filepath" + "strings" +) + +const memIndexFile = "MEMORY.md" + +// memIndexPrefix marque le message d'index injecté dans l'historique, pour le +// détecter et éviter les doublons. +const memIndexPrefix = "Project memory index" + +// memIndexMessage construit le message système d'index à injecter UNE FOIS au +// début de la conversation (et après un compactage). ok=false hors mode mémoire +// proactif ou si l'index est vide. On n'injecte que l'INDEX (titres + accroches), +// jamais le contenu des pages — l'IA fait mem_read pour lire une page. +func memIndexMessage() (Message, bool) { + if memMode() != MemAlways { + return Message{}, false + } + idx := strings.TrimSpace(MemContent(memIndexFile)) + // Un index qui n'a que son en-tête ne dit rien au modèle et coûte du contexte + // à chaque tour : on attend au moins une VRAIE ligne de page. Le test est fait + // sur le début de ligne, parce que l'en-tête cite le format `- [titre](…)` en + // exemple — une simple recherche du motif le prendrait pour une page. + if idx == "" || !hasIndexedPage(idx) { + return Message{}, false + } + content := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) + + "\" — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained.\n\n" + idx + return Message{Role: "system", Content: content}, true +} + +// projectContextPrefix marque le message de contexte projet injecté (description), +// pour le détecter et éviter les doublons. +const projectContextPrefix = "Project context" + +// projectContextMessage construit le message système décrivant le projet actif à +// partir de sa description, à injecter UNE FOIS au début de la conversation (et +// après un compactage). ok=false si aucune description : l'IA sait alors juste +// dans quel projet elle est via l'index mémoire, sans laïus. Indépendant du mode +// mémoire — une description reste utile même mémoire coupée. +func projectContextMessage() (Message, bool) { + slug := activeProjectSlug() + desc := strings.TrimSpace(projectDesc(slug)) + if desc == "" { + return Message{}, false + } + content := projectContextPrefix + " — you are working within the project \"" + + projectName(slug) + "\". What this project is about (set by the user):\n\n" + desc + return Message{Role: "system", Content: content}, true +} + +// projectSystemMessages renvoie, dans l'ordre, tout ce qui situe l'IA dans son +// projet : description du projet, index mémoire, index des trackers. +// +// Ces messages sont injectés dans la vue ENVOYÉE au modèle, jamais persistés dans +// c.Messages — même traitement que le prompt système personnalisé (voir +// Conversation.generate). Deux conséquences voulues : +// - l'index est TOUJOURS à jour : une page créée au milieu d'une conversation y +// apparaît au tour suivant, sans attendre un compactage ; +// - le compactage ne peut pas les avaler, puisqu'ils ne font pas partie de +// l'historique qu'il résume — donc rien à réinjecter après coup. +// +// Le prix est une invalidation du cache de prompt quand l'index change, c'est-à- +// dire quand une page est créée ou supprimée : exactement les moments où le +// modèle DOIT voir la nouvelle liste. +func projectSystemMessages() []Message { + var out []Message + if m, ok := projectContextMessage(); ok { + out = append(out, m) + } + if m, ok := memIndexMessage(); ok { + out = append(out, m) + } + if m, ok := trackerIndexMessage(); ok { + out = append(out, m) + } + return out +} + +// hasIndexedPage dit si l'index contient au moins une ligne de page réelle +// (« - [titre](fichier.md) » en début de ligne). +func hasIndexedPage(idx string) bool { + for _, line := range strings.Split(idx, "\n") { + l := strings.TrimSpace(line) + if strings.HasPrefix(l, "- [") && strings.Contains(l, "](") { + return true + } + } + return false +} + +// isIndexFile indique si `name` est le fichier d'index (à ne jamais indexer +// lui-même : il se listerait en boucle). +func isIndexFile(name string) bool { + return strings.EqualFold(strings.TrimSpace(name), memIndexFile) +} + +// indexLineFor construit la ligne d'index d'une page : `- [titre](fichier.md)`. +// Le titre vient de la 1re ligne de la page, à défaut le nom du fichier. +func indexLineFor(name string) string { + title := name + if t := titleOf(MemContent(name)); t != "" { + title = t + } + return "- [" + title + "](" + name + ")" +} + +// indexRefFor est le motif qui identifie la ligne d'une page dans l'index : +// `](fichier.md)`. Le nom complet ET la parenthèse fermante évitent qu'un nom en +// matche un autre dont il serait le suffixe (`notes.md` vs `mes-notes.md`). +func indexRefFor(name string) string { return "](" + name + ")" } + +// writeIndex écrit le contenu de l'index. Passe par le disque directement plutôt +// que par MemSave : MemSave normalise et pourrait un jour se mettre à indexer, +// ce qui bouclerait. +func writeIndex(body string) { + dir := memoryDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + _ = os.WriteFile(filepath.Join(dir, memIndexFile), []byte(body), 0o644) +} + +// memIndexAdd ajoute la ligne d'une page à MEMORY.md si elle n'y est pas déjà. Si +// une ligne pour ce fichier existe (l'IA a pu y écrire une accroche), on la LAISSE +// telle quelle : l'index appartient au code pour sa structure, au modèle pour sa +// prose. +func memIndexAdd(name string) { + fn, err := memFileName(name) + if err != nil || isIndexFile(fn) { + return + } + ensureIndexSeed() + content := MemContent(memIndexFile) + ref := indexRefFor(fn) + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, ref) { + return // déjà indexée + } + } + writeIndex(strings.TrimRight(content, "\n") + "\n" + indexLineFor(fn) + "\n") +} + +// memIndexRemove retire la (les) ligne(s) d'une page de MEMORY.md. +func memIndexRemove(name string) { + fn, err := memFileName(name) + if err != nil || isIndexFile(fn) { + return + } + content := MemContent(memIndexFile) + if content == "" { + return + } + ref := indexRefFor(fn) + lines := strings.Split(content, "\n") + out := make([]string, 0, len(lines)) + changed := false + for _, line := range lines { + if strings.Contains(line, ref) { + changed = true + continue + } + out = append(out, line) + } + if !changed { + return + } + writeIndex(strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n") +} + +// memIndexRename retire l'ancienne ligne et ajoute la nouvelle. +func memIndexRename(oldName, newName string) { + if oldName != "" { + memIndexRemove(oldName) + } + memIndexAdd(newName) +} + +// reconcileMemIndex complète l'index du projet ACTIF : pour chaque page existante +// non référencée dans MEMORY.md, il ajoute sa ligne. ADDITIF — il ne touche ni aux +// lignes ni aux accroches déjà présentes. Indispensable après la migration vers +// les projets : les pages déplacées à la main sur le disque n'ont jamais été +// indexées, puisque l'index n'est alimenté que par la création d'une page. +func reconcileMemIndex() { + pages := MemList() + if len(pages) == 0 { + return + } + ensureIndexSeed() + content := MemContent(memIndexFile) + var add []string + for _, p := range pages { + if isIndexFile(p.Name) { + continue + } + if strings.Contains(content, indexRefFor(p.Name)) { + continue // déjà indexée + } + add = append(add, indexLineFor(p.Name)) + } + if len(add) == 0 { + return + } + writeIndex(strings.TrimRight(content, "\n") + "\n" + strings.Join(add, "\n") + "\n") +} + +// ensureIndexSeed crée MEMORY.md (avec son en-tête) s'il n'existe pas encore, pour +// que l'ajout d'une première ligne ait un fichier où s'écrire. +func ensureIndexSeed() { + if strings.TrimSpace(MemContent(memIndexFile)) != "" { + return + } + writeIndex(memorySeed(projectName(activeProjectSlug()))) +} diff --git a/internal/loki/mem_index_test.go b/internal/loki/mem_index_test.go new file mode 100644 index 0000000..2dd4b02 --- /dev/null +++ b/internal/loki/mem_index_test.go @@ -0,0 +1,152 @@ +package loki + +import ( + "strings" + "testing" +) + +// L'index est tenu par le CODE : créer une page l'y inscrit, la supprimer l'en +// retire. C'est tout l'intérêt — un modèle qui oublie de tenir son index ne peut +// plus le désynchroniser. +func TestMemIndexSuitLesPages(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + if err := MemAdd("docker.md", "# Notes Docker\n\ncontenu"); err != nil { + t.Fatal(err) + } + idx := MemContent(memIndexFile) + if !strings.Contains(idx, "](docker.md)") { + t.Fatalf("page créée absente de l'index :\n%s", idx) + } + if !strings.Contains(idx, "Notes Docker") { + t.Fatalf("l'index doit porter le titre de la page :\n%s", idx) + } + + if err := MemDelete("docker.md"); err != nil { + t.Fatal(err) + } + if idx := MemContent(memIndexFile); strings.Contains(idx, "](docker.md)") { + t.Fatalf("page supprimée toujours indexée — l'IA la chercherait en vain :\n%s", idx) + } +} + +// L'accroche écrite après le titre appartient au MODÈLE : réenregistrer la page +// ne doit pas l'effacer, sinon tout ce qu'il ajoute disparaît à la prochaine +// modification. +func TestMemIndexPreserveLAccroche(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := MemAdd("nas.md", "# NAS\n"); err != nil { + t.Fatal(err) + } + // Le modèle enrichit la ligne d'index. + enriched := strings.Replace(MemContent(memIndexFile), "- [NAS](nas.md)", "- [NAS](nas.md) — adresses IP et partages", 1) + if err := MemSave(memIndexFile, "", enriched); err != nil { + t.Fatal(err) + } + // Une réconciliation ne doit pas repasser dessus. + reconcileMemIndex() + memIndexAdd("nas.md") + + idx := MemContent(memIndexFile) + if !strings.Contains(idx, "adresses IP et partages") { + t.Fatalf("l'accroche du modèle a été écrasée :\n%s", idx) + } + if strings.Count(idx, "](nas.md)") != 1 { + t.Fatalf("ligne dupliquée pour la même page :\n%s", idx) + } +} + +// Un nom qui est le SUFFIXE d'un autre ne doit pas emporter sa ligne : c'est ce +// que garantit le motif `](fichier.md)` plutôt qu'une simple recherche du nom. +func TestMemIndexNeConfondPasLesNomsProches(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := MemAdd("notes.md", "# Notes\n"); err != nil { + t.Fatal(err) + } + if err := MemAdd("mes-notes.md", "# Mes notes\n"); err != nil { + t.Fatal(err) + } + if err := MemDelete("notes.md"); err != nil { + t.Fatal(err) + } + idx := MemContent(memIndexFile) + if !strings.Contains(idx, "](mes-notes.md)") { + t.Fatalf("la suppression de notes.md a emporté mes-notes.md :\n%s", idx) + } + if strings.Contains(idx, "](notes.md)") { + t.Fatalf("notes.md est toujours indexée :\n%s", idx) + } +} + +// L'index n'est injecté qu'en mode mémoire proactif : en « sur demande » ou +// « désactivée », le lister à chaque tour contredirait le réglage (et coûterait +// du contexte pour rien). +func TestMemIndexMessageSuitLeModeMemoire(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := MemAdd("x.md", "# X\n"); err != nil { + t.Fatal(err) + } + + if err := setMemMode(MemAlways); err != nil { + t.Fatal(err) + } + m, ok := memIndexMessage() + if !ok { + t.Fatal("aucun index injecté en mode auto") + } + if s, _ := m.Content.(string); !strings.HasPrefix(s, memIndexPrefix) || !strings.Contains(s, "](x.md)") { + t.Fatalf("message d'index mal formé : %q", s) + } + + for _, mode := range []MemMode{MemOnDemand, MemOff} { + if err := setMemMode(mode); err != nil { + t.Fatal(err) + } + if _, ok := memIndexMessage(); ok { + t.Fatalf("index injecté en mode %q", mode) + } + } +} + +// projectSystemMessages est le point d'entrée unique : il doit poser le contexte +// projet AVANT l'index mémoire (on situe le chantier, puis on liste), et ne rien +// produire quand il n'y a rien à dire. +func TestProjectSystemMessagesOrdreEtVide(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := setMemMode(MemAlways); err != nil { + t.Fatal(err) + } + + if msgs := projectSystemMessages(); len(msgs) != 0 { + t.Fatalf("%d message(s) alors qu'il n'y a ni description, ni page, ni tracker", len(msgs)) + } + + if err := setProjectDesc(defaultProjectSlug, "le NAS"); err != nil { + t.Fatal(err) + } + if err := MemAdd("x.md", "# X\n"); err != nil { + t.Fatal(err) + } + msgs := projectSystemMessages() + if len(msgs) != 2 { + t.Fatalf("%d message(s), attendu 2 (contexte + index)", len(msgs)) + } + first, _ := msgs[0].Content.(string) + second, _ := msgs[1].Content.(string) + if !strings.HasPrefix(first, projectContextPrefix) { + t.Fatalf("le contexte projet doit venir en premier : %q", first) + } + if !strings.HasPrefix(second, memIndexPrefix) { + t.Fatalf("l'index mémoire doit suivre le contexte : %q", second) + } + for _, m := range msgs { + if m.Role != "system" { + t.Fatalf("rôle %q : ces messages doivent être fusionnables en tête par normalizeSystemMessages", m.Role) + } + } +} diff --git a/internal/loki/projects.go b/internal/loki/projects.go new file mode 100644 index 0000000..98ad61a --- /dev/null +++ b/internal/loki/projects.go @@ -0,0 +1,450 @@ +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//*.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//...). 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) +} diff --git a/internal/loki/projects_test.go b/internal/loki/projects_test.go new file mode 100644 index 0000000..49d5374 --- /dev/null +++ b/internal/loki/projects_test.go @@ -0,0 +1,257 @@ +package loki + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// newProjectHome monte un LOKI_HOME neuf et repose les caches de projet : le slug +// actif est mémorisé en RAM (activeProjCache), donc sans ça un test hériterait du +// projet du précédent alors qu'il croit partir d'une base vierge. +func newProjectHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("LOKI_HOME", home) + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + setProjectOverride("") + t.Cleanup(func() { + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + setProjectOverride("") + }) + return home +} + +// La migration doit être SILENCIEUSE et sans perte : une installation d'avant les +// projets a ses pages à plat dans memory/, elles doivent se retrouver dans +// « Générale » — et y être INDEXÉES, ce que rien ne fait autrement (l'index n'est +// alimenté que par la création d'une page). +func TestEnsureDefaultProjectMigreLaMemoirePlate(t *testing.T) { + home := newProjectHome(t) + flat := filepath.Join(home, "memory") + if err := os.MkdirAll(flat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(flat, "docker.md"), []byte("# Notes Docker\n\ncontenu"), 0o644); err != nil { + t.Fatal(err) + } + + ensureDefaultProject() + + if activeProjectSlug() != defaultProjectSlug { + t.Fatalf("projet actif %q après migration, attendu %q", activeProjectSlug(), defaultProjectSlug) + } + moved := filepath.Join(flat, defaultProjectSlug, "docker.md") + if _, err := os.Stat(moved); err != nil { + t.Fatalf("la page plate n'a pas été déplacée dans le projet : %v", err) + } + if _, err := os.Stat(filepath.Join(flat, "docker.md")); err == nil { + t.Fatal("la page est restée à la racine : elle serait invisible ET dupliquée") + } + idx := MemContent(memIndexFile) + if !strings.Contains(idx, "](docker.md)") { + t.Fatalf("page migrée absente de l'index — elle ne serait jamais proposée à l'IA :\n%s", idx) + } + if !strings.Contains(idx, "Notes Docker") { + t.Fatalf("l'index doit porter le TITRE de la page, pas seulement son nom :\n%s", idx) + } +} + +// Idempotence : le démarrage passe par ensureDefaultProject à chaque fois. Un +// second passage ne doit ni redupliquer une ligne d'index, ni créer un doublon +// de projet. +func TestEnsureDefaultProjectEstIdempotent(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := MemAdd("notes.md", "# Notes\n\nx"); err != nil { + t.Fatal(err) + } + before := MemContent(memIndexFile) + + ensureDefaultProject() + + if after := MemContent(memIndexFile); after != before { + t.Fatalf("l'index a changé au 2e passage :\navant %q\naprès %q", before, after) + } + if n := len(listProjects()); n != 1 { + t.Fatalf("%d projets après deux amorçages, attendu 1", n) + } +} + +// Le cloisonnement est la promesse centrale : une page écrite dans un projet ne +// doit pas exister dans un autre. +func TestMemoireCloisonneeParProjet(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + if err := MemAdd("generale.md", "# Page de Générale\n"); err != nil { + t.Fatal(err) + } + p, err := createProject("Serveur NAS") + if err != nil { + t.Fatal(err) + } + if err := setActiveProject(p.Slug); err != nil { + t.Fatal(err) + } + if err := MemAdd("nas.md", "# Page du NAS\n"); err != nil { + t.Fatal(err) + } + + names := func() []string { + var out []string + for _, m := range MemList() { + out = append(out, m.Name) + } + return out + } + got := strings.Join(names(), " ") + if strings.Contains(got, "generale.md") { + t.Fatalf("la page de Générale est visible depuis le projet NAS : %s", got) + } + if !strings.Contains(got, "nas.md") { + t.Fatalf("la page du projet NAS est absente de son propre projet : %s", got) + } + if err := setActiveProject(defaultProjectSlug); err != nil { + t.Fatal(err) + } + if got := strings.Join(names(), " "); strings.Contains(got, "nas.md") { + t.Fatalf("la page du NAS fuit dans Générale : %s", got) + } +} + +// Renommer ne doit RIEN casser : le slug est l'identité, il ne bouge pas, donc +// les pages restent là où elles sont. +func TestRenameProjectGardeLeSlugEtLesPages(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + p, err := createProject("Chantier") + if err != nil { + t.Fatal(err) + } + if err := setActiveProject(p.Slug); err != nil { + t.Fatal(err) + } + if err := MemAdd("plan.md", "# Plan\n"); err != nil { + t.Fatal(err) + } + + if err := renameProject(p.Slug, "Chantier rénové"); err != nil { + t.Fatal(err) + } + if projectName(p.Slug) != "Chantier rénové" { + t.Fatalf("libellé non mis à jour : %q", projectName(p.Slug)) + } + if activeProjectSlug() != p.Slug { + t.Fatalf("le slug a bougé au renommage : %q", activeProjectSlug()) + } + if len(MemList()) != 2 { // la page + l'index + t.Fatalf("pages perdues au renommage : %d", len(MemList())) + } +} + +// Le dernier projet n'est pas supprimable : sans lui, il n'y a plus de mémoire du +// tout et aucun endroit où retomber. +func TestDeleteProjectRefuseLeDernier(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := deleteProject(defaultProjectSlug); err == nil { + t.Fatal("la suppression du dernier projet a été acceptée") + } +} + +// Supprimer le projet ACTIF doit rebasculer sur un autre, sinon l'application +// pointe sur un dossier qui n'existe plus. +func TestDeleteProjectActifRebascule(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + p, err := createProject("Temporaire") + if err != nil { + t.Fatal(err) + } + if err := setActiveProject(p.Slug); err != nil { + t.Fatal(err) + } + if err := deleteProject(p.Slug); err != nil { + t.Fatal(err) + } + if activeProjectSlug() == p.Slug { + t.Fatal("le projet actif est resté sur le projet supprimé") + } + if projectExists(p.Slug) { + t.Fatal("le projet supprimé figure encore au registre") + } + if _, err := os.Stat(projectMemoryDir(p.Slug)); err == nil { + t.Fatal("le dossier mémoire du projet supprimé existe encore") + } +} + +// L'override sert aux tâches planifiées : il doit dévier memoryDir SANS toucher +// au projet persisté, sinon lancer une tâche changerait le projet à l'écran. +func TestProjectOverrideNeChangePasLActif(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + p, err := createProject("Veille") + if err != nil { + t.Fatal(err) + } + seen := "" + withProject(p.Slug, func() { seen = memoryDir() }) + if seen != projectMemoryDir(p.Slug) { + t.Fatalf("memoryDir n'a pas suivi l'override : %q", seen) + } + if activeProjectSlug() != defaultProjectSlug { + t.Fatalf("l'override a changé le projet actif : %q", activeProjectSlug()) + } + if memoryDir() != projectMemoryDir(defaultProjectSlug) { + t.Fatal("memoryDir est resté sur le projet de l'override après sa libération") + } +} + +// slugifyIdent porte l'identité d'un projet ET d'un tracker : les accents doivent +// être translittérés, pas jetés — « Réseau » qui devient « r-seau » est illisible +// dans un chemin de fichier. +func TestSlugifyIdent(t *testing.T) { + cases := map[string]string{ + "Serveur NAS": "serveur-nas", + "Réseau & Wifi": "reseau-wifi", + " Déjà vu ": "deja-vu", + "Poids (kg)": "poids-kg", + "": "", + } + for in, want := range cases { + if got := slugifyIdent(in); got != want { + t.Errorf("slugifyIdent(%q) = %q, attendu %q", in, got, want) + } + } +} + +// Une description non vide doit atteindre l'IA ; une description vide ne doit pas +// produire un message de contexte creux. +func TestProjectContextMessage(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + if _, ok := projectContextMessage(); ok { + t.Fatal("un message de contexte est produit alors qu'aucune description n'existe") + } + if err := setProjectDesc(defaultProjectSlug, "administration du NAS Unraid"); err != nil { + t.Fatal(err) + } + m, ok := projectContextMessage() + if !ok { + t.Fatal("aucun message de contexte malgré une description") + } + s, _ := m.Content.(string) + if !strings.HasPrefix(s, projectContextPrefix) { + t.Fatalf("préfixe manquant, le message ne serait pas reconnaissable : %q", s) + } + if !strings.Contains(s, "NAS Unraid") { + t.Fatalf("la description n'est pas dans le message : %q", s) + } +} diff --git a/internal/loki/run.go b/internal/loki/run.go index 1e41df4..131efea 100644 --- a/internal/loki/run.go +++ b/internal/loki/run.go @@ -227,10 +227,16 @@ func readEtcDefault() string { // Arborescence de $LOKI_HOME. Elle tient en six dossiers, et rien d'autre : // tout le reste (config, préférences, conversation, clés, drapeaux) vit dans la // base loki.db — voir store.go. -func backendsDir() string { return filepath.Join(LokiHome(), "backends") } -func binDir() string { return filepath.Join(LokiHome(), "bin") } -func presetsDir() string { return filepath.Join(LokiHome(), "presets") } -func memoryDir() string { return filepath.Join(LokiHome(), "memory") } +func backendsDir() string { return filepath.Join(LokiHome(), "backends") } +func binDir() string { return filepath.Join(LokiHome(), "bin") } +func presetsDir() string { return filepath.Join(LokiHome(), "presets") } + +// memoryDir est le dossier mémoire du projet ACTIF. C'est LA couture des projets +// (projects.go) : tout le code mémoire passe par ici, donc il hérite du +// cloisonnement sans avoir à connaître la notion de projet. Avant les projets, +// memory/ était plat ; il est désormais la racine des dossiers de projet, et +// ensureDefaultProject a migré les pages historiques vers memory/generale/. +func memoryDir() string { return projectMemoryDir(activeProjectSlug()) } func modelsDir() string { return filepath.Join(LokiHome(), "models") } func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") } diff --git a/internal/loki/store.go b/internal/loki/store.go index 1c44200..46369bd 100644 --- a/internal/loki/store.go +++ b/internal/loki/store.go @@ -34,6 +34,14 @@ const ( 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é + // « / » — 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é. diff --git a/internal/loki/tasks.go b/internal/loki/tasks.go index ee3b249..2ced12b 100644 --- a/internal/loki/tasks.go +++ b/internal/loki/tasks.go @@ -12,6 +12,7 @@ import ( "encoding/hex" "encoding/json" "sort" + "strings" "time" bolt "go.etcd.io/bbolt" @@ -25,6 +26,11 @@ type Task struct { Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif) + // Project = slug du projet visé (projects.go). L'exécution force ce projet + // (setProjectOverride), donc la tâche voit LA MÉMOIRE et les trackers du bon + // chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide + // sur les tâches d'avant les projets : traitées comme « Générale ». + Project string `json:"project,omitempty"` // Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON // (tâches créées avant ces champs) garde le comportement historique : accès. NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche @@ -68,6 +74,33 @@ func listTasks() []Task { return out } +// taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut : +// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. +func taskProjectOf(t Task) string { + if s := strings.TrimSpace(t.Project); s != "" { + return s + } + return defaultProjectSlug +} + +// Le PANNEAU de l'UI et le planificateur restent GLOBAUX : une tâche doit tourner +// même si son projet n'est pas celui affiché, et masquer les tâches des autres +// projets ferait croire qu'elles ne sont plus planifiées. Le projet gouverne ce +// que la tâche VOIT quand elle s'exécute (runTask force setProjectOverride), pas +// sa visibilité. + +// tagOrphanTasks rattache au projet donné les tâches qui n'en ont pas encore +// (migration, une seule fois). +func tagOrphanTasks(slug string) { + for _, t := range listTasks() { + if strings.TrimSpace(t.Project) != "" { + continue + } + t.Project = slug + _ = saveTask(t) + } +} + // getTask lit une tâche par ID. Renvoie false si absente. func getTask(id string) (Task, bool) { var t Task diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 5d23413..20df3a6 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -75,16 +75,19 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom // contexte (conscience du mode autonome + mémoire du passage précédent), // fusionnée dans UN SEUL message système en tête — comme l'exigent les // gabarits stricts (normalizeSystemMessages le garantit de toute façon). - final := msgs + // Contexte du projet de la tâche : le projet est déjà forcé par l'appelant + // (setProjectOverride dans runTask), donc ces messages décrivent le bon + // chantier — la tâche voit la mémoire et les trackers qu'elle vise. + final := append(projectSystemMessages(), msgs...) sys := readSysPrompt() note := taskContextNote(taskName, lastReport) switch { case sys != "" && note != "": - final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...) + final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...) case sys != "": - final = append([]Message{{Role: "system", Content: sys}}, msgs...) + final = append([]Message{{Role: "system", Content: sys}}, final...) case note != "": - final = append([]Message{{Role: "system", Content: note}}, msgs...) + final = append([]Message{{Role: "system", Content: note}}, final...) } var content strings.Builder @@ -154,6 +157,14 @@ func runTask(t Task) { return } + // Projet visé : on force le projet vu par memoryDir() et par les trackers, le + // temps du passage. Sans ça, une veille rattachée au projet « NAS » écrirait + // dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule + // inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà + // rendu la main quand on libère. + setProjectOverride(taskProjectOf(t)) + defer setProjectOverride("") + report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0) // Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste diff --git a/internal/loki/tracker.go b/internal/loki/tracker.go new file mode 100644 index 0000000..d7af3f6 --- /dev/null +++ b/internal/loki/tracker.go @@ -0,0 +1,586 @@ +package loki + +// tracker.go — les TRACKERS : le 3ᵉ type de mémoire, pour les données DATÉES qui +// s'accumulent (compteurs, relevés, journaux d'événements). Séparé des pages .md +// (faits + procédures) exprès : un tracker grandit sans fin, donc on ne le lit +// JAMAIS en entier. On le consulte par NIVEAUX — vue d'ensemble → année → mois → +// événements — chaque réponse indiquant comment descendre. Le contexte n'explose +// donc jamais, quelle que soit l'ancienneté des données. +// +// Un seul outil `tracker` côté modèle (action view/add/edit/delete). Stockage +// structuré dans le bucket bkTracker, cloisonné par projet : clé +// « / ». + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// TrackerEvent = un point daté d'un tracker. TS = l'instant de l'événement (ms), +// Text = la valeur ou la note (« abonnés : 4210 », « pesée 78,2 kg »…). +type TrackerEvent struct { + ID string `json:"id"` + TS int64 `json:"ts"` + Text string `json:"text"` + // DateOnly = le point n'a PAS d'heure (juste une date). Sans ça, un point saisi + // sans heure s'enregistre à 00:00 et s'affiche « minuit », ce qui est faux : il + // n'a simplement pas d'heure. On ne montre alors que la date. + DateOnly bool `json:"date_only,omitempty"` +} + +// Tracker = un flux d'événements sous un nom (« abonnés », « poids »…). Events +// triés par TS croissant. +type Tracker struct { + Name string `json:"name"` + Events []TrackerEvent `json:"events"` +} + +// --- stockage (bucket bkTracker, clé /) ------------------------- + +// trackerStoreKey : clé de stockage d’un tracker. « Store » dans le nom parce que +// code_tracker.go porte un tout autre « tracker » (suivi lu-avant-écrit du mode +// code) et que les deux vivent dans le même paquet. +func trackerStoreKey(proj, slug string) string { return proj + "/" + slug } + +// trackerLoad lit un tracker du projet actif par son slug. false si absent. +func trackerLoad(slug string) (*Tracker, bool) { + var s Tracker + if !getJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) { + return nil, false + } + return &s, true +} + +func trackerSave(slug string, s *Tracker) error { + sort.SliceStable(s.Events, func(i, j int) bool { return s.Events[i].TS < s.Events[j].TS }) + return putJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s) +} + +// trackerKeysForProject renvoie les clés brutes du bucket appartenant à un projet. +// Sert à la suppression d'un projet (projects.go), qui emporte ses trackers. +func trackerKeysForProject(proj string) []string { + prefix := proj + "/" + var out []string + for k := range allKV(bkTracker) { + if strings.HasPrefix(k, prefix) { + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +// trackerMeta = ligne légère de la vue d'ensemble (aucun événement chargé). +type trackerMeta struct { + Slug string `json:"slug"` + Name string `json:"name"` + Count int `json:"count"` + FirstTS int64 `json:"first_ts"` + LastTS int64 `json:"last_ts"` + LastText string `json:"last_text"` // texte du dernier point (dernière valeur) + last *TrackerEvent // dernier événement, pour formater sa date selon DateOnly +} + +// trackerList renvoie les trackers du projet actif (métadonnées seulement), le +// plus récemment alimenté d'abord. +func trackerList() []trackerMeta { + prefix := activeProjectSlug() + "/" + var out []trackerMeta + for k := range allKV(bkTracker) { + if !strings.HasPrefix(k, prefix) { + continue + } + slug := strings.TrimPrefix(k, prefix) + s, ok := trackerLoad(slug) + if !ok { + continue + } + m := trackerMeta{Slug: slug, Name: s.Name, Count: len(s.Events)} + if n := len(s.Events); n > 0 { + m.FirstTS = s.Events[0].TS + last := s.Events[n-1] + m.LastTS = last.TS + m.LastText = last.Text + m.last = &last + } + out = append(out, m) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].LastTS > out[j].LastTS }) + return out +} + +// --- mutations ---------------------------------------------------------------- + +// trackerAdd ajoute un point à un tracker (créé s'il n'existe pas). `when` vide = +// maintenant. +func trackerAdd(name, when, text string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("nom de tracker vide") + } + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("contenu vide") + } + ts, dateOnly, err := parseWhen(when) + if err != nil { + return "", err + } + slug := slugifyIdent(name) + if slug == "" { + slug = "tracker" + } + s, ok := trackerLoad(slug) + if !ok { + s = &Tracker{Name: name} + } + if strings.TrimSpace(s.Name) == "" { + s.Name = name + } + id := fmt.Sprintf("%d", time.Now().UnixNano()) + s.Events = append(s.Events, TrackerEvent{ID: id, TS: ts, Text: strings.TrimSpace(text), DateOnly: dateOnly}) + if err := trackerSave(slug, s); err != nil { + return "", err + } + return id, nil +} + +// trackerEditEvent modifie un événement (texte et/ou date). when/text vides = +// inchangés. +func trackerEditEvent(slug, id, when, text string) error { + s, ok := trackerLoad(slug) + if !ok { + return fmt.Errorf("tracker introuvable") + } + for i := range s.Events { + if s.Events[i].ID != id { + continue + } + if strings.TrimSpace(text) != "" { + s.Events[i].Text = strings.TrimSpace(text) + } + if strings.TrimSpace(when) != "" { + ts, dateOnly, err := parseWhen(when) + if err != nil { + return err + } + s.Events[i].TS = ts + s.Events[i].DateOnly = dateOnly + } + return trackerSave(slug, s) + } + return fmt.Errorf("événement introuvable") +} + +// trackerDeleteEvent retire un événement ; trackerDelete supprime le tracker entier. +func trackerDeleteEvent(slug, id string) error { + s, ok := trackerLoad(slug) + if !ok { + return fmt.Errorf("tracker introuvable") + } + kept := s.Events[:0] + found := false + for _, e := range s.Events { + if e.ID == id { + found = true + continue + } + kept = append(kept, e) + } + if !found { + return fmt.Errorf("événement introuvable") + } + s.Events = kept + return trackerSave(slug, s) +} + +func trackerDelete(slug string) error { + return putBytes(bkTracker, trackerStoreKey(activeProjectSlug(), slug), nil) +} + +// trackerRename change le NOM d'un tracker du projet actif. Le slug étant dérivé +// du nom ET servant de clé de stockage comme d'identité pour l'ajout de points +// (trackerAdd re-slugifie le nom), on re-clé le blob quand le slug change : sans +// ça, un point ajouté après renommage créerait un tracker parallèle. +func trackerRename(slug, newName string) error { + newName = strings.TrimSpace(newName) + if newName == "" { + return fmt.Errorf("nom vide") + } + s, ok := trackerLoad(slug) + if !ok { + return fmt.Errorf("tracker introuvable") + } + newSlug := slugifyIdent(newName) + if newSlug == "" { + return fmt.Errorf("nom invalide") + } + if newSlug == slug { + s.Name = newName + return trackerSave(slug, s) + } + if _, exists := trackerLoad(newSlug); exists { + return fmt.Errorf("un tracker du même nom existe déjà") + } + s.Name = newName + if err := trackerSave(newSlug, s); err != nil { + return err + } + return trackerDelete(slug) +} + +// trackerMoveToProject déplace un tracker du projet actif vers un autre projet. +// Les octets sont déplacés tels quels : seule la clé change. +func trackerMoveToProject(slug, toSlug string) error { + from := activeProjectSlug() + if !projectExists(toSlug) { + return fmt.Errorf("projet cible introuvable") + } + if from == toSlug { + return nil + } + fromKey, toKey := trackerStoreKey(from, slug), trackerStoreKey(toSlug, slug) + raw := getBytes(bkTracker, fromKey) + if len(raw) == 0 { + return fmt.Errorf("tracker introuvable") + } + if len(getBytes(bkTracker, toKey)) > 0 { + return fmt.Errorf("un tracker du même nom existe déjà dans le projet cible") + } + if err := putBytes(bkTracker, toKey, raw); err != nil { + return err + } + return putBytes(bkTracker, fromKey, nil) +} + +// --- temps -------------------------------------------------------------------- + +// parseWhen lit une date à précision variable (« 2026 », « 2026-07 », +// « 2026-07-15 », « 2026-07-15 14:30 ») et renvoie l'instant en ms (heure locale). +// Vide ou « now » = maintenant. Le T de l'ISO est accepté à la place de l'espace. +// dateOnly=true quand aucune heure n'a été fournie → le point n'aura pas d'heure +// d'affichage, plutôt qu'un faux « minuit ». +func parseWhen(when string) (ts int64, dateOnly bool, err error) { + w := strings.TrimSpace(when) + if w == "" || strings.EqualFold(w, "now") || strings.EqualFold(w, "maintenant") { + return time.Now().UnixMilli(), false, nil + } + w = strings.Replace(w, "T", " ", 1) + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02 15"} { + if t, e := time.ParseInLocation(layout, w, time.Local); e == nil { + return t.UnixMilli(), false, nil + } + } + for _, layout := range []string{"2006-01-02", "2006-01", "2006"} { + if t, e := time.ParseInLocation(layout, w, time.Local); e == nil { + return t.UnixMilli(), true, nil + } + } + return 0, false, fmt.Errorf("date non comprise : %q (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", when) +} + +// fmtTS formate un instant en « AAAA-MM-JJ HH:MM » (heure locale), base du +// filtrage par préfixe et du regroupement — toujours complet. +func fmtTS(ts int64) string { return time.UnixMilli(ts).In(time.Local).Format("2006-01-02 15:04") } + +// fmtDay formate un instant en « AAAA-MM-JJ » (pour les étendues). +func fmtDay(ts int64) string { return time.UnixMilli(ts).In(time.Local).Format("2006-01-02") } + +// fmtEvent formate un point pour AFFICHAGE : sans heure si le point n'en a pas. +func fmtEvent(e TrackerEvent) string { + if e.DateOnly { + return time.UnixMilli(e.TS).In(time.Local).Format("2006-01-02") + } + return fmtTS(e.TS) +} + +// whenParts compte les composantes datées d'un `when` (0 = rien, 1 = année, +// 2 = mois, 3 = jour, 4+ = heure). Sert à décider le NIVEAU de la vue. +func whenParts(when string) int { + w := strings.TrimSpace(when) + if w == "" { + return 0 + } + w = strings.Replace(w, "T", " ", 1) + date := w + if sp := strings.IndexByte(w, ' '); sp >= 0 { + date = w[:sp] + if strings.TrimSpace(w[sp:]) != "" { + return 4 + } + } + return len(strings.Split(date, "-")) +} + +// --- vue par niveaux ---------------------------------------------------------- + +// trackerView construit la réponse texte de l'outil `tracker` en lecture. name +// vide = vue d'ensemble ; sinon on descend selon `when` (année → mois → +// événements). Chaque réponse dit comment zoomer : le modèle sait qu'il y a un +// avant et un dessous sans jamais tout charger. +func trackerView(name, when string) string { + if strings.TrimSpace(name) == "" { + return trackerOverview() + } + slug := slugifyIdent(name) + s, ok := trackerLoad(slug) + if !ok { + return fmt.Sprintf("[aucun tracker « %s »] Appelle tracker() sans argument pour la liste, ou ajoute un point avec action:\"add\".", name) + } + if len(s.Events) == 0 { + return fmt.Sprintf("Tracker « %s » : vide pour l'instant.", s.Name) + } + prefix := strings.Replace(strings.TrimSpace(when), "T", " ", 1) + var match []TrackerEvent + for _, e := range s.Events { + if prefix == "" || strings.HasPrefix(fmtTS(e.TS), prefix) { + match = append(match, e) + } + } + switch level := whenParts(when); { + case level == 0: + return trackerSkeleton(s) + case level == 1: // une année → les mois + if len(match) == 0 { + return fmt.Sprintf("Tracker « %s » : rien en %s.", s.Name, when) + } + return trackerGroup(s.Name, when, match, "2006-01", "un mois", `when:"`+when+`-07"`) + default: // mois, jour, heure → on liste les événements + return trackerEvents(s.Name, when, match) + } +} + +// trackerOverview : la liste des trackers du projet, aucun contenu. +func trackerOverview() string { + list := trackerList() + if len(list) == 0 { + return "Aucun tracker dans ce projet. Crée-en un en ajoutant un point : action:\"add\", name:\"…\", text:\"…\" (when facultatif = maintenant)." + } + var b strings.Builder + fmt.Fprintf(&b, "Trackers du projet (%d) :\n", len(list)) + for _, m := range list { + span := "" + if m.Count > 0 { + span = fmt.Sprintf(" — %d points, du %s au %s", m.Count, fmtDay(m.FirstTS), fmtDay(m.LastTS)) + } + fmt.Fprintf(&b, "- %s%s\n", m.Name, span) + } + b.WriteString("Zoom : tracker(name:\"\") pour le détail d'un tracker.") + return b.String() +} + +// trackerSkeleton : les années présentes (avec compte) + l'année en cours +// détaillée par mois. C'est la vue d'entrée d'un tracker. +func trackerSkeleton(s *Tracker) string { + var b strings.Builder + fmt.Fprintf(&b, "Tracker « %s » — %d points, du %s au %s\n", s.Name, len(s.Events), fmtDay(s.Events[0].TS), fmtDay(s.Events[len(s.Events)-1].TS)) + years := map[string]int{} + var order []string + for _, e := range s.Events { + y := time.UnixMilli(e.TS).In(time.Local).Format("2006") + if years[y] == 0 { + order = append(order, y) + } + years[y]++ + } + sort.Strings(order) + parts := make([]string, 0, len(order)) + for _, y := range order { + parts = append(parts, fmt.Sprintf("%s (%d)", y, years[y])) + } + fmt.Fprintf(&b, "Années : %s\n", strings.Join(parts, " · ")) + cur := time.Now().In(time.Local).Format("2006") + var curEv []TrackerEvent + for _, e := range s.Events { + if strings.HasPrefix(fmtTS(e.TS), cur) { + curEv = append(curEv, e) + } + } + if len(curEv) > 0 { + fmt.Fprintf(&b, "%s (année en cours) : %s\n", cur, monthsLine(curEv)) + } + fmt.Fprintf(&b, "Zoom : tracker(name:\"%s\", when:\"2025\") pour une année · when:\"%s-07\" pour un mois.", s.Name, cur) + return b.String() +} + +// trackerGroup regroupe des événements par un format de date avec leurs comptes. +func trackerGroup(name, when string, ev []TrackerEvent, layout, unit, example string) string { + groups := map[string]int{} + var order []string + for _, e := range ev { + g := time.UnixMilli(e.TS).In(time.Local).Format(layout) + if groups[g] == 0 { + order = append(order, g) + } + groups[g]++ + } + sort.Strings(order) + parts := make([]string, 0, len(order)) + for _, g := range order { + parts = append(parts, fmt.Sprintf("%s (%d)", g, groups[g])) + } + var b strings.Builder + fmt.Fprintf(&b, "Tracker « %s » en %s — %d points :\n%s\n", name, when, len(ev), strings.Join(parts, " · ")) + fmt.Fprintf(&b, "Zoom : %s pour %s.", example, unit) + return b.String() +} + +// trackerEventsCap plafonne le nombre d'événements listés d'un coup : au-delà, +// c'est le contexte qu'on gonflerait pour rien — un `when` plus précis affine. +const trackerEventsCap = 60 + +// trackerEvents liste les événements (id, date, texte), plafonné. +func trackerEvents(name, when string, ev []TrackerEvent) string { + var b strings.Builder + shown := ev + trimmed := false + if len(ev) > trackerEventsCap { + shown = ev[len(ev)-trackerEventsCap:] // les plus récents + trimmed = true + } + fmt.Fprintf(&b, "Tracker « %s »", name) + if strings.TrimSpace(when) != "" { + fmt.Fprintf(&b, " en %s", when) + } + if trimmed { + fmt.Fprintf(&b, " — %d points (les %d plus récents ; affine le when pour les plus anciens) :\n", len(ev), trackerEventsCap) + } else { + fmt.Fprintf(&b, " — %d points :\n", len(ev)) + } + for _, e := range shown { + fmt.Fprintf(&b, "- [%s] %s — %s\n", e.ID, fmtEvent(e), e.Text) + } + b.WriteString("Modifier/supprimer : action:\"edit\"|\"delete\", name, id (l'identifiant entre crochets).") + return b.String() +} + +// monthsLine résume des événements en « AAAA-MM (n) · … ». +func monthsLine(ev []TrackerEvent) string { + groups := map[string]int{} + var order []string + for _, e := range ev { + g := time.UnixMilli(e.TS).In(time.Local).Format("2006-01") + if groups[g] == 0 { + order = append(order, g) + } + groups[g]++ + } + sort.Strings(order) + parts := make([]string, 0, len(order)) + for _, g := range order { + parts = append(parts, fmt.Sprintf("%s (%d)", g, groups[g])) + } + return strings.Join(parts, " · ") +} + +// --- index injecté dans le contexte ------------------------------------------- + +// trackerIndexPrefix marque le message d'index des trackers injecté dans +// l'historique (détection / anti-doublon), comme l'index mémoire. +const trackerIndexPrefix = "Trackers (tracker tool)" + +// trackerIndexMessage construit le message système listant les trackers du projet +// avec LEUR DERNIER POINT, injecté UNE FOIS en début de conversation (et après un +// compactage). L'IA sait alors qu'ils existent ET a souvent la réponse directe — +// la dernière valeur — SANS appeler l'outil : elle ne l'appelle que pour fouiller +// l'historique ou pour modifier. Quelques lignes, jamais le contenu. +func trackerIndexMessage() (Message, bool) { + if memMode() != MemAlways { + return Message{}, false + } + list := trackerList() + if len(list) == 0 { + return Message{}, false + } + var b strings.Builder + b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n") + for _, m := range list { + last := "" + if m.last != nil { + last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")" + } + fmt.Fprintf(&b, "- %s%s\n", m.Name, last) + } + return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}, true +} + +// --- outil modèle ------------------------------------------------------------- + +// trackerTool : schéma annoncé au modèle. Un seul outil pour les quatre actions — +// quatre schémas coûteraient quatre fois le contexte pour la même chose. +func trackerTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "tracker", + Description: "Dated data piling up over time (counters, readings, logs). View drills down level by level: no name lists trackers, name gives years/months, name+when goes deeper.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []string{"view", "add", "edit", "delete"}, "description": "Default \"view\"."}, + "name": map[string]any{"type": "string", "description": "Tracker name."}, + "when": map[string]any{"type": "string", "description": "Date, as precise as needed: \"2026\", \"2026-07\", \"2026-07-15\", \"2026-07-15 14:30\"."}, + "text": map[string]any{"type": "string", "description": "Value or note (add/edit)."}, + "id": map[string]any{"type": "string", "description": "Point id, shown in brackets (edit/delete)."}, + }, + }, + }, + } +} + +// toolTracker exécute l'outil `tracker` et rend le texte vu par le modèle. +func toolTracker(args map[string]any) string { + action := strings.ToLower(strings.TrimSpace(str(args["action"]))) + name := strings.TrimSpace(str(args["name"])) + when := strings.TrimSpace(str(args["when"])) + text := strings.TrimSpace(str(args["text"])) + id := strings.TrimSpace(str(args["id"])) + + switch action { + case "", "view", "read", "list": + return trackerView(name, when) + case "add": + newID, err := trackerAdd(name, when, text) + if err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] point ajouté à « %s » (id %s).", name, newID) + case "edit": + if name == "" || id == "" { + return "[erreur] name et id sont requis pour modifier un point" + } + if err := trackerEditEvent(slugifyIdent(name), id, when, text); err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] point %s de « %s » modifié.", id, name) + case "delete": + if name == "" { + return "[erreur] name requis" + } + // Sans id, c'est le tracker ENTIER qu'on supprime : on l'exige explicite + // pour qu'une suppression de point ratée n'efface pas tout l'historique. + if id == "" { + return "[erreur] id requis — supprimer un tracker entier se fait depuis l'interface, pas par l'outil" + } + if err := trackerDeleteEvent(slugifyIdent(name), id); err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] point %s de « %s » supprimé.", id, name) + } + return fmt.Sprintf("[erreur] action inconnue %q (view, add, edit, delete)", action) +} + +// str extrait une chaîne d'un argument d'outil, tolérant au type (les modèles +// envoient volontiers un nombre là où le schéma dit « string »). +func str(v any) string { + switch s := v.(type) { + case string: + return s + case float64: + return strings.TrimSuffix(fmt.Sprintf("%.0f", s), ".0") + case nil: + return "" + } + return fmt.Sprintf("%v", v) +} diff --git a/internal/loki/tracker_test.go b/internal/loki/tracker_test.go new file mode 100644 index 0000000..727ef87 --- /dev/null +++ b/internal/loki/tracker_test.go @@ -0,0 +1,291 @@ +package loki + +import ( + "strings" + "testing" + "time" +) + +// parseWhen accepte une date à précision variable. Le point important est +// dateOnly : un relevé saisi « le 12 mars » n'a pas d'heure, l'afficher +// « 00:00 » serait une information inventée. +func TestParseWhenPrecisionVariable(t *testing.T) { + cases := []struct { + in string + dateOnly bool + }{ + {"2026", true}, + {"2026-07", true}, + {"2026-07-15", true}, + {"2026-07-15 14:30", false}, + {"2026-07-15T14:30", false}, + } + for _, c := range cases { + ts, dateOnly, err := parseWhen(c.in) + if err != nil { + t.Fatalf("parseWhen(%q) : %v", c.in, err) + } + if ts <= 0 { + t.Fatalf("parseWhen(%q) rend un instant nul", c.in) + } + if dateOnly != c.dateOnly { + t.Fatalf("parseWhen(%q) dateOnly = %v, attendu %v", c.in, dateOnly, c.dateOnly) + } + } + if _, _, err := parseWhen("la semaine dernière"); err == nil { + t.Fatal("une date incompréhensible doit être refusée, pas rangée à un instant arbitraire") + } + // Vide = maintenant, et AVEC heure : c'est une saisie à l'instant t. + ts, dateOnly, err := parseWhen("") + if err != nil || dateOnly || ts <= 0 { + t.Fatalf("parseWhen(\"\") = (%d, %v, %v), attendu maintenant avec heure", ts, dateOnly, err) + } +} + +// Un point sans heure ne doit jamais s'afficher avec un faux minuit. +func TestFmtEventSansHeure(t *testing.T) { + ts, dateOnly, err := parseWhen("2026-07-15") + if err != nil { + t.Fatal(err) + } + got := fmtEvent(TrackerEvent{TS: ts, DateOnly: dateOnly}) + if strings.Contains(got, ":") { + t.Fatalf("un point sans heure affiche une heure : %q", got) + } + if got != "2026-07-15" { + t.Fatalf("fmtEvent = %q, attendu 2026-07-15", got) + } +} + +// Le stockage est cloisonné par projet, comme les pages : un tracker d'un projet +// ne doit pas apparaître dans un autre. +func TestTrackerCloisonneParProjet(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + if _, err := trackerAdd("poids", "2026-01-02", "78,2 kg"); err != nil { + t.Fatal(err) + } + p, err := createProject("NAS") + if err != nil { + t.Fatal(err) + } + if err := setActiveProject(p.Slug); err != nil { + t.Fatal(err) + } + if n := len(trackerList()); n != 0 { + t.Fatalf("%d tracker(s) visibles depuis un projet neuf", n) + } + if _, err := trackerAdd("température", "2026-01-02", "41 °C"); err != nil { + t.Fatal(err) + } + if n := len(trackerList()); n != 1 { + t.Fatalf("%d tracker(s) dans le projet NAS, attendu 1", n) + } + if err := setActiveProject(defaultProjectSlug); err != nil { + t.Fatal(err) + } + list := trackerList() + if len(list) != 1 || list[0].Name != "poids" { + t.Fatalf("le projet Générale ne retrouve pas son tracker : %+v", list) + } +} + +// Les points sont triés par date à l'enregistrement : on peut donc saisir un +// relevé oublié après coup sans que l'historique parte en désordre. +func TestTrackerTriParDate(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + for _, when := range []string{"2026-03-01", "2026-01-01", "2026-02-01"} { + if _, err := trackerAdd("poids", when, "valeur "+when); err != nil { + t.Fatal(err) + } + } + s, ok := trackerLoad("poids") + if !ok { + t.Fatal("tracker introuvable") + } + if len(s.Events) != 3 { + t.Fatalf("%d points, attendu 3", len(s.Events)) + } + for i := 1; i < len(s.Events); i++ { + if s.Events[i-1].TS > s.Events[i].TS { + t.Fatalf("points non triés : %d après %d", s.Events[i].TS, s.Events[i-1].TS) + } + } + // La vue d'ensemble doit annoncer la DERNIÈRE valeur, pas la dernière saisie. + m := trackerList()[0] + if !strings.Contains(m.LastText, "2026-03-01") { + t.Fatalf("dernière valeur = %q, attendu celle de mars", m.LastText) + } +} + +// Renommer re-clé le tracker quand le slug change : sans ça, un point ajouté +// après renommage (trackerAdd re-slugifie le nom) créerait un tracker parallèle. +func TestTrackerRenameRecleLeTracker(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if _, err := trackerAdd("poids", "2026-01-01", "78 kg"); err != nil { + t.Fatal(err) + } + if err := trackerRename("poids", "Masse corporelle"); err != nil { + t.Fatal(err) + } + if _, ok := trackerLoad("poids"); ok { + t.Fatal("l'ancienne clé existe encore : deux trackers pour la même donnée") + } + s, ok := trackerLoad("masse-corporelle") + if !ok { + t.Fatal("tracker introuvable sous son nouveau slug") + } + if len(s.Events) != 1 { + t.Fatalf("%d point(s) après renommage, attendu 1", len(s.Events)) + } + // Un point ajouté sous le NOUVEAU nom doit rejoindre le même tracker. + if _, err := trackerAdd("Masse corporelle", "2026-02-01", "77 kg"); err != nil { + t.Fatal(err) + } + if n := len(trackerList()); n != 1 { + t.Fatalf("%d trackers après ajout post-renommage, attendu 1", n) + } +} + +// Déplacer vers un autre projet emporte les points, et ne laisse rien derrière. +func TestTrackerMoveToProject(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if _, err := trackerAdd("conso", "2026-01-01", "412 kWh"); err != nil { + t.Fatal(err) + } + p, err := createProject("Maison") + if err != nil { + t.Fatal(err) + } + if err := trackerMoveToProject("conso", p.Slug); err != nil { + t.Fatal(err) + } + if n := len(trackerList()); n != 0 { + t.Fatalf("%d tracker(s) restants dans le projet d'origine", n) + } + if err := setActiveProject(p.Slug); err != nil { + t.Fatal(err) + } + s, ok := trackerLoad("conso") + if !ok || len(s.Events) != 1 { + t.Fatalf("tracker mal arrivé dans le projet cible : ok=%v", ok) + } +} + +// La vue par niveaux ne doit JAMAIS tout charger : c'est la raison d'être des +// trackers. Sur un historique long, la vue d'entrée résume par année. +func TestTrackerViewNeChargeJamaisTout(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + // 400 points étalés sur trois ans : bien au-delà du plafond d'affichage. + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local) + for i := 0; i < 400; i++ { + d := base.AddDate(0, 0, i*3).Format("2006-01-02") + if _, err := trackerAdd("conso", d, "valeur "+d); err != nil { + t.Fatal(err) + } + } + skeleton := trackerView("conso", "") + if strings.Count(skeleton, "\n") > 10 { + t.Fatalf("la vue d'entrée déverse l'historique :\n%s", skeleton) + } + if !strings.Contains(skeleton, "Années") { + t.Fatalf("la vue d'entrée doit résumer par année :\n%s", skeleton) + } + // Une descente au mois liste les points, mais reste plafonnée. + events := trackerView("conso", "2024-01") + if n := strings.Count(events, "\n- "); n > trackerEventsCap { + t.Fatalf("%d points listés, plafond %d", n, trackerEventsCap) + } +} + +// L'index injecté porte la DERNIÈRE valeur de chaque tracker : c'est ce qui +// permet à l'IA de répondre « tu pesais 78 kg » sans appeler l'outil. +func TestTrackerIndexMessagePorteLaDerniereValeur(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + if err := setMemMode(MemAlways); err != nil { + t.Fatal(err) + } + if _, ok := trackerIndexMessage(); ok { + t.Fatal("un index est produit alors qu'aucun tracker n'existe") + } + if _, err := trackerAdd("poids", "2026-01-01", "78 kg"); err != nil { + t.Fatal(err) + } + if _, err := trackerAdd("poids", "2026-02-01", "77 kg"); err != nil { + t.Fatal(err) + } + m, ok := trackerIndexMessage() + if !ok { + t.Fatal("aucun index malgré un tracker") + } + s, _ := m.Content.(string) + if !strings.HasPrefix(s, trackerIndexPrefix) { + t.Fatalf("préfixe manquant : %q", s) + } + if !strings.Contains(s, "77 kg") { + t.Fatalf("la dernière valeur n'est pas dans l'index : %q", s) + } + if strings.Contains(s, "78 kg") { + t.Fatalf("l'index déverse l'historique au lieu du dernier point : %q", s) + } +} + +// L'outil du modèle : les quatre actions passent par un seul schéma, et +// supprimer un tracker ENTIER lui est refusé — une erreur d'id ne doit pas +// effacer des années de relevés. +func TestToolTrackerActions(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + if out := toolTracker(map[string]any{"action": "add", "name": "poids", "when": "2026-01-01", "text": "78 kg"}); !strings.HasPrefix(out, "[ok]") { + t.Fatalf("ajout refusé : %q", out) + } + s, ok := trackerLoad("poids") + if !ok || len(s.Events) != 1 { + t.Fatal("le point n'a pas été enregistré") + } + id := s.Events[0].ID + + if out := toolTracker(map[string]any{"action": "edit", "name": "poids", "id": id, "text": "77 kg"}); !strings.HasPrefix(out, "[ok]") { + t.Fatalf("modification refusée : %q", out) + } + if s, _ := trackerLoad("poids"); s.Events[0].Text != "77 kg" { + t.Fatalf("texte non modifié : %q", s.Events[0].Text) + } + + if out := toolTracker(map[string]any{"action": "delete", "name": "poids"}); !strings.HasPrefix(out, "[erreur]") { + t.Fatalf("suppression du tracker entier acceptée par l'outil : %q", out) + } + if _, ok := trackerLoad("poids"); !ok { + t.Fatal("le tracker a été supprimé malgré le refus annoncé") + } + + if out := toolTracker(map[string]any{"action": "delete", "name": "poids", "id": id}); !strings.HasPrefix(out, "[ok]") { + t.Fatalf("suppression du point refusée : %q", out) + } + if out := toolTracker(map[string]any{"action": "danser"}); !strings.HasPrefix(out, "[erreur]") { + t.Fatalf("action inconnue acceptée : %q", out) + } +} + +// str tolère qu'un modèle envoie un nombre là où le schéma annonce une chaîne : +// c'est fréquent, et refuser l'appel pour ça n'apprend rien au modèle. +func TestStrTolerantAuType(t *testing.T) { + if got := str(float64(42)); got != "42" { + t.Fatalf("str(42.0) = %q, attendu \"42\"", got) + } + if got := str(nil); got != "" { + t.Fatalf("str(nil) = %q", got) + } + if got := str("texte"); got != "texte" { + t.Fatalf("str(\"texte\") = %q", got) + } +} diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index 593287c..37f16f7 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -149,9 +149,18 @@ function renderMemList(){ const span=document.createElement('span'); const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b); if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); } + const acts=document.createElement('span'); acts.style.cssText='display:flex;gap:4px;flex:none'; const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px'; btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); }; - row.appendChild(span); row.appendChild(btn); list.appendChild(row); + acts.appendChild(btn); + // Déplacer vers un autre projet : proposé seulement s'il y en a un autre, et + // jamais sur l'index MEMORY.md, qui appartient au projet et ne se déplace pas. + if(typeof PROJECTS!=='undefined' && PROJECTS.length>1 && x.name.toUpperCase()!=='MEMORY.MD'){ + const mv=document.createElement('button'); mv.textContent='déplacer'; mv.style.cssText='margin:0;padding:2px 8px;font-size:11px'; + mv.onclick=e=>{ e.stopPropagation(); moveMemPageUI(x.name); }; + acts.appendChild(mv); + } + row.appendChild(span); row.appendChild(acts); list.appendChild(row); }); if(matches.length > shown.length){ const more=document.createElement('div'); more.className='mem-more'; @@ -451,7 +460,7 @@ async function loadAll(){ // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // suppression de module doit donc retirer son appel de cette ligne, et le test // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index c07b23d..84d6556 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -269,6 +269,10 @@ function renderToolMsg(el, tu){ git_status: {lbl:'git', head:'git status'}, git_diff: {lbl:'git', head:'git diff'}, git_clone: {lbl:'git', head:'git clone'}, + // Mémoire longue de la conversation : blocs archivés au compactage. Étiquette + // distincte de « mémoire » (les pages MEMORY), ce n'est pas la même chose. + recall: {lbl:'archive', head:'rappel d’un bloc archivé'}, + recall_search: {lbl:'archive', head:'recherche dans l’archive'}, }; // Outils MCP (nom mcp____) : en-tête = nom du serveur, libellé lisible, // pas le fallback mémoire. On extrait serveur et outil du nom namespacé. diff --git a/internal/loki/ui/src/js/21-tasks.js b/internal/loki/ui/src/js/21-tasks.js index c7c7d79..2908f16 100644 --- a/internal/loki/ui/src/js/21-tasks.js +++ b/internal/loki/ui/src/js/21-tasks.js @@ -11,6 +11,7 @@ let tasksList = []; let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé) let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune) let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale) +let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé ») let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche) let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche) let tasksPollTimer = null; @@ -33,6 +34,7 @@ function renderTasks(r){ const paused = !!(r && r.paused); const agentOn = !!(r && r.agent); TASK_PRESETS = (r && r.presets) || []; + TASK_PROJECTS = (r && r.projects) || []; TASK_MEM_ON = !r || r.mem_on !== false; TASK_WEB_ON = !r || r.web_on !== false; TASK_RUNNING = (r && r.running_id) || ''; @@ -193,6 +195,9 @@ function openTask(id){ document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON; document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON; fillPresetSelect(t ? (t.preset||'') : ''); + // Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une + // tâche pour le chantier qu'on a sous les yeux. + fillTaskProjectSelect(t ? (t.project||'') : (typeof PROJ_ACTIVE!=='undefined' ? PROJ_ACTIVE : '')); // Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle : // « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours). @@ -265,6 +270,20 @@ function fillPresetSelect(selected){ sel.value = selected || ''; } +// fillTaskProjectSelect peuple le sélecteur « projet visé ». Pas d'option vide : +// une tâche appartient toujours à un projet — le serveur retombe sur le projet +// actif si rien n'est envoyé, autant le montrer explicitement. +function fillTaskProjectSelect(selected){ + const sel = document.getElementById('task-project'); + if(!sel) return; + sel.textContent = ''; + TASK_PROJECTS.forEach(p=>{ + const o = document.createElement('option'); o.value = p.slug; o.textContent = p.name; + sel.appendChild(o); + }); + if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; +} + function setTaskFreqMode(mode){ const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]'); if(r) r.checked = true; @@ -304,13 +323,14 @@ async function saveTask(){ const schedule = buildSchedule(); const enabled = document.getElementById('task-enabled').checked; const preset = document.getElementById('task-preset').value; + const project = (document.getElementById('task-project')||{}).value || ''; const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || ''; const no_mem = !document.getElementById('task-mem').checked; const no_web = !document.getElementById('task-web').checked; const st = document.getElementById('task-modal-status'); if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; } if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; } - const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web}); + const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; } closeTask(); await loadTasks(); diff --git a/internal/loki/ui/src/js/22-settings-modal.js b/internal/loki/ui/src/js/22-settings-modal.js index 27a6863..9dae571 100644 --- a/internal/loki/ui/src/js/22-settings-modal.js +++ b/internal/loki/ui/src/js/22-settings-modal.js @@ -15,6 +15,14 @@ let SET_LAST = 'config'; const SET_HOOKS = { 'engine-log': () => { loadSvcLog(); showPaths(); }, 'dictee': () => { loadDictateSettings(); }, + // Les compteurs d'un projet (discussions, pages, trackers) bougent en + // permanence : on les relit à l'ouverture plutôt que d'afficher ceux du + // chargement de la page. + 'projets': () => { loadProjects(); }, + // Le prompt système appartient au PRESET actif : changer de modèle change le + // texte à afficher. On le relit à l'ouverture du panneau plutôt que de garder + // celui du chargement de la page, qui serait alors celui d'un autre preset. + 'sysprompt': () => { loadSys(); }, }; function openSettings(pane){ showModal('settings-modal'); diff --git a/internal/loki/ui/src/js/25-projects.js b/internal/loki/ui/src/js/25-projects.js new file mode 100644 index 0000000..c936726 --- /dev/null +++ b/internal/loki/ui/src/js/25-projects.js @@ -0,0 +1,184 @@ +// Projets — un chantier = une mémoire + ses discussions + ses trackers. +// +// Deux surfaces, volontairement asymétriques : +// - le SÉLECTEUR de la barre latérale, qu'on utilise dix fois par jour : il ne +// fait qu'une chose, changer de projet ; +// - le PANNEAU de réglages, qu'on ouvre rarement : créer, renommer, décrire, +// supprimer. +// +// Changer de projet change ce que voit l'IA (mémoire, trackers) ET la liste des +// discussions : le serveur ouvre une discussion du projet d'arrivée, et le flux +// SSE redessine le chat tout seul — comme une bascule de discussion. + +let PROJECTS = [], PROJ_ACTIVE = ''; + +async function loadProjects(){ + let r; + try{ r = await jget('/api/projects'); }catch(_){ return; } + if(!r || !r.ok) return; + PROJECTS = r.projects || []; + PROJ_ACTIVE = r.active || ''; + renderProjectPicker(); + renderProjectList(); +} + +// Le sélecteur de la barre latérale. Reconstruit à chaque chargement : la liste +// est courte, et un rendu complet évite de synchroniser des options une par une. +function renderProjectPicker(){ + const sel = document.getElementById('proj-switch'); + if(!sel) return; + sel.textContent = ''; + PROJECTS.forEach(p => { + const o = document.createElement('option'); + o.value = p.slug; + o.textContent = p.name; + sel.appendChild(o); + }); + sel.value = PROJ_ACTIVE; +} + +async function onProjectSwitch(sel){ + const slug = sel.value; + if(!slug || slug === PROJ_ACTIVE) return; + const r = await jpost('/api/projects', {action:'switch', slug}); + if(!r.ok){ + // Refusé (génération en cours) : on remet le sélecteur sur le projet réel, + // sinon il affiche un projet où l'on n'est pas. + sel.value = PROJ_ACTIVE; + toast('erreur : ' + (r.error||'')); + return; + } + PROJECTS = r.projects || PROJECTS; + PROJ_ACTIVE = r.active || PROJ_ACTIVE; + renderProjectPicker(); + renderProjectList(); + // La mémoire, les trackers et les discussions appartiennent au projet : tout + // ce qui est à l'écran parle du précédent. + if(typeof loadConversations === 'function') loadConversations(); + if(typeof loadAgent === 'function') loadAgent(); + if(typeof loadTrackers === 'function') loadTrackers(); +} + +// Le panneau de gestion. Une ligne par projet, avec ce qu'il contient — c'est ce +// qui permet de décider quoi supprimer sans ouvrir chaque projet. +function renderProjectList(){ + const list = document.getElementById('proj-list'); + if(!list) return; + list.textContent = ''; + PROJECTS.forEach(p => { + const row = document.createElement('div'); + row.className = 'preset'; + row.style.fontSize = '12px'; + + const span = document.createElement('span'); + const b = document.createElement('b'); + b.style.color = 'var(--text)'; + b.textContent = p.name; + span.appendChild(b); + if(p.active){ + const a = document.createElement('span'); + a.className = 'sbadge on'; + a.textContent = 'actif'; + a.style.marginLeft = '6px'; + span.appendChild(a); + } + const d = document.createElement('span'); + d.className = 'muted'; + d.textContent = ' — ' + p.convs + ' discussion' + (p.convs > 1 ? 's' : '') + + ' · ' + p.pages + ' page' + (p.pages > 1 ? 's' : '') + + ' · ' + p.trackers + ' tracker' + (p.trackers > 1 ? 's' : ''); + span.appendChild(d); + if(p.desc){ + const desc = document.createElement('div'); + desc.className = 'muted'; + desc.style.cssText = 'font-size:11px;margin-top:2px'; + desc.textContent = p.desc; + span.appendChild(desc); + } + + const acts = document.createElement('span'); + acts.style.cssText = 'display:flex;gap:4px;flex:none'; + acts.appendChild(projBtn('décrire', () => editProjectDesc(p))); + acts.appendChild(projBtn('renommer', () => renameProjectUI(p))); + // Le dernier projet n'est pas supprimable côté serveur : ne pas proposer le + // bouton évite une erreur qu'on ne peut pas résoudre. + if(PROJECTS.length > 1) acts.appendChild(projBtn('supprimer', () => deleteProjectUI(p))); + + row.appendChild(span); + row.appendChild(acts); + list.appendChild(row); + }); +} + +function projBtn(label, fn){ + const b = document.createElement('button'); + b.textContent = label; + b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px'; + b.onclick = e => { e.stopPropagation(); fn(); }; + return b; +} + +// Applique une action et rafraîchit tout ce qui dépend des projets. +async function projectAction(payload, okMsg){ + const r = await jpost('/api/projects', payload); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; } + PROJECTS = r.projects || PROJECTS; + PROJ_ACTIVE = r.active || PROJ_ACTIVE; + renderProjectPicker(); + renderProjectList(); + if(okMsg) toast(okMsg); + return true; +} + +async function newProject(){ + const name = await askPrompt('Nom du projet (ex. « Serveur NAS », « Recettes ») :', {title:'Nouveau projet', okText:'Créer'}); + if(!name || !name.trim()) return; + await projectAction({action:'create', name:name.trim()}, 'projet créé'); +} + +async function renameProjectUI(p){ + const name = await askPrompt('Nouveau nom :', {title:'Renommer le projet', default:p.name, okText:'Renommer'}); + if(!name || !name.trim() || name.trim() === p.name) return; + // Seul le libellé change : les chemins disque, les discussions et les trackers + // restent accrochés au slug d'origine. + await projectAction({action:'rename', slug:p.slug, name:name.trim()}, 'renommé'); +} + +async function editProjectDesc(p){ + const desc = await askPrompt( + 'À quoi sert ce projet ? Ce texte est donné à l\'IA au début de chaque discussion du projet.', + {title:'Description du projet', default:p.desc||'', placeholder:'ex. administration du NAS Unraid, docker, sauvegardes', okText:'Enregistrer'}); + if(desc === null) return; + await projectAction({action:'desc', slug:p.slug, desc}, 'description enregistrée'); +} + +async function deleteProjectUI(p){ + const ok = await askConfirm( + 'Supprimer « ' + p.name + ' » ?\n\nSes ' + p.pages + ' page(s) mémoire, ses ' + + p.convs + ' discussion(s) et ses ' + p.trackers + ' tracker(s) seront effacés. Irréversible.', + {title:'Supprimer le projet', okText:'Supprimer', danger:true}); + if(!ok) return; + await projectAction({action:'delete', slug:p.slug}, 'projet supprimé'); + if(typeof loadConversations === 'function') loadConversations(); + if(typeof loadAgent === 'function') loadAgent(); + if(typeof loadTrackers === 'function') loadTrackers(); +} + +// Déplacer une page mémoire vers un autre projet — appelé depuis la liste des +// pages (06-settings.js). Une page rangée dans le mauvais chantier n'a pas à +// être recopiée à la main pour en changer. +async function moveMemPageUI(name){ + const others = PROJECTS.filter(p => p.slug !== PROJ_ACTIVE); + if(!others.length){ toast('aucun autre projet où déplacer'); return; } + const choice = await askPrompt( + 'Déplacer « ' + name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'), + {title:'Déplacer la page', default:others[0].name, okText:'Déplacer'}); + if(!choice) return; + const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase()); + if(!target){ toast('projet inconnu : ' + choice); return; } + const r = await jpost('/api/mem/move', {name, to:target.slug}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('déplacée vers ' + target.name); + if(typeof loadAgent === 'function') loadAgent(); + loadProjects(); +} diff --git a/internal/loki/ui/src/js/26-tracker.js b/internal/loki/ui/src/js/26-tracker.js new file mode 100644 index 0000000..1f1889c --- /dev/null +++ b/internal/loki/ui/src/js/26-tracker.js @@ -0,0 +1,226 @@ +// Trackers — le 3ᵉ type de mémoire : les données DATÉES qui s'accumulent +// (compteurs, relevés, journaux d'événements). +// +// Une page mémoire répond à « qu'est-ce que je sais » ; un tracker répond à +// « comment ça évolue ». D'où deux affichages distincts : la liste montre la +// DERNIÈRE valeur de chaque tracker (c'est presque toujours la question posée), +// le détail montre les points, du plus récent au plus ancien. +// +// La liste ne charge jamais les points : un tracker peut compter des milliers +// d'entrées, et le panneau doit s'ouvrir instantanément. + +let TRACKERS = [], TRACK_OPEN = null; + +async function loadTrackers(){ + let r; + try{ r = await jget('/api/tracker'); }catch(_){ return; } + if(!r || !r.ok) return; + TRACKERS = r.trackers || []; + renderTrackers(); +} + +function toggleTrackers(){ + const body = document.getElementById('track-body'); + if(!body) return; + body.hidden = !body.hidden; + if(!body.hidden) loadTrackers(); +} + +// Date lisible : on n'affiche l'heure que si le point en a une côté serveur — +// un relevé saisi « le 12 mars » ne doit pas s'afficher « 00:00 ». +function trackWhen(ev){ + const d = new Date(ev.ts); + const day = d.toLocaleDateString(); + return ev.date_only ? day : day + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); +} + +function renderTrackers(){ + const list = document.getElementById('track-list'); + const count = document.getElementById('track-count'); + if(!list) return; + if(count) count.textContent = TRACKERS.length ? '(' + TRACKERS.length + ')' : ''; + list.textContent = ''; + if(!TRACKERS.length){ + const e = document.createElement('div'); + e.className = 'muted'; + e.style.fontSize = '12px'; + e.textContent = '(aucun tracker — ex. « poids », « abonnés », « relevés compteur »)'; + list.appendChild(e); + return; + } + TRACKERS.forEach(t => { + const row = document.createElement('div'); + row.className = 'preset'; + row.style.fontSize = '12px'; + row.onclick = () => openTracker(t.slug); + + const span = document.createElement('span'); + const b = document.createElement('b'); + b.style.color = 'var(--text)'; + b.textContent = t.name; + span.appendChild(b); + const d = document.createElement('span'); + d.className = 'muted'; + // La dernière valeur EST l'information : elle passe avant le compte. + d.textContent = t.last_text ? ' — ' + t.last_text : ' — (vide)'; + span.appendChild(d); + const sub = document.createElement('div'); + sub.className = 'muted'; + sub.style.cssText = 'font-size:11px;margin-top:2px'; + sub.textContent = t.count + ' point' + (t.count > 1 ? 's' : '') + + (t.last_ts ? ' · dernier le ' + new Date(t.last_ts).toLocaleDateString() : ''); + span.appendChild(sub); + + row.appendChild(span); + list.appendChild(row); + }); +} + +// --- détail d'un tracker ----------------------------------------------------- + +async function openTracker(slug){ + const r = await jget('/api/tracker?slug=' + encodeURIComponent(slug)); + if(!r || !r.ok){ toast('erreur : ' + ((r&&r.error)||'')); return; } + TRACK_OPEN = r.tracker; + document.getElementById('track-modal-title').textContent = TRACK_OPEN.name; + renderTrackerEvents(); + showModal('track-modal'); +} + +function closeTracker(){ hideModal('track-modal'); TRACK_OPEN = null; } + +function renderTrackerEvents(){ + const body = document.getElementById('track-events'); + if(!body || !TRACK_OPEN) return; + body.textContent = ''; + const evs = (TRACK_OPEN.events || []).slice().reverse(); // plus récent d'abord + if(!evs.length){ + const e = document.createElement('div'); + e.className = 'muted'; + e.style.fontSize = '12px'; + e.textContent = '(aucun point)'; + body.appendChild(e); + return; + } + evs.forEach(ev => { + const row = document.createElement('div'); + row.className = 'preset'; + row.style.fontSize = '12px'; + const span = document.createElement('span'); + const w = document.createElement('b'); + w.style.color = 'var(--text)'; + w.textContent = trackWhen(ev); + span.appendChild(w); + const t = document.createElement('span'); + t.className = 'muted'; + t.textContent = ' — ' + ev.text; + span.appendChild(t); + + const acts = document.createElement('span'); + acts.style.cssText = 'display:flex;gap:4px;flex:none'; + acts.appendChild(trackBtn('modifier', () => editTrackerPoint(ev))); + acts.appendChild(trackBtn('supprimer', () => deleteTrackerPoint(ev))); + + row.appendChild(span); + row.appendChild(acts); + body.appendChild(row); + }); +} + +function trackBtn(label, fn){ + const b = document.createElement('button'); + b.textContent = label; + b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px'; + b.onclick = e => { e.stopPropagation(); fn(); }; + return b; +} + +// Applique une action sur le tracker ouvert, puis recharge son détail ET la +// liste : les deux affichent la même donnée, ils ne doivent pas diverger. +async function trackerAction(payload, okMsg){ + const r = await jpost('/api/tracker', payload); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; } + TRACKERS = r.trackers || TRACKERS; + renderTrackers(); + if(okMsg) toast(okMsg); + return true; +} + +// La date est saisie en texte libre à précision variable (« 2026-07-15 », +// « 2026-07-15 14:30 », vide = maintenant) : c'est le serveur qui la comprend +// (parseWhen), pour que l'outil du modèle et l'interface acceptent exactement +// la même chose. +const TRACK_WHEN_HINT = 'Date (vide = maintenant) — AAAA-MM-JJ ou AAAA-MM-JJ HH:MM'; + +async function addTrackerPoint(){ + if(!TRACK_OPEN) return; + const text = await askPrompt('Valeur ou note du point :', {title:'Ajouter un point', placeholder:'ex. 78,2 kg', okText:'Suivant'}); + if(!text || !text.trim()) return; + const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Ajouter'}); + if(when === null) return; + if(!await trackerAction({action:'add', name:TRACK_OPEN.name, when, text}, 'point ajouté')) return; + openTracker(TRACK_OPEN.slug); +} + +async function newTracker(){ + const name = await askPrompt('Nom du tracker (ex. « poids », « abonnés ») :', {title:'Nouveau tracker', okText:'Suivant'}); + if(!name || !name.trim()) return; + const text = await askPrompt('Premier point — valeur ou note :', {title:'Premier point', placeholder:'ex. 78,2 kg', okText:'Suivant'}); + if(!text || !text.trim()) return; + const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Créer'}); + if(when === null) return; + // Un tracker naît de son premier point : pas de coquille vide à remplir plus tard. + await trackerAction({action:'add', name:name.trim(), when, text}, 'tracker créé'); +} + +async function editTrackerPoint(ev){ + if(!TRACK_OPEN) return; + const text = await askPrompt('Valeur ou note :', {title:'Modifier le point', default:ev.text, okText:'Suivant'}); + if(text === null) return; + const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', default:'', placeholder:'vide = date inchangée', okText:'Enregistrer'}); + if(when === null) return; + if(!await trackerAction({action:'edit', slug:TRACK_OPEN.slug, id:ev.id, text, when}, 'point modifié')) return; + openTracker(TRACK_OPEN.slug); +} + +async function deleteTrackerPoint(ev){ + if(!TRACK_OPEN) return; + if(!await askConfirm('Supprimer le point du ' + trackWhen(ev) + ' ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return; + if(!await trackerAction({action:'delete', slug:TRACK_OPEN.slug, id:ev.id}, 'point supprimé')) return; + openTracker(TRACK_OPEN.slug); +} + +async function renameTracker(){ + if(!TRACK_OPEN) return; + const name = await askPrompt('Nouveau nom :', {title:'Renommer le tracker', default:TRACK_OPEN.name, okText:'Renommer'}); + if(!name || !name.trim() || name.trim() === TRACK_OPEN.name) return; + // Le slug dérive du nom : le serveur re-clé le tracker si nécessaire, sinon un + // point ajouté ensuite créerait un tracker parallèle sous l'ancien nom. + if(!await trackerAction({action:'rename', slug:TRACK_OPEN.slug, name:name.trim()}, 'renommé')) return; + closeTracker(); +} + +async function moveTracker(){ + if(!TRACK_OPEN) return; + const others = (typeof PROJECTS !== 'undefined' ? PROJECTS : []).filter(p => p.slug !== PROJ_ACTIVE); + if(!others.length){ toast('aucun autre projet où déplacer'); return; } + const choice = await askPrompt( + 'Déplacer « ' + TRACK_OPEN.name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'), + {title:'Déplacer le tracker', default:others[0].name, okText:'Déplacer'}); + if(!choice) return; + const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase()); + if(!target){ toast('projet inconnu : ' + choice); return; } + if(!await trackerAction({action:'move', slug:TRACK_OPEN.slug, to:target.slug}, 'déplacé vers ' + target.name)) return; + closeTracker(); + if(typeof loadProjects === 'function') loadProjects(); +} + +async function deleteTracker(){ + if(!TRACK_OPEN) return; + const ok = await askConfirm( + 'Supprimer le tracker « ' + TRACK_OPEN.name + ' » et ses ' + TRACK_OPEN.count + ' point(s) ? Irréversible.', + {title:'Supprimer le tracker', okText:'Supprimer', danger:true}); + if(!ok) return; + if(!await trackerAction({action:'delete_tracker', slug:TRACK_OPEN.slug}, 'tracker supprimé')) return; + closeTracker(); +} diff --git a/internal/loki/web_projects.go b/internal/loki/web_projects.go new file mode 100644 index 0000000..b02a224 --- /dev/null +++ b/internal/loki/web_projects.go @@ -0,0 +1,137 @@ +package loki + +// web_projects.go — l'API du hub des projets. Un seul endpoint verbe-orienté +// (/api/projects) plutôt qu'une route par action : le hub fait six choses sur le +// même objet, six routes n'auraient rien clarifié. +// +// Toutes les mutations passent par projects.go, qui porte les invariants (slug +// unique, dernier projet indestructible, réassignation de l'actif). + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// projectDTO est la vue d'un projet renvoyée à l'UI : le registre, plus ce qui +// se compte (discussions, pages, trackers) pour que le hub soit lisible sans +// ouvrir chaque projet. +type projectDTO struct { + Slug string `json:"slug"` + Name string `json:"name"` + Desc string `json:"desc,omitempty"` + Created int64 `json:"created"` + Convs int `json:"convs"` + Pages int `json:"pages"` + Trackers int `json:"trackers"` + Active bool `json:"active"` +} + +func projectDTOs() []projectDTO { + active := activeProjectSlug() + // Un seul parcours de l'index des discussions pour tous les projets : compter + // projet par projet relirait la base autant de fois qu'il y a de projets. + convs := map[string]int{} + for _, m := range convIndex() { + convs[convProjectOf(m)]++ + } + out := make([]projectDTO, 0, len(listProjects())) + for _, p := range listProjects() { + d := projectDTO{ + Slug: p.Slug, Name: p.Name, Desc: p.Desc, Created: p.CreatedAt, + Convs: convs[p.Slug], Trackers: len(trackerKeysForProject(p.Slug)), + Active: p.Slug == active, + } + // Les pages se comptent dans le dossier du projet : on emprunte memoryDir + // le temps du comptage plutôt que de dupliquer la lecture du dossier. + // MEMORY.md est exclu — c'est l'index du projet, pas une page de contenu : + // l'annoncer ferait afficher « 1 page » sur un projet tout neuf et vide. + withProject(p.Slug, func() { + for _, m := range MemList() { + if !isIndexFile(m.Name) { + d.Pages++ + } + } + }) + out = append(out, d) + } + return out +} + +// handleProjects : +// +// GET → {ok, projects, active} +// POST {action, ...} → create | rename | desc | delete | switch +func handleProjects(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()}) + case http.MethodPost: + var body struct { + Action string `json:"action"` + Slug string `json:"slug"` + Name string `json:"name"` + Desc string `json:"desc"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + slug := strings.TrimSpace(body.Slug) + var err error + switch strings.ToLower(strings.TrimSpace(body.Action)) { + case "create": + _, err = createProject(body.Name) + case "rename": + err = renameProject(slug, body.Name) + case "desc": + err = setProjectDesc(slug, body.Desc) + case "delete": + // Une génération en cours travaille dans la mémoire du projet actif : + // supprimer sous ses pieds la laisserait écrire dans le vide. + if conv.isGenerating() { + sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"}) + return + } + err = deleteProject(slug) + case "switch": + if conv.isGenerating() { + sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"}) + return + } + err = projectSwitch(slug) + default: + err = fmt.Errorf("action inconnue : %q", body.Action) + } + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()}) + default: + sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"}) + } +} + +// handleMemMove déplace une page mémoire vers un autre projet. Séparé de +// /api/projects : c'est une opération sur une PAGE, pas sur un projet. +func handleMemMove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"}) + return + } + var body struct { + Name string `json:"name"` + To string `json:"to"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := moveMemPage(body.Name, activeProjectSlug(), strings.TrimSpace(body.To)); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 4168116..fb01d76 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -99,6 +99,10 @@ func cmdWeb(args []string) error { var convLoadOnce sync.Once func newWebMux() *http.ServeMux { + // Projets : amorce « Générale » et migre l'existant (mémoire plate, discussions + // et tâches sans projet). AVANT le chargement de la conversation, qui consulte + // déjà le projet actif. Idempotent : sans effet aux démarrages suivants. + ensureDefaultProject() // Charge l'état de conversation persisté (une fois par process : loki web ET // loki link serve appellent newWebMux). convLoadOnce.Do(LoadConversation) @@ -168,8 +172,8 @@ func newWebMux() *http.ServeMux { api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) } api("/api/ping", handlePing) api("/api/status", handleStatus) - api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local) - api("/api/dictate/config", handleDictateConfig) // réglages : modèle, langue, matériel, réactivité + api("/api/transcribe", handleTranscribe) // dictée vocale (Parakeet via sherpa-onnx, local) + api("/api/dictate/config", handleDictateConfig) // réglages : modèle et réactivité api("/api/dictate/models", handleDictateModels) // catalogue + présence sur disque api("/api/dictate/models/download", handleDictateDownload) // télécharge un modèle api("/api/dictate/state", handleDictateState) // serveur allumé, modèle chargé, dernière erreur @@ -267,6 +271,11 @@ func newWebMux() *http.ServeMux { api("/api/mem", handleMem) api("/api/mem/save", handleMemSave) api("/api/mem/delete", handleMemDelete) + api("/api/mem/move", handleMemMove) + // Projets (mémoire, discussions et tâches cloisonnées) et trackers (3e type de + // mémoire) : un endpoint verbe-orienté chacun, cf. web_projects.go/web_tracker.go. + api("/api/projects", handleProjects) + api("/api/tracker", handleTracker) api("/api/switch", handleSwitch) api("/api/start", svcHandler("start")) api("/api/stop", svcHandler("stop")) diff --git a/internal/loki/web_sysprompt.go b/internal/loki/web_sysprompt.go index be87280..9146f6d 100644 --- a/internal/loki/web_sysprompt.go +++ b/internal/loki/web_sysprompt.go @@ -13,11 +13,57 @@ import ( "strings" ) -// readSysPrompt renvoie le prompt système personnalisé ("" si absent). -func readSysPrompt() string { return getStr(bkState, "sysprompt") } +// Le prompt était GLOBAL : un seul texte pour tous les modèles. En pratique il ne +// l'est pas — la consigne qui va bien à un modèle à raisonnement en dessert un +// petit modèle d'instruction, et changer de preset obligeait à réécrire le champ +// à la main. Il est donc rattaché AU PRESET, et bascule avec lui. +// +// Il vit en base (une clé par preset) et NON dans le fichier .env du preset : +// un .env est lu ligne à ligne, il ne peut pas porter un texte multiligne — et +// un prompt système en fait presque toujours plusieurs. +// +// La clé globale historique reste le REPLI : une installation qui avait un prompt +// le garde tant que le preset actif n'en définit pas, plutôt que de se retrouver +// silencieusement sans consigne après mise à jour. +const ( + sysPromptGlobalKey = "sysprompt" + sysPromptKeyPrefix = "sysprompt:" +) +// activePresetID renvoie l'id du preset actif ("" si aucun ne correspond à la +// configuration courante — config bricolée à la main, ou aucun preset créé). +func activePresetID() string { + list, err := ListPresets() + if err != nil { + return "" + } + for _, p := range list { + if p.Active { + return p.ID + } + } + return "" +} + +// readSysPrompt renvoie le prompt système à appliquer ("" si aucun). +func readSysPrompt() string { + if id := activePresetID(); id != "" { + if s := strings.TrimSpace(getStr(bkState, sysPromptKeyPrefix+id)); s != "" { + return s + } + } + return getStr(bkState, sysPromptGlobalKey) +} + +// saveSysPrompt écrit le prompt système DU PRESET ACTIF. Sans preset identifiable, +// on écrit le global — c'est le seul emplacement qui aura un effet, et le champ +// doit rester utilisable sur une installation sans preset. func saveSysPrompt(text string) error { - return putStr(bkState, "sysprompt", strings.TrimSpace(text)) + text = strings.TrimSpace(text) + if id := activePresetID(); id != "" { + return putStr(bkState, sysPromptKeyPrefix+id, text) + } + return putStr(bkState, sysPromptGlobalKey, text) } // handleSysPrompt : diff --git a/internal/loki/web_sysprompt_test.go b/internal/loki/web_sysprompt_test.go new file mode 100644 index 0000000..24db788 --- /dev/null +++ b/internal/loki/web_sysprompt_test.go @@ -0,0 +1,134 @@ +package loki + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTestPreset pose un preset minimal et renvoie son id. Le contenu doit +// différer d'un preset à l'autre : c'est l'empreinte du contenu qui décide +// lequel est « actif » (presetFingerprint). +func writeTestPreset(t *testing.T, id, model string) string { + t.Helper() + dir := presetsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "# NAME=" + id + "\nMODEL=" + model + "\nCTX=8192\n" + if err := os.WriteFile(filepath.Join(dir, id+".env"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return id +} + +// Le prompt système appartient au PRESET : basculer de modèle doit changer la +// consigne, pas la traîner d'un modèle à l'autre. C'était le défaut du prompt +// global — une consigne écrite pour un modèle à raisonnement suivait un petit +// modèle d'instruction, qu'elle dessert. +func TestSysPromptSuitLePresetActif(t *testing.T) { + newProjectHome(t) + a := writeTestPreset(t, "modele-a", "/models/a.gguf") + b := writeTestPreset(t, "modele-b", "/models/b.gguf") + + if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil { + t.Fatal(err) + } + if id := activePresetID(); id != a { + t.Fatalf("preset actif %q, attendu %q", id, a) + } + if err := saveSysPrompt("réponds en une phrase"); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != "réponds en une phrase" { + t.Fatalf("prompt du preset A = %q", got) + } + + if err := applyPresetFile(filepath.Join(presetsDir(), b+".env")); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != "" { + t.Fatalf("le prompt du preset A a suivi jusqu'au preset B : %q", got) + } + if err := saveSysPrompt("tu es un assistant technique"); err != nil { + t.Fatal(err) + } + + // Retour sur A : sa consigne doit être intacte. + if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != "réponds en une phrase" { + t.Fatalf("le prompt du preset A n'a pas survécu à l'aller-retour : %q", got) + } +} + +// Un prompt multiligne doit passer tel quel : c'est le cas courant, et c'est +// précisément ce qu'un fichier .env (lu ligne à ligne) ne saurait porter — d'où +// le stockage en base. +func TestSysPromptMultiligne(t *testing.T) { + newProjectHome(t) + id := writeTestPreset(t, "modele-a", "/models/a.gguf") + if err := applyPresetFile(filepath.Join(presetsDir(), id+".env")); err != nil { + t.Fatal(err) + } + prompt := "Tu es concis.\n\nRègles :\n- pas de préambule\n- du français" + if err := saveSysPrompt(prompt); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != prompt { + t.Fatalf("prompt multiligne altéré :\n%q", got) + } +} + +// Repli : une installation sans preset identifiable (config bricolée à la main) +// doit garder un champ qui fonctionne, et retrouver son ancien prompt global. +func TestSysPromptReplieSurLeGlobal(t *testing.T) { + newProjectHome(t) + if id := activePresetID(); id != "" { + t.Fatalf("preset actif %q alors qu'aucun n'existe", id) + } + if err := saveSysPrompt("consigne globale"); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != "consigne globale" { + t.Fatalf("prompt global = %q", got) + } + // Un preset apparaît et devient actif : tant qu'il n'a pas SA consigne, le + // global continue de s'appliquer — pas de perte silencieuse après migration. + id := writeTestPreset(t, "modele-a", "/models/a.gguf") + if err := applyPresetFile(filepath.Join(presetsDir(), id+".env")); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != "consigne globale" { + t.Fatalf("le prompt global n'est plus le repli : %q", got) + } +} + +// Supprimer un preset doit emporter sa consigne : sinon elle ressuscite sur un +// preset recréé plus tard sous le même identifiant. +func TestDeletePresetEmporteSonSysPrompt(t *testing.T) { + newProjectHome(t) + a := writeTestPreset(t, "modele-a", "/models/a.gguf") + b := writeTestPreset(t, "modele-b", "/models/b.gguf") + if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil { + t.Fatal(err) + } + if err := saveSysPrompt("consigne du A"); err != nil { + t.Fatal(err) + } + // On quitte A : un preset actif n'est pas supprimable. + if err := applyPresetFile(filepath.Join(presetsDir(), b+".env")); err != nil { + t.Fatal(err) + } + if err := DeletePreset(a); err != nil { + t.Fatal(err) + } + writeTestPreset(t, a, "/models/a.gguf") + if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil { + t.Fatal(err) + } + if got := readSysPrompt(); got != "" { + t.Fatalf("la consigne d'un preset supprimé est revenue : %q", got) + } +} diff --git a/internal/loki/web_tasks.go b/internal/loki/web_tasks.go index 17c0e25..ff06f66 100644 --- a/internal/loki/web_tasks.go +++ b/internal/loki/web_tasks.go @@ -35,6 +35,8 @@ func handleTasks(w http.ResponseWriter, r *http.Request) { "agent": agentEnabled(), "running_id": runningID, "presets": presets, + // Projets, pour le sélecteur « projet visé » du formulaire de tâche. + "projects": projectDTOs(), // État global mémoire/web, pour proposer des défauts cohérents à la création. "mem_on": memMode() != MemOff, "web_on": internetEnabled() && crawlReachable(), @@ -51,6 +53,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) { Schedule string `json:"schedule"` TZ string `json:"tz"` Preset string `json:"preset"` + Project string `json:"project"` NoMem bool `json:"no_mem"` NoWeb bool `json:"no_web"` Enabled bool `json:"enabled"` @@ -84,6 +87,14 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) { } t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled t.TZ, t.Preset = req.TZ, req.Preset + // Projet visé : vide = le projet actif au moment de l'enregistrement. Une tâche + // créée depuis un projet appartient à ce projet — c'est là que sa mémoire et + // ses trackers l'attendent quand elle tournera (voir runTask). + if p := strings.TrimSpace(req.Project); p != "" && projectExists(p) { + t.Project = p + } else if t.Project == "" { + t.Project = activeProjectSlug() + } t.NoMem, t.NoWeb = req.NoMem, req.NoWeb // Recalcule NextRun si la fréquence a changé (ou à la création). if t.Schedule != req.Schedule || t.NextRun == 0 { diff --git a/internal/loki/web_tracker.go b/internal/loki/web_tracker.go new file mode 100644 index 0000000..1198a2a --- /dev/null +++ b/internal/loki/web_tracker.go @@ -0,0 +1,107 @@ +package loki + +// web_tracker.go — l'API du panneau des trackers. Même forme que le hub des +// projets : un endpoint verbe-orienté, les invariants restant dans tracker.go. +// +// Différence avec la vue MODÈLE (trackerView) : l'UI reçoit des données +// structurées, pas de la prose. Le modèle a besoin d'un texte qui lui dit comment +// zoomer ; un panneau, lui, sait afficher une liste. + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// trackerDTO : un tracker pour le panneau. Events n'est rempli que sur le détail +// d'un tracker — la liste ne charge jamais les points, sans quoi ouvrir le +// panneau lirait tout l'historique de tous les trackers. +type trackerDTO struct { + Slug string `json:"slug"` + Name string `json:"name"` + Count int `json:"count"` + FirstTS int64 `json:"first_ts"` + LastTS int64 `json:"last_ts"` + LastText string `json:"last_text"` + Events []TrackerEvent `json:"events,omitempty"` +} + +func trackerDTOs() []trackerDTO { + list := trackerList() + out := make([]trackerDTO, 0, len(list)) + for _, m := range list { + out = append(out, trackerDTO{ + Slug: m.Slug, Name: m.Name, Count: m.Count, + FirstTS: m.FirstTS, LastTS: m.LastTS, LastText: m.LastText, + }) + } + return out +} + +// handleTracker : +// +// GET → {ok, trackers} (liste, sans les points) +// GET ?slug=x → {ok, tracker} (détail, avec ses points) +// POST {action…} → add | edit | delete | delete_tracker | rename | move +func handleTracker(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + slug := strings.TrimSpace(r.URL.Query().Get("slug")) + if slug == "" { + sendJSON(w, 200, map[string]any{"ok": true, "trackers": trackerDTOs()}) + return + } + s, ok := trackerLoad(slug) + if !ok { + sendJSON(w, 404, map[string]any{"ok": false, "error": "tracker introuvable"}) + return + } + d := trackerDTO{Slug: slug, Name: s.Name, Count: len(s.Events), Events: s.Events} + if n := len(s.Events); n > 0 { + d.FirstTS = s.Events[0].TS + d.LastTS = s.Events[n-1].TS + d.LastText = s.Events[n-1].Text + } + sendJSON(w, 200, map[string]any{"ok": true, "tracker": d}) + case http.MethodPost: + var body struct { + Action string `json:"action"` + Slug string `json:"slug"` + Name string `json:"name"` + When string `json:"when"` + Text string `json:"text"` + ID string `json:"id"` + To string `json:"to"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + slug := strings.TrimSpace(body.Slug) + var err error + switch strings.ToLower(strings.TrimSpace(body.Action)) { + case "add": + _, err = trackerAdd(body.Name, body.When, body.Text) + case "edit": + err = trackerEditEvent(slug, body.ID, body.When, body.Text) + case "delete": + err = trackerDeleteEvent(slug, body.ID) + case "delete_tracker": + err = trackerDelete(slug) + case "rename": + err = trackerRename(slug, body.Name) + case "move": + err = trackerMoveToProject(slug, strings.TrimSpace(body.To)) + default: + err = fmt.Errorf("action inconnue : %q", body.Action) + } + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "trackers": trackerDTOs()}) + default: + sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"}) + } +}