mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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>
624 lines
22 KiB
Go
624 lines
22 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)
|
||
}
|
||
// 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)
|
||
}
|