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

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

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

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

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

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

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

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

587 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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é
// « <slug-projet>/<slug-tracker> ».
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é <projet>/<slug>) -------------------------
// 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:\"<nom>\") 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)
}