mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Reprise d'AJEAN (mem_crypto/mem_vault/mem_store/mem_io/mem_migrate/ mem_snapshots/mem_health/mem_fsutil + backup_bundle), adaptée à Loki. Chiffrement à enveloppe : une DEK tirée une fois chiffre les données en AES-256-GCM ; elle est enfermée dans un coffre par une KEK dérivée en Argon2id. Ce qui ouvre le coffre : la clé de pilotage de l'appareil (le serveur n'en a que l'empreinte, il ne peut pas ouvrir seul) ou la clé de récupération donnée une fois. La DEK ne vit qu'en RAM. Périmètre chiffré, choisi pour Loki : les pages mémoire, les discussions (journal ET index, qui porte les titres), les blocs archivés au compactage — du verbatim de conversation — et les trackers. Pas les buckets de réglages : le coffre lui-même y vit, les chiffrer serait une boucle. Verrouillé, rien n'est écrasé : putStoreBytes REFUSE d'écrire du clair par-dessus du chiffré, la liste des discussions se lit vide et les pages s'affichent « 🔒 chiffré ». Le déverrouillage recharge la discussion et rattrape ce qui serait resté en clair. Une migration interrompue reprend au démarrage, un snapshot est pris avant chaque bascule, et aucune donnée n'est supprimée avant relecture vérifiée de son remplaçant. Piège corrigé au passage : chiffrer À L'INTÉRIEUR d'une transaction bbolt se bloquait sur le verrou de la base (memEncActive relit la config, donc la base). L'état du chiffrement est désormais résolu AVANT la transaction (memEncoderNow), qui ne reçoit plus qu'un encodeur pur. Sauvegarde : le paquet chiffré {mémoire, presets, réglages} s'exporte et s'importe en FICHIER (/api/backup/export, /api/backup/import). La sauvegarde vers le relais ajean.link n'est pas reprise — c'est le service de l'auteur amont ; ici le fichier reste chez soi. Le dossier mémoire n'était déjà plus joignable qu'aux outils mem_* : c'était la condition de ce chiffrement (un `cat memory/…` aurait rendu du binaire). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
587 lines
20 KiB
Go
587 lines
20 KiB
Go
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)
|
||
}
|
||
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)
|
||
}
|