mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 40 : outils mémoire de l'agent exécutés par la façade
Quand la façade tient la mémoire (LOKI_MEM=rust), les outils mem_search, mem_read, mem_add, mem_edit et tracker de ses tours s'exécutent chez elle (src/memory/tools.rs, port de chat_memory.go et de l'outil de tracker.go) : mêmes résultats et diffs, mêmes lectures et écritures que les routes de la mémoire (scellées selon le chiffrement, .bak, index tenu par le code, seed au nom du projet). Recherche classée par couverture puis pertinence, extrait coupé à l'octet comme le Go ; trackers vus niveau par niveau. ChatShared porte la même mémoire que les routes ; tracker_add prend l'heure en paramètre (tracker_add_at). Côté Go, l'heure des trackers passe par trackerNow, que le journal fixe. Parité : journal de 57 appels tiré du Go (parity_mem_tools_test.go → tests/parity_mem/outils.json : résultats, diffs, fichiers de la mémoire et trackers), rejoué par tests/memory.rs ; un tour de la façade qui crée une page (tests/lot6.rs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
14 files changed
+3578
-14
No files matched your search
@@ -233,7 +233,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Lot 40 — Outils mémoire de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
|
||||
|
||||
- pages ;
|
||||
- index `MEMORY.md` ;
|
||||
- trackers ;
|
||||
- mode mémoire.
|
||||
|
||||
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
|
||||
(`/internal/v1/tool/exec`) :
|
||||
|
||||
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
|
||||
- `tracker`.
|
||||
|
||||
Deux processus écrivaient donc les mêmes pages et le même index.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
|
||||
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
|
||||
routes de la mémoire :
|
||||
|
||||
- pages scellées selon le chiffrement ;
|
||||
- `.bak` de la version précédente ;
|
||||
- écriture vérifiée ;
|
||||
- index tenu par le code.
|
||||
|
||||
Chaque outil :
|
||||
|
||||
- **`mem_search`** : classement par couverture des termes puis par
|
||||
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
|
||||
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
|
||||
coupé) ; limite 8 par défaut, 30 au plus ;
|
||||
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
|
||||
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
|
||||
nom du projet), diff d'ajout ;
|
||||
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
|
||||
- **`tracker`** :
|
||||
- vue d'ensemble ;
|
||||
- squelette (années, année en cours par mois) ;
|
||||
- une année par mois ;
|
||||
- points (60 plus récents au plus) ;
|
||||
- ajout, modification, suppression d'un point (jamais d'un tracker
|
||||
entier) ;
|
||||
- arguments d'un autre type rendus comme `str` du Go.
|
||||
|
||||
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
|
||||
|
||||
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
|
||||
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
|
||||
comme avant.
|
||||
|
||||
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
|
||||
`trackerNow`, que le journal fixe ; rien ne change en service.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
|
||||
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
|
||||
étape porte :
|
||||
|
||||
- les arguments ;
|
||||
- le libellé du tour ;
|
||||
- le résultat, le diff ;
|
||||
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
|
||||
- les trackers en base.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
|
||||
- lecture : plage, au-delà, page absente, nom invalide ;
|
||||
- ajout : existant, nom invalide, majuscules ;
|
||||
- modification : rejouée, déjà en place, absente, en double, vide ;
|
||||
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
|
||||
inconnus.
|
||||
|
||||
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
|
||||
|
||||
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une page. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la page est rangée dans le projet actif, l'index tenu ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
|
||||
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
|
||||
// mem_add, mem_edit.
|
||||
//
|
||||
// Port Rust des outils (lot 40) : rust/loki-rs/src/memory/tools.rs, employé
|
||||
// par les tours de la façade quand elle tient la mémoire (LOKI_MEM=rust) ;
|
||||
// journal de parité dans parity_mem_tools_test.go.
|
||||
|
||||
type MemPage struct {
|
||||
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parity_mem_tools_test.go — journal de référence des outils mémoire de
|
||||
// l'agent (lot 40 de la migration Rust) : mem_search, mem_read, mem_add,
|
||||
// mem_edit et tracker. Chaque appel (arguments tels que le modèle les
|
||||
// envoie), son résultat et son diff, puis les fichiers de la mémoire du projet
|
||||
// et les trackers après coup. Heure fixée, fuseau UTC. Rangé dans
|
||||
// rust/loki-rs/tests/parity_mem/outils.json ; src/memory/tools/tests.rs le
|
||||
// rejoue.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsMemoire
|
||||
|
||||
const parityMemToolsFile = "../../rust/loki-rs/tests/parity_mem/outils.json"
|
||||
|
||||
type parityMemToolStep struct {
|
||||
Name string `json:"name"`
|
||||
Now string `json:"now,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Label string `json:"label"`
|
||||
Out string `json:"out"`
|
||||
Diff any `json:"diff"`
|
||||
Added int `json:"added"`
|
||||
Removed int `json:"removed"`
|
||||
Files map[string]string `json:"files"`
|
||||
Trackers map[string]any `json:"trackers"`
|
||||
}
|
||||
|
||||
var parityTrackerIDRe = regexp.MustCompile(`\b[0-9]{18,20}\b`)
|
||||
|
||||
func TestPariteOutilsMemoire(t *testing.T) {
|
||||
home := testHome(t)
|
||||
oldLocal, oldNow := time.Local, trackerNow
|
||||
time.Local = time.UTC
|
||||
var now time.Time
|
||||
trackerNow = func() time.Time { return now }
|
||||
t.Cleanup(func() { time.Local, trackerNow = oldLocal, oldNow })
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
|
||||
ids := map[string]string{}
|
||||
var order []string
|
||||
mask := func(s string) string {
|
||||
return parityTrackerIDRe.ReplaceAllStringFunc(s, func(id string) string {
|
||||
if _, ok := ids[id]; !ok {
|
||||
order = append(order, id)
|
||||
ids[id] = "{{T" + strconv.Itoa(len(order)) + "}}"
|
||||
}
|
||||
return ids[id]
|
||||
})
|
||||
}
|
||||
unmask := func(s string) string {
|
||||
for id, m := range ids {
|
||||
s = strings.ReplaceAll(s, m, id)
|
||||
}
|
||||
return s
|
||||
}
|
||||
dir := projectMemoryDir(defaultProjectSlug)
|
||||
files := func() map[string]string {
|
||||
out := map[string]string{}
|
||||
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
b, _ := os.ReadFile(p)
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
out[rel] = string(b)
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
trackers := func() map[string]any {
|
||||
out := map[string]any{}
|
||||
kv := allKV(bkTracker)
|
||||
keys := make([]string, 0, len(kv))
|
||||
for k := range kv {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
var v any
|
||||
_ = json.Unmarshal([]byte(mask(string(kv[k]))), &v)
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
var steps []parityMemToolStep
|
||||
run := func(st parityMemToolStep) {
|
||||
if st.Now != "" {
|
||||
n, err := time.Parse(time.RFC3339, st.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = n
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(unmask(string(st.Args))), &args); err != nil {
|
||||
t.Fatalf("%s : %v", st.Name, err)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
st.Label = toolCallLabel(st.Tool, args)
|
||||
res := execTool(context.Background(), st.Tool, args, st.Label, Caps{Agent: true})
|
||||
b, _ := json.Marshal(res.Result)
|
||||
var out string
|
||||
_ = json.Unmarshal(b, &out)
|
||||
st.Out = mask(out)
|
||||
db, _ := json.Marshal(res.Diff)
|
||||
_ = json.Unmarshal(db, &st.Diff)
|
||||
st.Added, st.Removed = res.Added, res.Removed
|
||||
st.Files = files()
|
||||
st.Trackers = trackers()
|
||||
steps = append(steps, st)
|
||||
}
|
||||
a := func(s string) json.RawMessage { return json.RawMessage(s) }
|
||||
|
||||
// Pages.
|
||||
run(parityMemToolStep{Name: "recherche dans une mémoire vide", Now: "2026-07-20T10:00:00Z", Tool: "mem_search", Args: a(`{"query":"docker"}`)})
|
||||
run(parityMemToolStep{Name: "ajout", Tool: "mem_add", Args: a(`{"file":"docker-notes","content":"# Notes Docker\n\nUtiliser compose v2.\nLe port 8080 est pris.\n\n\n"}`)})
|
||||
run(parityMemToolStep{Name: "ajout d'une page existante", Tool: "mem_add", Args: a(`{"file":"docker-notes.md","content":"x"}`)})
|
||||
run(parityMemToolStep{Name: "ajout d'un nom invalide", Tool: "mem_add", Args: a(`{"file":"../x","content":"x"}`)})
|
||||
run(parityMemToolStep{Name: "ajout sans nom", Tool: "mem_add", Args: a(`{"content":"x"}`)})
|
||||
run(parityMemToolStep{Name: "ajout en majuscules", Tool: "mem_add", Args: a(`{"file":"Recettes.MD","content":"# Recettes\nTarte aux pommes\nLa copine de Nathan aime la tarte"}`)})
|
||||
run(parityMemToolStep{Name: "ajout d'une page bavarde", Tool: "mem_add", Args: a(`{"file":"nathan","content":"## Nathan\nNathan Nathan Nathan Nathan Nathan Nathan Nathan Nathan.\nNathan est né en 2019."}`)})
|
||||
long := strings.Repeat("Un texte de remplissage pour éloigner le terme. ", 4) + "Le mot RARE est là, " + strings.Repeat("puis encore du texte après lui, assez pour couper. ", 4)
|
||||
lb, _ := json.Marshal(map[string]string{"file": "longue", "content": "# Longue\n" + long})
|
||||
run(parityMemToolStep{Name: "ajout d'une longue page", Tool: "mem_add", Args: lb})
|
||||
run(parityMemToolStep{Name: "couverture d'abord", Tool: "mem_search", Args: a(`{"query":"copine Nathan"}`)})
|
||||
run(parityMemToolStep{Name: "rareté et nom", Tool: "mem_search", Args: a(`{"query":"Nathan nathan TARTE"}`)})
|
||||
run(parityMemToolStep{Name: "requête vide : tout", Tool: "mem_search", Args: a(`{"query":" "}`)})
|
||||
run(parityMemToolStep{Name: "limite", Tool: "mem_search", Args: a(`{"query":"e","limit":2}`)})
|
||||
run(parityMemToolStep{Name: "limite hors bornes", Tool: "mem_search", Args: a(`{"query":"e","limit":99}`)})
|
||||
run(parityMemToolStep{Name: "extrait coupé des deux côtés", Tool: "mem_search", Args: a(`{"query":"rare"}`)})
|
||||
run(parityMemToolStep{Name: "rien", Tool: "mem_search", Args: a(`{"query":"zzz"}`)})
|
||||
run(parityMemToolStep{Name: "lecture", Tool: "mem_read", Args: a(`{"file":"docker-notes"}`)})
|
||||
run(parityMemToolStep{Name: "lecture d'une plage", Tool: "mem_read", Args: a(`{"file":"docker-notes.md","offset":3,"limit":1}`)})
|
||||
run(parityMemToolStep{Name: "lecture au-delà", Tool: "mem_read", Args: a(`{"file":"docker-notes","offset":40}`)})
|
||||
run(parityMemToolStep{Name: "lecture d'une page absente", Tool: "mem_read", Args: a(`{"file":"absente"}`)})
|
||||
run(parityMemToolStep{Name: "lecture d'un nom invalide", Tool: "mem_read", Args: a(`{"file":"a/b"}`)})
|
||||
run(parityMemToolStep{Name: "modification", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"Le port 8080 est pris.","new":"Le port 8080 est pris.\nLe port 9090 aussi."}`)})
|
||||
run(parityMemToolStep{Name: "modification rejouée", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"Le port 8080 est pris.\nLe port 9090 aussi.","new":"Le port 8080 est pris.\nLe port 9090 aussi."}`)})
|
||||
run(parityMemToolStep{Name: "modification déjà en place", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"compose v1","new":"Utiliser compose v2."}`)})
|
||||
run(parityMemToolStep{Name: "ancien texte absent", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"introuvable","new":"x"}`)})
|
||||
run(parityMemToolStep{Name: "ancien texte en double", Tool: "mem_edit", Args: a(`{"file":"nathan","old":"Nathan Nathan","new":"N"}`)})
|
||||
run(parityMemToolStep{Name: "ancien texte vide", Tool: "mem_edit", Args: a(`{"file":"nathan","old":"","new":"N"}`)})
|
||||
run(parityMemToolStep{Name: "modification d'une page absente", Tool: "mem_edit", Args: a(`{"file":"absente","old":"a","new":"b"}`)})
|
||||
run(parityMemToolStep{Name: "suppression d'un passage", Tool: "mem_edit", Args: a(`{"file":"recettes","old":"\nTarte aux pommes","new":""}`)})
|
||||
|
||||
// Trackers.
|
||||
run(parityMemToolStep{Name: "aucun tracker", Tool: "tracker", Args: a(`{}`)})
|
||||
run(parityMemToolStep{Name: "ajout sans nom", Tool: "tracker", Args: a(`{"action":"add","text":"1"}`)})
|
||||
run(parityMemToolStep{Name: "ajout sans texte", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","text":" "}`)})
|
||||
run(parityMemToolStep{Name: "date illisible", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","when":"hier","text":"72"}`)})
|
||||
run(parityMemToolStep{Name: "ajout daté", Tool: "tracker", Args: a(`{"action":"ADD","name":"Poids","when":"2026-07-15","text":" 72.5 "}`)})
|
||||
run(parityMemToolStep{Name: "ajout à l'heure", Tool: "tracker", Args: a(`{"action":"add","name":"poids","when":"2026-07-16T08:30","text":"72.1"}`)})
|
||||
run(parityMemToolStep{Name: "ajout l'an passé", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","when":"2025-12","text":"73"}`)})
|
||||
run(parityMemToolStep{Name: "ajout maintenant", Now: "2026-07-20T10:15:00Z", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","text":"71.9"}`)})
|
||||
run(parityMemToolStep{Name: "un autre tracker", Tool: "tracker", Args: a(`{"action":"add","name":"Séances de sport","when":"2024","text":"course"}`)})
|
||||
run(parityMemToolStep{Name: "un nom en nombre", Tool: "tracker", Args: a(`{"action":"add","name":42,"when":"2026-01-02 03:04:05","text":7}`)})
|
||||
run(parityMemToolStep{Name: "vue d'ensemble", Tool: "tracker", Args: a(`{"action":"view"}`)})
|
||||
run(parityMemToolStep{Name: "squelette", Tool: "tracker", Args: a(`{"name":"Poids"}`)})
|
||||
run(parityMemToolStep{Name: "une année", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026"}`)})
|
||||
run(parityMemToolStep{Name: "une année vide", Tool: "tracker", Args: a(`{"name":"Poids","when":"2027"}`)})
|
||||
run(parityMemToolStep{Name: "un mois", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026-07"}`)})
|
||||
run(parityMemToolStep{Name: "un jour à l'heure", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026-07-16T08"}`)})
|
||||
run(parityMemToolStep{Name: "tracker inconnu", Tool: "tracker", Args: a(`{"name":"Tension"}`)})
|
||||
run(parityMemToolStep{Name: "modifier sans id", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","text":"1"}`)})
|
||||
run(parityMemToolStep{Name: "modifier", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","id":"{{T1}}","text":"72.4","when":"2026-07-14"}`)})
|
||||
run(parityMemToolStep{Name: "modifier une date illisible", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","id":"{{T1}}","when":"demain"}`)})
|
||||
run(parityMemToolStep{Name: "modifier un point inconnu", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","id":"1"}`)})
|
||||
run(parityMemToolStep{Name: "modifier un tracker inconnu", Tool: "tracker", Args: a(`{"action":"edit","name":"Tension","id":"1"}`)})
|
||||
run(parityMemToolStep{Name: "supprimer sans id", Tool: "tracker", Args: a(`{"action":"delete","name":"Poids"}`)})
|
||||
run(parityMemToolStep{Name: "supprimer sans nom", Tool: "tracker", Args: a(`{"action":"delete"}`)})
|
||||
run(parityMemToolStep{Name: "supprimer", Tool: "tracker", Args: a(`{"action":"delete","name":"Poids","id":"{{T2}}"}`)})
|
||||
run(parityMemToolStep{Name: "supprimer deux fois", Tool: "tracker", Args: a(`{"action":"delete","name":"Poids","id":"{{T2}}"}`)})
|
||||
run(parityMemToolStep{Name: "action inconnue", Tool: "tracker", Args: a(`{"action":"purge","name":"Poids"}`)})
|
||||
run(parityMemToolStep{Name: "après modifications", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026"}`)})
|
||||
|
||||
got, err := json.MarshalIndent(steps, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, '\n')
|
||||
if strings.Contains(string(got), home) {
|
||||
t.Fatal("le journal contient le dossier du test")
|
||||
}
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityMemToolsFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityMemToolsFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityMemToolsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("les outils mémoire ne produisent plus le journal rangé dans %s.\n"+
|
||||
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityMemToolsFile)
|
||||
}
|
||||
}
|
||||
@@ -273,10 +273,14 @@ func trackerMoveToProject(slug, toSlug string) error {
|
||||
// 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 ».
|
||||
// trackerNow : l'heure des trackers (« maintenant », année en cours),
|
||||
// remplaçable par les tests (journal de parité des outils mémoire).
|
||||
var trackerNow = time.Now
|
||||
|
||||
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
|
||||
return trackerNow().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"} {
|
||||
@@ -402,7 +406,7 @@ func trackerSkeleton(s *Tracker) string {
|
||||
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")
|
||||
cur := trackerNow().In(time.Local).Format("2006")
|
||||
var curEv []TrackerEvent
|
||||
for _, e := range s.Events {
|
||||
if strings.HasPrefix(fmtTS(e.TS), cur) {
|
||||
|
||||
@@ -1321,6 +1321,7 @@ impl ChatShared {
|
||||
perf,
|
||||
mcp: None,
|
||||
tasks: None,
|
||||
memory: None,
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé
|
||||
//! tenu par le Go), outils MCP par le pool de la façade quand elle le tient
|
||||
//! (lot 36), outils task_* quand elle tient les tâches (lot 37), critères
|
||||
//! du mode Code (lot 38) ; les autres
|
||||
//! du mode Code (lot 38), outils mémoire quand elle tient la mémoire
|
||||
//! (lot 40) ; les autres
|
||||
//! par le Go (`/internal/v1/tool/exec`), avec
|
||||
//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur,
|
||||
//! image relayée) ;
|
||||
@@ -343,6 +344,9 @@ pub struct ChatShared {
|
||||
/// Les tâches, quand la façade les tient (LOKI_TASKS=rust) : les outils
|
||||
/// `task_*` du tour s'exécutent ici (lot 37).
|
||||
pub tasks: Option<Arc<crate::tasks::Tasks>>,
|
||||
/// La mémoire, quand la façade la tient (LOKI_MEM=rust) : les outils
|
||||
/// `mem_*` et `tracker` du tour s'exécutent ici (lot 40).
|
||||
pub memory: Option<Arc<crate::memory::Memory>>,
|
||||
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
|
||||
/// une fois.
|
||||
pub go_version: tokio::sync::OnceCell<String>,
|
||||
@@ -675,6 +679,25 @@ impl TurnHost for GoTurnHost {
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
// Outils mémoire, quand la façade tient la mémoire (lot 40).
|
||||
if crate::memory::tools::is_tool(name)
|
||||
&& let Some(mem) = &self.shared.memory
|
||||
{
|
||||
let env = crate::memory::tools::MemEnv {
|
||||
store: &self.shared.store,
|
||||
mem,
|
||||
now_ms: crate::memory::now_ms(),
|
||||
};
|
||||
if let Some(o) = crate::memory::tools::exec(&env, name, args, label).await {
|
||||
return ToolOutcome {
|
||||
result: o.result,
|
||||
diff: o.diff,
|
||||
added: o.added,
|
||||
removed: o.removed,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
// Outils task_*, quand la façade tient les tâches (lot 37).
|
||||
if crate::tasks::tools::is_tool(name)
|
||||
&& let Some(tasks) = &self.shared.tasks
|
||||
|
||||
@@ -268,8 +268,14 @@ fn serve() -> ExitCode {
|
||||
.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(engine_paths.home.clone())));
|
||||
let tasks = tasks_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::tasks::Tasks::new(engine_paths.home.clone())));
|
||||
let mem = mem_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(engine_paths.home.clone())));
|
||||
let chat = if chat_rust {
|
||||
let held = (mcp.clone(), tasks.clone());
|
||||
let held = ChatHeld {
|
||||
mcp: mcp.clone(),
|
||||
tasks: tasks.clone(),
|
||||
mem: mem.clone(),
|
||||
};
|
||||
match start_chat(backend, &token, &engine_paths.home, held).await {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
@@ -345,7 +351,6 @@ fn serve() -> ExitCode {
|
||||
}
|
||||
};
|
||||
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
|
||||
let mem_home = engine_paths.home.clone();
|
||||
// Le superviseur reçoit ce que le Go lui passait (rustSvcAction), et de
|
||||
// quoi joindre l'API interne du Go pour « loki-rs engine serve ».
|
||||
let engine_ctl = engine_rust.then(|| {
|
||||
@@ -391,7 +396,7 @@ fn serve() -> ExitCode {
|
||||
settings: settings_rust,
|
||||
files,
|
||||
push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())),
|
||||
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
|
||||
mem,
|
||||
presets: presets_rust,
|
||||
engine_ctl,
|
||||
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
|
||||
@@ -509,15 +514,20 @@ async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result<Option<String
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// Ce que la façade tient aussi pour les outils de ses tours : le pool MCP,
|
||||
/// les tâches, la mémoire (les mêmes que pour leurs routes).
|
||||
struct ChatHeld {
|
||||
mcp: Option<std::sync::Arc<loki_rs::mcp::Mcp>>,
|
||||
tasks: Option<std::sync::Arc<loki_rs::tasks::Tasks>>,
|
||||
mem: Option<std::sync::Arc<loki_rs::memory::Memory>>,
|
||||
}
|
||||
|
||||
/// Charge la discussion active et prépare le service des tours.
|
||||
async fn start_chat(
|
||||
backend: SocketAddr,
|
||||
token: &str,
|
||||
home: &std::path::Path,
|
||||
(mcp, tasks): (
|
||||
Option<std::sync::Arc<loki_rs::mcp::Mcp>>,
|
||||
Option<std::sync::Arc<loki_rs::tasks::Tasks>>,
|
||||
),
|
||||
held: ChatHeld,
|
||||
) -> Result<std::sync::Arc<loki_rs::chat::generate::ChatService>, String> {
|
||||
use loki_rs::chat::{conv::Conversation, generate::ChatService, host::ChatShared, sessions};
|
||||
let store = loki_rs::store::Store::new(backend, token.to_string());
|
||||
@@ -529,8 +539,9 @@ async fn start_chat(
|
||||
s.load().await?;
|
||||
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = mcp;
|
||||
shared.tasks = tasks;
|
||||
shared.mcp = held.mcp;
|
||||
shared.tasks = held.tasks;
|
||||
shared.memory = held.mem;
|
||||
eprintln!("[loki-rs] conversation tenue par la façade (LOKI_CHAT=go pour la rendre au Go)");
|
||||
Ok(ChatService::new(
|
||||
std::sync::Arc::new(s),
|
||||
|
||||
@@ -43,6 +43,8 @@ use crate::gojson::{self, Kind};
|
||||
use crate::proxy::Body;
|
||||
use crate::store::{BK_CONFIG, BK_STATE, CryptError, Store};
|
||||
|
||||
pub mod tools;
|
||||
|
||||
/// Index du projet, tenu par le code.
|
||||
pub const MEM_INDEX_FILE: &str = "MEMORY.md";
|
||||
/// Magic des contenus chiffrés (`memPageMagic`).
|
||||
@@ -1021,6 +1023,20 @@ impl Memory {
|
||||
name: &str,
|
||||
when: &str,
|
||||
text: &str,
|
||||
) -> Result<String, String> {
|
||||
self.tracker_add_at(store, proj, name, when, text, now_ms())
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`Self::tracker_add`], « maintenant » donné (`when` vide).
|
||||
async fn tracker_add_at(
|
||||
&self,
|
||||
store: &Store,
|
||||
proj: &str,
|
||||
name: &str,
|
||||
when: &str,
|
||||
text: &str,
|
||||
now: i64,
|
||||
) -> Result<String, String> {
|
||||
let name = go_trim(name).to_string();
|
||||
if name.is_empty() {
|
||||
@@ -1029,7 +1045,7 @@ impl Memory {
|
||||
if go_trim(text).is_empty() {
|
||||
return Err("contenu vide".into());
|
||||
}
|
||||
let (ts, date_only) = parse_when(when, now_ms(), self.tz)?;
|
||||
let (ts, date_only) = parse_when(when, now, self.tz)?;
|
||||
let mut slug = slugify_ident(&name);
|
||||
if slug.is_empty() {
|
||||
slug = "tracker".into();
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
//! Les outils mémoire de l'agent (lot 40) — port de `chat_memory.go`
|
||||
//! (`MemSearch`, `MemRead`, `MemAdd`, `MemEdit`) et de l'outil `tracker`
|
||||
//! (`tracker.go`), exécutés par la façade quand elle tient la mémoire
|
||||
//! (`LOKI_MEM=rust`). La mémoire est celle du projet actif ; les pages
|
||||
//! passent par les mêmes lectures et écritures que les routes (scellées
|
||||
//! selon le chiffrement, `.bak`, index `MEMORY.md` tenu par le code).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::{
|
||||
Memory, ReadErr, Tracker, TrackerEvent, Tz, active_project, parse_when, slugify_ident,
|
||||
};
|
||||
use crate::chat::types::DiffLine;
|
||||
use crate::store::Store;
|
||||
|
||||
/// Les outils portés ici.
|
||||
pub fn is_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"mem_search" | "mem_read" | "mem_add" | "mem_edit" | "tracker"
|
||||
)
|
||||
}
|
||||
|
||||
/// L'issue d'un appel : le texte rendu au modèle et le diff montré.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct MemOut {
|
||||
pub result: String,
|
||||
pub diff: Vec<DiffLine>,
|
||||
pub added: i64,
|
||||
pub removed: i64,
|
||||
}
|
||||
|
||||
impl MemOut {
|
||||
fn text(s: impl Into<String>) -> Self {
|
||||
MemOut {
|
||||
result: s.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `strings.TrimSpace`.
|
||||
fn go_trim(s: &str) -> &str {
|
||||
s.trim_matches(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// `unicode.ToLower` rune par rune (`strings.ToLower`).
|
||||
fn go_lower(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| c.to_lowercase().next().unwrap_or(c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Un entier d'argument (`args[k].(float64)` → `int`), 0 sinon.
|
||||
fn int_arg(args: &Map<String, Value>, k: &str) -> i64 {
|
||||
args.get(k).and_then(Value::as_f64).map_or(0, |v| v as i64)
|
||||
}
|
||||
|
||||
/// `str` de `tracker.go` : une chaîne, un nombre arrondi, sinon la forme `%v`.
|
||||
fn go_str(v: Option<&Value>) -> String {
|
||||
match v {
|
||||
None | Some(Value::Null) => String::new(),
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => {
|
||||
let f = n.as_f64().unwrap_or(0.0);
|
||||
format!("{f:.0}")
|
||||
}
|
||||
Some(v) => go_fmt_v(v),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fmt.Sprintf("%v", v)` d'une valeur JSON décodée par le Go.
|
||||
fn go_fmt_v(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Null => "<nil>".into(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => {
|
||||
let f = n.as_f64().unwrap_or(0.0);
|
||||
if f.fract() == 0.0 && f.abs() < 1e21 {
|
||||
format!("{f:.0}")
|
||||
} else {
|
||||
f.to_string()
|
||||
}
|
||||
}
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(a) => format!("[{}]", a.iter().map(go_fmt_v).collect::<Vec<_>>().join(" ")),
|
||||
Value::Object(m) => {
|
||||
let mut keys: Vec<&String> = m.keys().collect();
|
||||
keys.sort();
|
||||
format!(
|
||||
"map[{}]",
|
||||
keys.iter()
|
||||
.map(|k| format!("{k}:{}", go_fmt_v(&m[*k])))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce qu'il faut pour exécuter un outil : la base, la mémoire, et l'heure
|
||||
/// (ms Unix) pour les trackers.
|
||||
pub struct MemEnv<'a> {
|
||||
pub store: &'a Store,
|
||||
pub mem: &'a Memory,
|
||||
pub now_ms: i64,
|
||||
}
|
||||
|
||||
/// Un appel d'outil mémoire ; `None` pour un autre outil. `label` : le
|
||||
/// libellé du tour (`toolCallLabel`), argument principal des outils de page.
|
||||
pub async fn exec(
|
||||
env: &MemEnv<'_>,
|
||||
name: &str,
|
||||
args: &Map<String, Value>,
|
||||
label: &str,
|
||||
) -> Option<MemOut> {
|
||||
let project = active_project(env.store).await;
|
||||
let dir = env.mem.project_dir(&project);
|
||||
Some(match name {
|
||||
"mem_search" => {
|
||||
let hits = mem_search(env.store, &dir, label, int_arg(args, "limit")).await;
|
||||
if hits.is_empty() {
|
||||
MemOut::text("[aucun résultat]")
|
||||
} else {
|
||||
let mut b = String::new();
|
||||
for (file, title, snippet) in hits {
|
||||
b.push_str(&format!("- {file} — {title}\n {snippet}\n"));
|
||||
}
|
||||
MemOut::text(b.trim_end_matches('\n'))
|
||||
}
|
||||
}
|
||||
"mem_read" => match mem_read(
|
||||
env.store,
|
||||
&dir,
|
||||
label,
|
||||
int_arg(args, "offset"),
|
||||
int_arg(args, "limit"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(c) => MemOut::text(c),
|
||||
Err(e) => MemOut::text(format!("[erreur] {e}")),
|
||||
},
|
||||
"mem_add" => {
|
||||
let content = args.get("content").and_then(Value::as_str).unwrap_or("");
|
||||
let pname = super::project_name(&super::list_projects(env.store).await, &project);
|
||||
match mem_add(env.store, &dir, &pname, label, content).await {
|
||||
Ok(()) => {
|
||||
let (diff, added) = crate::tools::diff::added_diff(content);
|
||||
MemOut {
|
||||
result: format!("[ok] page '{label}' créée"),
|
||||
diff,
|
||||
added,
|
||||
removed: 0,
|
||||
}
|
||||
}
|
||||
Err(e) => MemOut::text(format!("[erreur] {e}")),
|
||||
}
|
||||
}
|
||||
"mem_edit" => {
|
||||
let s = |k: &str| args.get(k).and_then(Value::as_str).unwrap_or("");
|
||||
let (old, new) = (s("old"), s("new"));
|
||||
match mem_edit(env.store, &dir, label, old, new).await {
|
||||
Ok(()) => {
|
||||
let (diff, added, removed) = crate::tools::diff::line_diff(old, new);
|
||||
MemOut {
|
||||
result: format!("[ok] page '{label}' modifiée"),
|
||||
diff,
|
||||
added,
|
||||
removed,
|
||||
}
|
||||
}
|
||||
Err(EditErr::Applied) => {
|
||||
MemOut::text(format!("[ok] page '{label}' {ERR_ALREADY_APPLIED}"))
|
||||
}
|
||||
Err(EditErr::Other(e)) => MemOut::text(format!("[erreur] {e}")),
|
||||
}
|
||||
}
|
||||
"tracker" => MemOut::text(tracker(env, &project, args).await),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Une page illisible, dite comme le Go.
|
||||
fn read_err(name: &str, e: &ReadErr) -> String {
|
||||
match e {
|
||||
ReadErr::Locked => format!("page '{name}' chiffrée — mémoire verrouillée"),
|
||||
_ => format!("page '{name}' introuvable"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `MemSearch` : les pages classées par couverture des termes, puis par
|
||||
/// pertinence (rareté du terme, fréquence amortie, bonus du nom et du
|
||||
/// titre) ; (fichier, titre, extrait).
|
||||
pub async fn mem_search(
|
||||
store: &Store,
|
||||
dir: &std::path::Path,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
) -> Vec<(String, String, String)> {
|
||||
let limit = if limit <= 0 || limit > 30 {
|
||||
8
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let terms = unique_terms(&go_lower(query));
|
||||
struct Doc {
|
||||
file: String,
|
||||
title: String,
|
||||
name: String,
|
||||
title_low: String,
|
||||
content: String,
|
||||
hay: String,
|
||||
}
|
||||
let mut docs = Vec::new();
|
||||
let mut df: HashMap<&str, i64> = HashMap::new();
|
||||
for (file, title) in Memory::list(store, dir).await {
|
||||
let content = match Memory::read_page(store, dir, &file).await {
|
||||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
let hay = go_lower(&format!("{file}\n{content}"));
|
||||
for t in &terms {
|
||||
if hay.contains(t.as_str()) {
|
||||
*df.entry(t.as_str()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
docs.push(Doc {
|
||||
name: go_lower(&file),
|
||||
title_low: go_lower(&title),
|
||||
file,
|
||||
title,
|
||||
content,
|
||||
hay,
|
||||
});
|
||||
}
|
||||
let n = docs.len() as f64;
|
||||
let mut ranked: Vec<(i64, f64, (String, String, String))> = Vec::new();
|
||||
for d in &docs {
|
||||
let (mut matched, mut score) = (0i64, 0.0f64);
|
||||
for t in &terms {
|
||||
let cnt = d.hay.matches(t.as_str()).count();
|
||||
if cnt == 0 {
|
||||
continue;
|
||||
}
|
||||
matched += 1;
|
||||
let dft = df.get(t.as_str()).copied().unwrap_or(0) as f64;
|
||||
let idf = ((n + 1.0) / (dft + 1.0)).ln() + 1.0;
|
||||
let tf = 1.0 + (cnt as f64).ln();
|
||||
let mut field = 1.0;
|
||||
if d.name.contains(t.as_str()) {
|
||||
field += 4.0;
|
||||
}
|
||||
if d.title_low.contains(t.as_str()) {
|
||||
field += 2.0;
|
||||
}
|
||||
score += idf * tf * field;
|
||||
}
|
||||
if matched == 0 && !terms.is_empty() {
|
||||
continue;
|
||||
}
|
||||
ranked.push((
|
||||
matched,
|
||||
score,
|
||||
(
|
||||
d.file.clone(),
|
||||
d.title.clone(),
|
||||
snippet_around(&d.content, &terms),
|
||||
),
|
||||
));
|
||||
}
|
||||
// Tri stable : couverture d'abord, puis pertinence.
|
||||
ranked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)));
|
||||
ranked.into_iter().take(limit).map(|r| r.2).collect()
|
||||
}
|
||||
|
||||
/// `uniqueTerms` : les mots distincts, dans l'ordre.
|
||||
fn unique_terms(q: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for t in q.split_whitespace() {
|
||||
if !out.iter().any(|o| o == t) {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `snippetAround` : un court extrait autour de la première occurrence d'un
|
||||
/// terme. Coupé à l'octet comme le Go : un caractère coupé devient U+FFFD
|
||||
/// par octet, comme à son encodage JSON.
|
||||
fn snippet_around(content: &str, terms: &[String]) -> String {
|
||||
let flat = content.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let low = go_lower(&flat);
|
||||
let idx = terms.iter().filter_map(|t| low.find(t.as_str())).min();
|
||||
let fb = flat.as_bytes();
|
||||
let Some(idx) = idx else {
|
||||
if fb.len() > 160 {
|
||||
return format!("{}…", crate::system::go_lossy(&fb[..160]));
|
||||
}
|
||||
return flat;
|
||||
};
|
||||
let start = idx.saturating_sub(60).min(fb.len());
|
||||
let end = (idx + 100).min(fb.len());
|
||||
let mut s = crate::system::go_lossy(&fb[start..end.max(start)]);
|
||||
if start > 0 {
|
||||
s = format!("…{s}");
|
||||
}
|
||||
if end < fb.len() {
|
||||
s.push('…');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `MemRead` : une plage de lignes numérotées (1-indexé ; 500 au plus).
|
||||
async fn mem_read(
|
||||
store: &Store,
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<String, String> {
|
||||
let b = Memory::read_page(store, dir, name)
|
||||
.await
|
||||
.map_err(|e| read_err(name, &e))?;
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let offset = if offset <= 0 { 1 } else { offset };
|
||||
let limit = if limit <= 0 || limit > 500 {
|
||||
500
|
||||
} else {
|
||||
limit
|
||||
};
|
||||
let mut out = String::new();
|
||||
let mut i = offset - 1;
|
||||
while (i as usize) < lines.len() && i < offset - 1 + limit {
|
||||
out.push_str(&format!("{}\t{}\n", i + 1, lines[i as usize]));
|
||||
i += 1;
|
||||
}
|
||||
Ok(out.trim_end_matches('\n').to_string())
|
||||
}
|
||||
|
||||
/// `MemAdd` : une nouvelle page, jamais par-dessus une existante ; l'index
|
||||
/// la reçoit toujours.
|
||||
async fn mem_add(
|
||||
store: &Store,
|
||||
dir: &std::path::Path,
|
||||
project_name: &str,
|
||||
name: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let p = Memory::safe_path(dir, name)?;
|
||||
if std::fs::metadata(&p).is_ok() {
|
||||
return Err("la page existe déjà — utilise mem_edit pour la modifier".into());
|
||||
}
|
||||
let body = format!("{}\n", content.trim_end_matches('\n'));
|
||||
Memory::write_page(store, dir, name, body.as_bytes()).await?;
|
||||
Memory::index_add(store, dir, name, project_name).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `errAlreadyApplied`.
|
||||
const ERR_ALREADY_APPLIED: &str = "déjà à jour — la page contient déjà cette modification";
|
||||
|
||||
enum EditErr {
|
||||
/// L'édition est déjà en place : pas un échec.
|
||||
Applied,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// `MemEdit` : `old` doit apparaître exactement une fois.
|
||||
async fn mem_edit(
|
||||
store: &Store,
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<(), EditErr> {
|
||||
let b = Memory::read_page(store, dir, name)
|
||||
.await
|
||||
.map_err(|e| EditErr::Other(read_err(name, &e)))?;
|
||||
let content = String::from_utf8_lossy(&b).into_owned();
|
||||
if old.is_empty() {
|
||||
return Err(EditErr::Other("old vide".into()));
|
||||
}
|
||||
let n = content.matches(old).count();
|
||||
if n == 0 {
|
||||
if !new.is_empty() && content.contains(new) {
|
||||
return Err(EditErr::Applied);
|
||||
}
|
||||
return Err(EditErr::Other("old introuvable dans la page".into()));
|
||||
}
|
||||
if n > 1 {
|
||||
return Err(EditErr::Other(format!(
|
||||
"old apparaît {n} fois — ajoute du contexte pour le rendre unique"
|
||||
)));
|
||||
}
|
||||
let updated = content.replacen(old, new, 1);
|
||||
Memory::write_page(store, dir, name, updated.as_bytes())
|
||||
.await
|
||||
.map_err(EditErr::Other)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trackers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// L'heure murale d'un instant (ms) dans le fuseau des trackers.
|
||||
fn civil_at(tz: Tz, ms: i64) -> crate::tz::Civil {
|
||||
let sec = ms.div_euclid(1000);
|
||||
match tz {
|
||||
Tz::Fixed(off) => crate::tz::civil(sec + off),
|
||||
Tz::Local => crate::tz::Location::local().civil(sec),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fmtTS` : « AAAA-MM-JJ HH:MM ».
|
||||
fn fmt_ts(tz: Tz, ms: i64) -> String {
|
||||
civil_at(tz, ms).ymd_hm()
|
||||
}
|
||||
|
||||
/// `fmtDay` : « AAAA-MM-JJ ».
|
||||
fn fmt_day(tz: Tz, ms: i64) -> String {
|
||||
let c = civil_at(tz, ms);
|
||||
format!("{:04}-{:02}-{:02}", c.year, c.month, c.day)
|
||||
}
|
||||
|
||||
fn fmt_month(tz: Tz, ms: i64) -> String {
|
||||
let c = civil_at(tz, ms);
|
||||
format!("{:04}-{:02}", c.year, c.month)
|
||||
}
|
||||
|
||||
fn fmt_year(tz: Tz, ms: i64) -> String {
|
||||
format!("{:04}", civil_at(tz, ms).year)
|
||||
}
|
||||
|
||||
/// `fmtEvent` : sans heure si le point n'en a pas.
|
||||
fn fmt_event(tz: Tz, e: &TrackerEvent) -> String {
|
||||
if e.date_only {
|
||||
fmt_day(tz, e.ts)
|
||||
} else {
|
||||
fmt_ts(tz, e.ts)
|
||||
}
|
||||
}
|
||||
|
||||
/// `whenParts` : 0 rien, 1 année, 2 mois, 3 jour, 4 heure.
|
||||
fn when_parts(when: &str) -> usize {
|
||||
let w = go_trim(when);
|
||||
if w.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let w = w.replacen('T', " ", 1);
|
||||
let mut date = w.as_str();
|
||||
if let Some(sp) = w.find(' ') {
|
||||
date = &w[..sp];
|
||||
if !go_trim(&w[sp..]).is_empty() {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
date.split('-').count()
|
||||
}
|
||||
|
||||
/// `toolTracker` : le texte vu par le modèle.
|
||||
async fn tracker(env: &MemEnv<'_>, proj: &str, args: &Map<String, Value>) -> String {
|
||||
let action = go_trim(&go_str(args.get("action"))).to_lowercase();
|
||||
let name = go_trim(&go_str(args.get("name"))).to_string();
|
||||
let when = go_trim(&go_str(args.get("when"))).to_string();
|
||||
let text = go_trim(&go_str(args.get("text"))).to_string();
|
||||
let id = go_trim(&go_str(args.get("id"))).to_string();
|
||||
let store = env.store;
|
||||
match action.as_str() {
|
||||
"" | "view" | "read" | "list" => tracker_view(env, proj, &name, &when).await,
|
||||
"add" => match env
|
||||
.mem
|
||||
.tracker_add_at(store, proj, &name, &when, &text, env.now_ms)
|
||||
.await
|
||||
{
|
||||
Ok(id) => format!("[ok] point ajouté à « {name} » (id {id})."),
|
||||
Err(e) => format!("[erreur] {e}"),
|
||||
},
|
||||
"edit" => {
|
||||
if name.is_empty() || id.is_empty() {
|
||||
return "[erreur] name et id sont requis pour modifier un point".into();
|
||||
}
|
||||
let slug = slugify_ident(&name);
|
||||
let res = async {
|
||||
let mut t = Memory::tracker_load(store, proj, &slug)
|
||||
.await
|
||||
.ok_or("tracker introuvable")?;
|
||||
let ev = t.events.get_or_insert_with(Vec::new);
|
||||
let Some(e) = ev.iter_mut().find(|e| e.id == id) else {
|
||||
return Err("événement introuvable".to_string());
|
||||
};
|
||||
if !text.is_empty() {
|
||||
e.text = text.clone();
|
||||
}
|
||||
if !when.is_empty() {
|
||||
let (ts, date_only) = parse_when(&when, env.now_ms, env.mem.tz)?;
|
||||
e.ts = ts;
|
||||
e.date_only = date_only;
|
||||
}
|
||||
Memory::tracker_save(store, proj, &slug, &mut t).await
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => format!("[ok] point {id} de « {name} » modifié."),
|
||||
Err(e) => format!("[erreur] {e}"),
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
if name.is_empty() {
|
||||
return "[erreur] name requis".into();
|
||||
}
|
||||
// Sans id, c'est le tracker entier : réservé à l'interface.
|
||||
if id.is_empty() {
|
||||
return "[erreur] id requis — supprimer un tracker entier se fait depuis l'interface, pas par l'outil".into();
|
||||
}
|
||||
let slug = slugify_ident(&name);
|
||||
let res = async {
|
||||
let mut t = Memory::tracker_load(store, proj, &slug)
|
||||
.await
|
||||
.ok_or("tracker introuvable")?;
|
||||
let ev = t.events.get_or_insert_with(Vec::new);
|
||||
let n = ev.len();
|
||||
ev.retain(|e| e.id != id);
|
||||
if ev.len() == n {
|
||||
return Err("événement introuvable".to_string());
|
||||
}
|
||||
Memory::tracker_save(store, proj, &slug, &mut t).await
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => format!("[ok] point {id} de « {name} » supprimé."),
|
||||
Err(e) => format!("[erreur] {e}"),
|
||||
}
|
||||
}
|
||||
a => format!(
|
||||
"[erreur] action inconnue {} (view, add, edit, delete)",
|
||||
crate::gojson::quote(a)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `trackerView` : vue d'ensemble, ou descente année → mois → points.
|
||||
async fn tracker_view(env: &MemEnv<'_>, proj: &str, name: &str, when: &str) -> String {
|
||||
let tz = env.mem.tz;
|
||||
if go_trim(name).is_empty() {
|
||||
return tracker_overview(env, proj).await;
|
||||
}
|
||||
let slug = slugify_ident(name);
|
||||
let Some(t) = Memory::tracker_load(env.store, proj, &slug).await else {
|
||||
return format!(
|
||||
"[aucun tracker « {name} »] Appelle tracker() sans argument pour la liste, ou ajoute un point avec action:\"add\"."
|
||||
);
|
||||
};
|
||||
let ev = t.events.clone().unwrap_or_default();
|
||||
if ev.is_empty() {
|
||||
return format!("Tracker « {} » : vide pour l'instant.", t.name);
|
||||
}
|
||||
let prefix = go_trim(when).replacen('T', " ", 1);
|
||||
let matched: Vec<TrackerEvent> = ev
|
||||
.iter()
|
||||
.filter(|e| prefix.is_empty() || fmt_ts(tz, e.ts).starts_with(&prefix))
|
||||
.cloned()
|
||||
.collect();
|
||||
match when_parts(when) {
|
||||
0 => tracker_skeleton(tz, &t, &ev, env.now_ms),
|
||||
1 => {
|
||||
if matched.is_empty() {
|
||||
return format!("Tracker « {} » : rien en {when}.", t.name);
|
||||
}
|
||||
let example = format!("when:\"{when}-07\"");
|
||||
format!(
|
||||
"Tracker « {} » en {when} — {} points :\n{}\nZoom : {example} pour un mois.",
|
||||
t.name,
|
||||
matched.len(),
|
||||
group_line(&matched, |e| fmt_month(tz, e.ts))
|
||||
)
|
||||
}
|
||||
_ => tracker_events(tz, &t.name, when, &matched),
|
||||
}
|
||||
}
|
||||
|
||||
/// `trackerOverview` : la liste des trackers du projet.
|
||||
async fn tracker_overview(env: &MemEnv<'_>, proj: &str) -> String {
|
||||
let tz = env.mem.tz;
|
||||
let list = Memory::tracker_list(env.store, proj).await;
|
||||
if list.is_empty() {
|
||||
return "Aucun tracker dans ce projet. Crée-en un en ajoutant un point : action:\"add\", name:\"…\", text:\"…\" (when facultatif = maintenant).".into();
|
||||
}
|
||||
let mut b = format!("Trackers du projet ({}) :\n", list.len());
|
||||
for m in &list {
|
||||
let count = m["count"].as_i64().unwrap_or(0);
|
||||
let span = if count > 0 {
|
||||
format!(
|
||||
" — {count} points, du {} au {}",
|
||||
fmt_day(tz, m["first_ts"].as_i64().unwrap_or(0)),
|
||||
fmt_day(tz, m["last_ts"].as_i64().unwrap_or(0))
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
b.push_str(&format!("- {}{span}\n", m["name"].as_str().unwrap_or("")));
|
||||
}
|
||||
b.push_str("Zoom : tracker(name:\"<nom>\") pour le détail d'un tracker.");
|
||||
b
|
||||
}
|
||||
|
||||
/// Des points regroupés par une clé de date, triés, avec leurs comptes :
|
||||
/// « 2026-07 (3) · … » (`monthsLine`, `trackerGroup`).
|
||||
fn group_line(ev: &[TrackerEvent], key: impl Fn(&TrackerEvent) -> String) -> String {
|
||||
let mut groups: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for e in ev {
|
||||
*groups.entry(key(e)).or_default() += 1;
|
||||
}
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|(g, n)| format!("{g} ({n})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
/// `trackerSkeleton` : les années (avec compte), et l'année en cours par
|
||||
/// mois.
|
||||
fn tracker_skeleton(tz: Tz, t: &Tracker, ev: &[TrackerEvent], now_ms: i64) -> String {
|
||||
let mut b = format!(
|
||||
"Tracker « {} » — {} points, du {} au {}\n",
|
||||
t.name,
|
||||
ev.len(),
|
||||
fmt_day(tz, ev[0].ts),
|
||||
fmt_day(tz, ev[ev.len() - 1].ts)
|
||||
);
|
||||
b.push_str(&format!(
|
||||
"Années : {}\n",
|
||||
group_line(ev, |e| fmt_year(tz, e.ts))
|
||||
));
|
||||
let cur = fmt_year(tz, now_ms);
|
||||
let cur_ev: Vec<TrackerEvent> = ev
|
||||
.iter()
|
||||
.filter(|e| fmt_ts(tz, e.ts).starts_with(&cur))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !cur_ev.is_empty() {
|
||||
b.push_str(&format!(
|
||||
"{cur} (année en cours) : {}\n",
|
||||
group_line(&cur_ev, |e| fmt_month(tz, e.ts))
|
||||
));
|
||||
}
|
||||
b.push_str(&format!(
|
||||
"Zoom : tracker(name:\"{}\", when:\"2025\") pour une année · when:\"{cur}-07\" pour un mois.",
|
||||
t.name
|
||||
));
|
||||
b
|
||||
}
|
||||
|
||||
/// `trackerEventsCap`.
|
||||
const EVENTS_CAP: usize = 60;
|
||||
|
||||
/// `trackerEvents` : les points (id, date, texte), les plus récents d'abord
|
||||
/// gardés au-delà du plafond.
|
||||
fn tracker_events(tz: Tz, name: &str, when: &str, ev: &[TrackerEvent]) -> String {
|
||||
let trimmed = ev.len() > EVENTS_CAP;
|
||||
let shown = if trimmed {
|
||||
&ev[ev.len() - EVENTS_CAP..]
|
||||
} else {
|
||||
ev
|
||||
};
|
||||
let mut b = format!("Tracker « {name} »");
|
||||
if !go_trim(when).is_empty() {
|
||||
b.push_str(&format!(" en {when}"));
|
||||
}
|
||||
if trimmed {
|
||||
b.push_str(&format!(
|
||||
" — {} points (les {EVENTS_CAP} plus récents ; affine le when pour les plus anciens) :\n",
|
||||
ev.len()
|
||||
));
|
||||
} else {
|
||||
b.push_str(&format!(" — {} points :\n", ev.len()));
|
||||
}
|
||||
for e in shown {
|
||||
b.push_str(&format!("- [{}] {} — {}\n", e.id, fmt_event(tz, e), e.text));
|
||||
}
|
||||
b.push_str(
|
||||
"Modifier/supprimer : action:\"edit\"|\"delete\", name, id (l'identifiant entre crochets).",
|
||||
);
|
||||
b
|
||||
}
|
||||
@@ -970,6 +970,7 @@ pub async fn start_with(
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = opts.mcp.clone();
|
||||
shared.tasks = opts.tasks.clone();
|
||||
shared.memory = opts.mem.clone();
|
||||
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -670,6 +670,7 @@ async fn tour_complet_par_le_go() {
|
||||
perf: Arc::new(loki_rs::perfring::PerfRing::new(Default::default())),
|
||||
mcp: None,
|
||||
tasks: None,
|
||||
memory: None,
|
||||
go_version: Default::default(),
|
||||
});
|
||||
let cfg = std::collections::HashMap::new();
|
||||
@@ -2708,3 +2709,40 @@ async fn anneau_de_la_facade_pour_le_go() {
|
||||
assert_eq!(code, 405);
|
||||
drop(go);
|
||||
}
|
||||
|
||||
/// Lot 40 : un outil mémoire appelé par un tour de la façade s'exécute ici
|
||||
/// quand elle tient la mémoire — la page rangée dans le projet actif, l'index
|
||||
/// tenu, le diff montré, rien par le Go.
|
||||
#[tokio::test]
|
||||
async fn outil_memoire_par_la_facade() {
|
||||
let call = sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"m1","type":"function","function":{"name":"mem_add","arguments":"{\"file\":\"chat\",\"content\":\"# Chat\\nIl s'appelle Pixel.\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]);
|
||||
let (url, seen) = fake_engine(vec![call, answer("noté")]).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mem = Arc::new(loki_rs::memory::Memory::new(dir.path().to_path_buf()));
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.memory = Some(mem.clone())).await;
|
||||
*go.crypt_mode.lock().unwrap() = fakego::CryptMode::Plain;
|
||||
svc.send(serde_json::json!({"message": "retiens le nom du chat", "cid": "m"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
|
||||
let page = dir.path().join("memory/generale/chat.md");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&page).unwrap(),
|
||||
"# Chat\nIl s'appelle Pixel.\n"
|
||||
);
|
||||
let index = std::fs::read_to_string(dir.path().join("memory/generale/MEMORY.md")).unwrap();
|
||||
assert!(index.ends_with("- [Chat](chat.md)\n"), "{index}");
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let tool = seen[1].1["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.rfind(|m| m["role"] == "tool")
|
||||
.unwrap()["content"]
|
||||
.clone();
|
||||
assert_eq!(tool, serde_json::json!("[ok] page 'chat' créée"));
|
||||
}
|
||||
@@ -392,3 +392,149 @@ async fn coffre_et_projets_restent_au_go() {
|
||||
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Lot 40 : les outils mémoire de l'agent (mem_search, mem_read, mem_add,
|
||||
/// mem_edit, tracker), rejoués contre le journal du Go
|
||||
/// (`parity_mem_tools_test.go` → `parity_mem/outils.json`) : résultat,
|
||||
/// diff, fichiers de la mémoire et trackers après chaque appel.
|
||||
#[tokio::test]
|
||||
async fn outils_memoire_comme_le_go() {
|
||||
use loki_rs::memory::tools::{MemEnv, exec, is_tool};
|
||||
let fx: Value = serde_json::from_str(include_str!("parity_mem/outils.json")).expect("journal");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
let (_f, go, _stop) = fakego::start(home.clone()).await;
|
||||
*go.crypt_mode.lock().unwrap() = CryptMode::Plain;
|
||||
let backend = go.backend.lock().unwrap().unwrap();
|
||||
let store = loki_rs::store::Store::new(backend, fakego::JETON.into());
|
||||
let mem = Memory::with_tz(home.clone(), Tz::Fixed(0));
|
||||
let dir = home.join("memory").join("generale");
|
||||
let mut ids = TrackerIds::default();
|
||||
let mut now = 0i64;
|
||||
for st in fx.as_array().unwrap() {
|
||||
let name = st["name"].as_str().unwrap();
|
||||
if let Some(n) = st["now"].as_str() {
|
||||
now = rfc3339_ms(n);
|
||||
}
|
||||
let tool = st["tool"].as_str().unwrap();
|
||||
assert!(is_tool(tool), "{name}");
|
||||
// Les identifiants de points déjà vus : {{Tn}} → valeur.
|
||||
let raw = ids.unmask(&st["args"].to_string());
|
||||
let args: serde_json::Map<String, Value> = serde_json::from_str(&raw).unwrap();
|
||||
let label = st["label"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
loki_rs::chat::stream::tool_call_label(tool, &args),
|
||||
label,
|
||||
"{name} : libellé"
|
||||
);
|
||||
let env = MemEnv {
|
||||
store: &store,
|
||||
mem: &mem,
|
||||
now_ms: now,
|
||||
};
|
||||
let out = exec(&env, tool, &args, label).await.expect("outil mémoire");
|
||||
assert_eq!(ids.mask(&out.result), st["out"].as_str().unwrap(), "{name}");
|
||||
let diff = serde_json::to_value(&out.diff).unwrap();
|
||||
if st["diff"].is_null() {
|
||||
assert!(out.diff.is_empty(), "{name} : diff {diff}");
|
||||
} else {
|
||||
assert_eq!(diff, st["diff"], "{name} : diff");
|
||||
}
|
||||
assert_eq!(
|
||||
(out.added, out.removed),
|
||||
(
|
||||
st["added"].as_i64().unwrap(),
|
||||
st["removed"].as_i64().unwrap()
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
let mut files = serde_json::Map::new();
|
||||
for (rel, content) in walk(&dir) {
|
||||
files.insert(rel, Value::String(content));
|
||||
}
|
||||
assert_eq!(Value::Object(files), st["files"], "{name} : fichiers");
|
||||
let mut trackers = serde_json::Map::new();
|
||||
let all = store.all("tracker").await.unwrap_or_default();
|
||||
for k in all.keys() {
|
||||
let b = store.get_open("tracker", k).await.unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&ids.mask(&String::from_utf8_lossy(&b))).unwrap();
|
||||
trackers.insert(k.clone(), v);
|
||||
}
|
||||
assert_eq!(Value::Object(trackers), st["trackers"], "{name} : trackers");
|
||||
}
|
||||
assert!(
|
||||
exec(
|
||||
&MemEnv {
|
||||
store: &store,
|
||||
mem: &mem,
|
||||
now_ms: now
|
||||
},
|
||||
"bash",
|
||||
&serde_json::Map::new(),
|
||||
""
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// Identifiants de points de trackers → {{T1}}, {{T2}}… dans l'ordre où
|
||||
/// ils apparaissent (même règle que le journal du Go).
|
||||
#[derive(Default)]
|
||||
struct TrackerIds {
|
||||
ids: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl TrackerIds {
|
||||
fn mask(&mut self, s: &str) -> String {
|
||||
let re = regex::Regex::new(r"\b[0-9]{18,20}\b").unwrap();
|
||||
re.replace_all(s, |c: ®ex::Captures| {
|
||||
let id = c[0].to_string();
|
||||
if let Some((_, p)) = self.ids.iter().find(|(i, _)| *i == id) {
|
||||
return p.clone();
|
||||
}
|
||||
let p = format!("{{{{T{}}}}}", self.ids.len() + 1);
|
||||
self.ids.push((id, p.clone()));
|
||||
p
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn unmask(&self, s: &str) -> String {
|
||||
let mut s = s.to_string();
|
||||
// Les plus longs d'abord : {{T10}} avant {{T1}}.
|
||||
let mut ids: Vec<_> = self.ids.iter().collect();
|
||||
ids.sort_by_key(|(_, p)| std::cmp::Reverse(p.len()));
|
||||
for (id, p) in ids {
|
||||
s = s.replace(p, id);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn rfc3339_ms(v: &str) -> i64 {
|
||||
let p: Vec<i64> = v[..10].split('-').map(|x| x.parse().unwrap()).collect();
|
||||
let h: Vec<i64> = v[11..19].split(':').map(|x| x.parse().unwrap()).collect();
|
||||
loki_rs::tz::Location::utc().date(p[0], p[1], p[2], h[0], h[1], h[2]) * 1000
|
||||
}
|
||||
|
||||
/// Les fichiers d'un dossier, chemin relatif → contenu.
|
||||
fn walk(dir: &Path) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else {
|
||||
let rel = p.strip_prefix(dir).unwrap().to_string_lossy().into_owned();
|
||||
out.insert(rel, std::fs::read_to_string(&p).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user