Files
Loki/internal/loki/tracker.go
T
MichaelandClaude Opus 5.5 4932999d5c Contexte : PROJ_SNAPSHOT fige le bloc projet par discussion, ses changements arrivent en mise à jour, en opt-in
Le contexte du projet (description, index mémoire, trackers, AGENTS.md) part
en tête du premier message utilisateur, reconstruit à chaque tour : une page
créée ou une valeur de tracker notée faisait recalculer toute la conversation
derrière lui, 10 à 45k tokens. Nouvelle clé PROJ_SNAPSHOT (off par défaut) :
avec on, chaque discussion garde une copie datée du bloc, renvoyée à l'octet
près, et les changements arrivent en tête du message suivant dans un bloc
<context_update from="loki">, rangé dans l'historique. Sans la clé, la requête
est identique à l'octet près (testé contre l'ancien assemblage).

- en-têtes figés « as of <date> » pour l'index mémoire et les trackers, sans
  « answer straight from this » ; ligne fixe du système, seulement avec la clé
- mises à jour par type : +/~/- par page (clé « ](fichier) »), une ligne
  complète par tracker (clé = slug), texte COMPLET pour la description,
  AGENTS.md, ou un index dont la prose a changé — jamais de diff de prose
- état annoncé structuré et persisté (ProjSnap, omitempty), instantané pris
  sur marqueur explicite : la première page d'un projet vide arrive en mise
  à jour, le premier message ne bouge pas
- rafraîchi (blocs retirés de tout l'historique) quand le prompt change de
  toute façon : compaction début/fin/manuelle, compaction ou réduction en
  cours de tour (bloc vivant aussitôt, rangé comme instantané : pas de
  recalcul de plus), système ou outils modifiés, redémarrage de Loki, réglage
  moteur ou modèle, projet, nom, mode mémoire, mode Code, dépôt, textes des
  en-têtes ; et au-delà d'un seuil de mises à jour accumulées
- mise à jour posée sous c.mu avec l'epoch, après la compaction de début de
  tour ; texte ou multimodal ; retirée du titre, de l'export JSON, de l'entrée
  du résumeur et de la tâche du vérificateur
- loadFrom, Reset et le chargement remettent l'instantané à zéro ; clé retirée
  = nettoyage ; agent off = rien du projet ; tâches et presets externes
  inchangés ; un sous-agent ne voit pas le bloc du tour
- ordre des trackers déjà déterministe (lot 1, test existant)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:44:46 +02:00

624 lines
22 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 !getStoreJSON(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 putStoreJSON(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)
}
// Départage par nom puis slug : allKV parcourt une map, et deux trackers au
// même horodatage (deux points datés du même jour, sans heure) sortaient dans
// un ordre tiré au sort. Cette liste part dans le contexte du modèle — un ordre qui
// change d'un tour à l'autre change le prompt, et tout le cache derrière.
sort.SliceStable(out, func(i, j int) bool {
if out[i].LastTS != out[j].LastTS {
return out[i].LastTS > out[j].LastTS
}
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Slug < out[j].Slug
})
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) {
list, ok := trackerIndexList()
if !ok {
return Message{}, false
}
return renderTrackerIndex(list, ""), true
}
// trackerIndexList : les trackers à annoncer, ok=false hors mode mémoire
// proactif ou s'il n'y en a aucun. Séparé du rendu pour le bloc figé
// (chat_projsnap.go), qui en tire aussi son état ligne à ligne.
func trackerIndexList() ([]trackerMeta, bool) {
if memMode() != MemAlways {
return nil, false
}
list := trackerList()
return list, len(list) > 0
}
// trackerIndexLine : la ligne d'un tracker, « nom — dernière valeur (date) ».
func trackerIndexLine(m trackerMeta) string {
last := ""
if m.last != nil {
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
}
return m.Name + last
}
// renderTrackerIndex : le message d'index des trackers. asOf vide = la forme
// vivante (texte d'avant PROJ_SNAPSHOT, à l'octet près). Sinon la copie figée :
// datée, et sans « réponds directement d'ici », puisque des <context_update>
// plus récents peuvent en remplacer des lignes.
func renderTrackerIndex(list []trackerMeta, asOf string) Message {
var b strings.Builder
if asOf == "" {
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")
} else {
b.WriteString(trackerIndexPrefix + " as of " + asOf + trackerFrozenHead)
}
for _, m := range list {
fmt.Fprintf(&b, "- %s\n", trackerIndexLine(m))
}
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}
}
// --- 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)
}