From 1e5f0904f2f388315de8a80eb5a260058cdbd8fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:33:22 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2013=20:=20m=C3=A9moire=20tenue=20par=20la?= =?UTF-8?q?=20fa=C3=A7ade=20(LOKI=5FMEM=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert l'éditeur des pages mémoire (lecture, enregistrement, renommage, suppression, déplacement vers un autre projet, index MEMORY.md tenu par le code), le panneau des trackers, le mode mémoire du projet actif et les interrupteurs du mode agent, quand LOKI_MEM=rust (expérimental ; le Go reste le défaut). Les pages restent des fichiers memory//*.md écrits de façon sûre (.bak, temporaire synchronisé puis renommé, relecture vérifiée), les trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et n'écrit jamais en clair quand la mémoire est verrouillée. gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF, erreurs du scanner et de type, structs anonymes) pour renvoyer au client les mêmes messages que le Go ; il servira aux lots suivants. Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go. Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ; parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de route avec le disque et la base après chacun) → tests/memory.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...-15-rust-migration-lot13-memoire-design.md | 117 + internal/loki/facade_chat.go | 2 + internal/loki/facade_chat_test.go | 34 + internal/loki/facade_mem.go | 45 + internal/loki/parity_gojson_test.go | 85 + internal/loki/parity_mem_test.go | 442 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/gojson.rs | 687 ++ rust/loki-rs/src/lib.rs | 2 + rust/loki-rs/src/main.rs | 37 +- rust/loki-rs/src/memory.rs | 1379 +++ rust/loki-rs/src/proxy.rs | 8 + rust/loki-rs/tests/common/fakego.rs | 34 + rust/loki-rs/tests/gojson.rs | 41 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/memory.rs | 394 + rust/loki-rs/tests/parity_gojson/decode.json | 1052 +++ rust/loki-rs/tests/parity_mem/mem.json | 7597 +++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 11944 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md create mode 100644 internal/loki/facade_mem.go create mode 100644 internal/loki/parity_gojson_test.go create mode 100644 internal/loki/parity_mem_test.go create mode 100644 rust/loki-rs/src/gojson.rs create mode 100644 rust/loki-rs/src/memory.rs create mode 100644 rust/loki-rs/tests/gojson.rs create mode 100644 rust/loki-rs/tests/memory.rs create mode 100644 rust/loki-rs/tests/parity_gojson/decode.json create mode 100644 rust/loki-rs/tests/parity_mem/mem.json diff --git a/README.md b/README.md index 6b58d0d..e30d5b1 100644 --- a/README.md +++ b/README.md @@ -233,6 +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 | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md new file mode 100644 index 0000000..dcf05a9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md @@ -0,0 +1,117 @@ +# Lot 13 — mémoire tenue par la façade + +## Contexte + +La mémoire de Loki vivait dans le Go : +- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ; +- `moveMemPage` (`projects.go`) pour le déplacement entre projets ; +- `tracker.go` et `web_tracker.go` pour les trackers ; +- les routes du mode agent. + +Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`, +expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) | +| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit | +| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle | +| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent | +| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages | +| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) | +| `POST /api/agent/compact` | compactage automatique (`COMPACT`) | +| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` | +| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement | + +### Disque et base + +Les pages restent des fichiers `memory//.md`, et l'index +`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour +sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures +restent sûres, comme dans le Go : +- un `.bak` de la version précédente est gardé ; +- le fichier est écrit dans un temporaire, synchronisé, puis renommé ; +- le contenu est relu et comparé octet par octet. + +Les trackers restent dans le bucket `tracker`, sous la clé +`/`. Le registre des projets (`state/projects`) est lu tel +quel. Le mode mémoire y est écrit avec l'échappement HTML de +`encoding/json`, pour garder les mêmes octets en base. + +### Chiffrement + +La clé de la mémoire ne quitte pas le Go : +- une page qui porte le magic `LOKIMEMv1` est ouverte par + `/internal/v1/crypt/open` ; +- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`, + qui rend le clair tel quel quand le chiffrement est coupé. + +Quand la mémoire est verrouillée : +- les pages restent listées avec le titre « 🔒 (chiffré — mémoire + verrouillée) » ; +- les trackers illisibles sont tus ; +- aucune écriture ne retombe en clair. + +### Messages d'erreur JSON + +Ces routes renvoient au client le message d'erreur de décodage du Go. +`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` : +- seule la première valeur compte ; +- `EOF` et `unexpected EOF` ; +- les erreurs du scanner (`invalid character …`) ; +- les erreurs de type, champs anonymes compris ; +- la casse des noms de champs, et l'allocation des pointeurs. + +`gojson.rs` est mutualisé pour les lots suivants. + +## Restent au Go + +- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`). +- La santé, les instantanés, la sauvegarde. +- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion. +- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en + RAM du Go : la façade sert toujours le projet actif rangé. + +## Écarts assumés + +- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8 + invalide, nombre hors limites) donne le message de serde. +- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par + `mktime`. + +## Parité + +### Décodage JSON + +`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs +et message d'erreur. `tests/gojson.rs` les rejoue. + +### Mémoire + +`internal/loki/parity_mem_test.go` tire du Go : +- les noms de pages, les titres, les slugs et les modes mémoire ; +- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ; +- 102 appels de route, avec après chacun le dossier `memory/` complet + (`.bak` compris) et les clés touchées en base (mode agent, compactage, + mode mémoire, projets, trackers). + +Ces appels couvrent : +- les pages : création, renommage, réenregistrement qui perd l'accroche, + noms refusés, fichiers cachés et dossiers ignorés ; +- le déplacement, avec ses six refus ; +- la bascule de projet ; +- le mode agent et le compactage ; +- le mode mémoire, y compris un projet actif inconnu ; +- les trackers : ajout, modification, suppression, renommage, déplacement, + valeurs illisibles et événements nuls. + +`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des +identifiants et des instants « maintenant ». + +Une mutation de chaque harnais fait échouer son rejeu. + +Un essai de bout en bout simule le chiffrement par le faux Go : +- les pages sont écrites scellées et relues en clair ; +- l'index reste en clair ; +- les trackers sont scellés ; +- une fois la mémoire verrouillée, rien n'est réécrit en clair. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 7e1b5d9..28de305 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -160,6 +160,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeFiles, "fichiers indisponibles : " case facadePushRoutes[path]: held, unavailable = facadePush, "notifications indisponibles : " + case facadeMemRoutes[path]: + held, unavailable = facadeMem, "mémoire indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index bdc8e1d..18289fb 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -511,3 +511,37 @@ func TestFacadeNotifications(t *testing.T) { t.Fatalf("envoi délégué sans la variable (%d appels)", n) } } + +func TestFacadeMemoire(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + // Sans la variable : le Go sert lui-même. + for p := range facadeMemRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s servi ailleurs sans LOKI_MEM : %q", p, w.Body.String()) + } + } + t.Setenv(facadeMemEnv, "rust") + for p := range facadeMemRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadeMemRoutes) { + t.Fatalf("reçu par la façade : %d appels", n) + } + // Les routes voisines (coffre, projets) restent au Go. + for _, p := range []string{"/api/mem/health", "/api/mem/unlock", "/api/projects", "/api/backup/export"} { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s remis à la façade : %q", p, w.Body.String()) + } + } +} diff --git a/internal/loki/facade_mem.go b/internal/loki/facade_mem.go new file mode 100644 index 0000000..884dfbe --- /dev/null +++ b/internal/loki/facade_mem.go @@ -0,0 +1,45 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_mem.go — mémoire tenue par la façade Rust (LOKI_MEM=rust, lot 13 de +// la migration ; expérimental, le Go reste le défaut) : éditeur des pages, +// index MEMORY.md, déplacement d'une page vers un autre projet, panneau des +// trackers, mode mémoire et interrupteurs du mode agent. +// +// Les pages restent des fichiers memory//*.md et les trackers des +// valeurs du bucket tracker : ce process continue de les lire et de les +// écrire pour sa propre boucle (outils mem_* et tracker, index injecté au +// modèle). Le chiffrement reste ici (la clé ne quitte pas ce process) : la +// façade fait sceller et ouvrir ses contenus par /internal/v1/crypt/*. +// +// Seules les routes changent de main ; celles qui arrivent ici par le tunnel +// du relais sont remises à la façade (withFacadeChat). + +const facadeMemEnv = "LOKI_MEM" + +// facadeMem : la façade tient-elle la mémoire ? +func facadeMem() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMemEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeMemRoutes : les routes que la façade sert alors (memory.rs). +var facadeMemRoutes = map[string]bool{ + "/api/mem": true, + "/api/mem/save": true, + "/api/mem/delete": true, + "/api/mem/move": true, + "/api/agent": true, + "/api/tools": true, + "/api/skills": true, + "/api/agent/toggle": true, + "/api/tools/toggle": true, + "/api/skills/toggle": true, + "/api/agent/compact": true, + "/api/memory": true, + "/api/tracker": true, +} diff --git a/internal/loki/parity_gojson_test.go b/internal/loki/parity_gojson_test.go new file mode 100644 index 0000000..1fc6443 --- /dev/null +++ b/internal/loki/parity_gojson_test.go @@ -0,0 +1,85 @@ +package loki + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_gojson_test.go — journal de référence du décodage JSON des requêtes +// (json.NewDecoder(r.Body).Decode(&req)) : champs décodés et message d'erreur, +// que plusieurs routes renvoient tels quels au client. Rangé dans +// rust/loki-rs/tests/parity_gojson/decode.json ; tests/gojson.rs le rejoue +// contre src/gojson.rs. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDecodageJSON + +const parityGoJSONFile = "../../rust/loki-rs/tests/parity_gojson/decode.json" + +type parityDecodeT struct { + Name string `json:"name"` + On bool `json:"on"` + N int `json:"n"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + Opt *string `json:"opt"` + Flag *bool `json:"flag"` +} + +func TestPariteDecodageJSON(t *testing.T) { + type cas struct { + In string `json:"in"` + Out parityDecodeT `json:"out"` + Err string `json:"err,omitempty"` + } + var fx struct { + Cases []cas `json:"cases"` + } + bodies := []string{ + ``, ` `, "\n\t", `pas du json`, `{`, `{"name"`, `{"name":`, `{"name":"a"`, `{"name":"a",}`, + `{"name" "a"}`, `{"name":"a" "on":true}`, `{name:"a"}`, `{'name':"a"}`, `[`, `[1,]`, `[1 2]`, + `{"name":"a"} et la suite`, `{"name":"a"}{"name":"b"}`, `null`, `true`, `"texte"`, `12`, `[1,2]`, + `{"name":5}`, `{"name":true}`, `{"name":{}}`, `{"name":[]}`, `{"name":null}`, `{"NAME":"maj"}`, + `{"Name":"a","name":"b"}`, `{"on":"oui"}`, `{"on":1}`, `{"on":true,"name":3,"n":4}`, + `{"n":1.5}`, `{"n":"3"}`, `{"n":-7}`, `{"n":1e2}`, `{"args":["a","b"]}`, `{"args":["a",1,"c"]}`, + `{"args":"x"}`, `{"args":[]}`, `{"args":[null]}`, `{"env":{"A":"1","B":2}}`, `{"env":[]}`, + `{"opt":""}`, `{"opt":null}`, `{"opt":"x","flag":false}`, `{"flag":"non"}`, + `{"name":"été"}`, `{"name":"a\nb"}`, "{\"name\":\"a\x01b\"}", `{"name":"\x"}`, + `{"name":"\u12G4"}`, `tru`, `trux`, `nul`, `nulx`, `fals`, `falsy`, `-`, `-x`, `1.`, `1.x`, `1e`, + `1ex`, `{"a":1.}`, `{"a":01}`, `{"a":-}`, `[1,2,3}`, `{"a":1]`, "\x01", "\x7f", "\xc3\xa9", + `'x'`, `"x`, `{"a":"b"}}`, `}`, `]`, `,`, `:`, `{"a" : [ { "b" : null } ] , "name" : "ok" }`, + } + for _, b := range bodies { + var v parityDecodeT + c := cas{In: b} + if err := json.NewDecoder(strings.NewReader(b)).Decode(&v); err != nil { + c.Err = err.Error() + } + c.Out = v + fx.Cases = append(fx.Cases, c) + } + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityGoJSONFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityGoJSONFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityGoJSONFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if string(got) != string(want) { + t.Errorf("le décodage JSON ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDecodageJSON,\n"+ + "puis cargo test --test gojson dit si le port Rust suit.\n--- obtenu ---\n%s", parityGoJSONFile, got) + } +} diff --git a/internal/loki/parity_mem_test.go b/internal/loki/parity_mem_test.go new file mode 100644 index 0000000..7c542a3 --- /dev/null +++ b/internal/loki/parity_mem_test.go @@ -0,0 +1,442 @@ +package loki + +import ( + "encoding/json" + "io/fs" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +// parity_mem_test.go — journal de référence de la mémoire (lot 13 de la +// migration Rust) : noms et titres de pages, slugs, modes mémoire, dates des +// trackers, puis une suite d'appels aux routes (éditeur des pages, index +// MEMORY.md, déplacement entre projets, mode agent, mode mémoire, trackers) +// avec, après chacun, le contenu du dossier memory/ et des clés touchées en +// base. Rangé dans rust/loki-rs/tests/parity_mem/mem.json ; tests/memory.rs le +// rejoue par la façade. +// +// Les dates sont lues dans un fuseau fixe (UTC+2) ; les identifiants de points +// (horodatages en ns) deviennent {{ID1}}, {{ID2}}… dans l'ordre d'apparition, +// et les instants « maintenant » {{NOW}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMemoire + +const parityMemFile = "../../rust/loki-rs/tests/parity_mem/mem.json" + +type parityMemStep struct { + // Avant l'appel : fichiers sous memory/ et valeurs en base (« bucket/clé ») + // posés ; une valeur vide supprime. + Files map[string]string `json:"files,omitempty"` + Store map[string]string `json:"store,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + // Après l'appel. + Disk map[string]string `json:"disk"` + State any `json:"state"` +} + +type parityMemWhen struct { + In string `json:"in"` + TS any `json:"ts,omitempty"` + DateOnly bool `json:"date_only,omitempty"` + Err string `json:"err,omitempty"` +} + +type parityMemFixture struct { + Names []parityNameCase `json:"names"` + Titles map[string]string `json:"titles"` + Slugs map[string]string `json:"slugs"` + Modes map[string]string `json:"modes"` + When []parityMemWhen `json:"when"` + Seed map[string]string `json:"seed"` + Steps []parityMemStep `json:"steps"` +} + +type parityNameCase struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` +} + +var ( + parityIDRe = regexp.MustCompile(`"id":"(\d{15,})"`) + parityNowRe = regexp.MustCompile(`"(ts|first_ts|last_ts)":(\d{12,14})`) +) + +// parityMasker remplace identifiants et instants « maintenant » d'un JSON +// canonique (clés triées), dans l'ordre d'apparition. +type parityMasker struct { + ids map[string]string +} + +func (m *parityMasker) mask(s string) string { + s = parityIDRe.ReplaceAllStringFunc(s, func(x string) string { + id := parityIDRe.FindStringSubmatch(x)[1] + ph, ok := m.ids[id] + if !ok { + ph = "{{ID" + strconv.Itoa(len(m.ids)+1) + "}}" + m.ids[id] = ph + } + return `"id":"` + ph + `"` + }) + now := time.Now().UnixMilli() + return parityNowRe.ReplaceAllStringFunc(s, func(x string) string { + sm := parityNowRe.FindStringSubmatch(x) + v, _ := strconv.ParseInt(sm[2], 10, 64) + if d := v - now; d > -86400000 && d < 86400000 { + return `"` + sm[1] + `":"{{NOW}}"` + } + return x + }) +} + +// unmask : les {{IDn}} d'un corps de requête rendus à leur valeur. +func (m *parityMasker) unmask(s string) string { + for id, ph := range m.ids { + s = strings.ReplaceAll(s, ph, id) + } + return s +} + +// canon : JSON aux clés triées, masqué, relu. +func (m *parityMasker) canon(t *testing.T, raw []byte) any { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("JSON illisible : %v (%s)", err, raw) + } + b, _ := json.Marshal(v) + var out any + if err := json.Unmarshal([]byte(m.mask(string(b))), &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestPariteMemoire(t *testing.T) { + home := testHome(t) + old := time.Local + time.Local = time.FixedZone("essai", 7200) + t.Cleanup(func() { time.Local = old }) + + var fx parityMemFixture + for _, in := range []string{"", " ", "notes", "notes.md", "NOTES.MD", "a b", "../x", "x/y", "é", "a.b-c_d", + ".md", ".", "..", "x.md.md", " pad ", "tab\t", "MEMORY", "memory.md", "x.Md"} { + c := parityNameCase{In: in} + if out, err := memFileName(in); err != nil { + c.Err = err.Error() + } else { + c.Out = out + } + fx.Names = append(fx.Names, c) + } + fx.Titles = map[string]string{} + for _, in := range []string{"", "\n\n", "# Titre", "## Titre ##", " ### ", "\n \n# A\n# B", "sans dièse\nsuite", + "#\n#\nVrai titre", "\t# tab", "#  espace insécable ", "# # double"} { + fx.Titles[in] = titleOf(in) + } + fx.Slugs = map[string]string{} + for _, in := range []string{"Réseau NAS", " Hello World ", "àâäáã éèêë", "İstanbul", "Ωmega", strings.Repeat("ab", 30), + "---x---", "", "Ça_va?", "ÉCOLE", "Straße", strings.Repeat("a", 39) + "-bcd", "!!!", "Perso & Cie", "ÎLE ÔÔ ÙÛ Ñ Ç"} { + fx.Slugs[in] = slugifyIdent(in) + } + fx.Modes = map[string]string{} + for _, in := range []string{"", "off", " OFF ", "ondemand", "on-demand", "search", "always", "auto", "bizarre"} { + fx.Modes[in] = string(normalizeMemMode(in)) + } + mk := &parityMasker{ids: map[string]string{}} + for _, in := range []string{"", "now", "NOW", " Maintenant ", "2024-03-10", "2024-03-10T08:15", "2024-03-10 7:05", + "2024-03-10 07:5", "2024-03-10 07:05:09", "2024-03-10 07:05:09.5", "2024-03-10 07:05:09,123456789999", + "2024-03-10 24:00", "2024-03-10 23:60", "2024-13-01", "2024-02-29", "2023-02-29", "2024-04-31", "2024-03", + "2024", "24", "2024-3", "2024-03-1", " 2024-03-10 ", "2024-03-10 07:05", "demain", "2024-03-10T07:05Z", + "today", "+024", "0000-01-01", "9999-12-31 23:59:59", "1234", "2024-03-10 07", "2024-03-10 7", + "x\x01y", "2024-03-10T", "2024-03-10 07:05:60"} { + c := parityMemWhen{In: in} + ts, dateOnly, err := parseWhen(in) + if err != nil { + c.Err = err.Error() + } else { + c.TS = ts + if d := ts - time.Now().UnixMilli(); d > -86400000 && d < 86400000 { + c.TS = "{{NOW}}" + } + c.DateOnly = dateOnly + } + fx.When = append(fx.When, c) + } + + // Base de départ : deux projets, le premier actif, mode mémoire global. + fx.Seed = map[string]string{ + "state/projects": `[{"slug":"generale","name":"Générale","created_at":1000},{"slug":"perso","name":"Perso & Cie","created_at":2000,"desc":"à moi"}]`, + "state/active_project": "generale", + "config/MEM_MODE": "ondemand", + } + apply := func(store map[string]string) { + keys := make([]string, 0, len(store)) + for k := range store { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b, key, _ := strings.Cut(k, "/") + if b == bkState && key == stActiveProjectKey { + if store[k] == "" { + putStr(bkState, key, "") + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + } else if err := setActiveProject(store[k]); err != nil { + t.Fatal(err) + } + continue + } + if err := putStr(b, key, store[k]); err != nil { + t.Fatal(err) + } + } + } + apply(fx.Seed) + + memRoot := filepath.Join(home, "memory") + disk := func() map[string]string { + out := map[string]string{} + _ = filepath.WalkDir(memRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(memRoot, p) + b, _ := os.ReadFile(p) + out[filepath.ToSlash(rel)] = string(b) + return nil + }) + return out + } + state := func() any { + st := map[string]any{ + "agent": getStr(bkState, "agent"), + "active": getStr(bkState, stActiveProjectKey), + "compact": ReadConfig()["COMPACT"], + "mem_mode": ReadConfig()["MEM_MODE"], + "projects": nil, + "trackers": map[string]any{}, + "tracker_raw": map[string]string{}, + } + if b := getBytes(bkState, stProjectsKey); len(b) > 0 { + var v any + if json.Unmarshal(b, &v) == nil { + st["projects"] = v + } + } + for k, v := range allKV(bkTracker) { + var x any + if json.Unmarshal([]byte(v), &x) == nil { + st["trackers"].(map[string]any)[k] = x + } else { + st["tracker_raw"].(map[string]string)[k] = v + } + } + b, _ := json.Marshal(st) + return mk.canon(t, b) + } + + run := func(st parityMemStep) parityMemStep { + for rel, content := range st.Files { + p := filepath.Join(memRoot, filepath.FromSlash(rel)) + if content == "" { + _ = os.RemoveAll(p) + continue + } + if strings.HasSuffix(rel, "/") { + _ = os.MkdirAll(p, 0o755) + continue + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + apply(st.Store) + w := httptest.NewRecorder() + r := httptest.NewRequest(st.Method, mk.unmask(st.Path), strings.NewReader(mk.unmask(st.Body))) + switch r.URL.Path { + case "/api/mem": + handleMem(w, r) + case "/api/mem/save": + handleMemSave(w, r) + case "/api/mem/delete": + handleMemDelete(w, r) + case "/api/mem/move": + handleMemMove(w, r) + case "/api/agent", "/api/tools", "/api/skills": + handleAgent(w, r) + case "/api/agent/toggle", "/api/tools/toggle", "/api/skills/toggle": + handleAgentToggle(w, r) + case "/api/agent/compact": + handleCompactToggle(w, r) + case "/api/memory": + handleMemoryMode(w, r) + case "/api/tracker": + handleTracker(w, r) + default: + t.Fatalf("route inattendue %s", st.Path) + } + st.Status = w.Code + st.Out = mk.canon(t, w.Body.Bytes()) + st.Disk = disk() + st.State = state() + return st + } + + for _, st := range []parityMemStep{ + // --- pages --- + {Method: "GET", Path: "/api/mem"}, + {Method: "GET", Path: "/api/mem?name=absente"}, + {Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses","content":"# Courses\n\nlait\n\n\n"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"notes.md","content":"Sans titre"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"../evil","content":"x"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":" ","content":"x"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `pas du json`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":5}`}, + {Method: "POST", Path: "/api/mem/save", Body: `[1]`}, + {Method: "POST", Path: "/api/mem/save", Body: ``}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses-v2","old":"courses","content":"# Courses v2\nœufs & "}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"notes","old":"notes","content":"## Titre\nmaj"}`}, + {Method: "GET", Path: "/api/mem?name=courses-v2"}, + {Method: "GET", Path: "/api/mem?name=+courses-v2.md+"}, + {Method: "GET", Path: "/api/mem?name=a&name=courses-v2"}, + {Method: "GET", Path: "/api/mem?name=../x"}, + {Files: map[string]string{"generale/Zeta.MD": "# Zêta\n", "generale/.cache.md": "caché", "generale/dossier.md/": "-", + "generale/notes.txt": "pas une page", "generale/vide.md": "\n\n"}, + Method: "GET", Path: "/api/tools"}, + {Method: "POST", Path: "/api/mem/delete", Body: `{"name":"absente"}`}, + {Method: "POST", Path: "/api/mem/delete", Body: `{"name":"bad/name"}`}, + {Method: "POST", Path: "/api/mem/delete", Body: `{"name":"vide"}`}, + {Method: "POST", Path: "/api/mem/delete", Body: `{nope}`}, + // Accroche écrite à la main : un réenregistrement du même nom la perd + // (ligne retirée puis rajoutée à la fin), comme le Go. + {Files: map[string]string{"generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v2](courses-v2.md) — la liste\n- [Titre](notes.md)\n"}, + Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses-v2","old":"courses-v2","content":"# Courses v3"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"neuve","content":"# Neuve"}`}, + // --- déplacement --- + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"notes","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"notes","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":"generale"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":" generale "}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":"inconnu"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"MEMORY","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"x/y","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `[]`}, + {Method: "GET", Path: "/api/mem/move"}, + {Files: map[string]string{"generale/doublon.md": "# A", "perso/doublon.md": "# B"}, + Method: "POST", Path: "/api/mem/move", Body: `{"name":"doublon","to":"perso"}`}, + // Bascule vers le projet perso : ses pages, son nom. + {Store: map[string]string{"state/active_project": "perso"}, Method: "GET", Path: "/api/skills"}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"perso-1","content":"Première\nligne"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"doublon","to":"generale"}`}, + {Store: map[string]string{"state/active_project": "generale"}, Method: "GET", Path: "/api/agent"}, + // --- mode agent, compactage --- + {Method: "POST", Path: "/api/agent/toggle", Body: `{"on":true}`}, + {Method: "GET", Path: "/api/tools"}, + {Method: "POST", Path: "/api/skills/toggle", Body: `{"on":"oui"}`}, + {Method: "POST", Path: "/api/tools/toggle", Body: `{"on":true}`}, + {Method: "POST", Path: "/api/agent/toggle", Body: `pas du json`}, + {Method: "POST", Path: "/api/agent/compact", Body: `{"on":false}`}, + {Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/agent/compact", Body: `{}`}, + {Store: map[string]string{"config/COMPACT": " NON "}, Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/agent/compact", Body: `{"on":true}`}, + // --- mode mémoire --- + {Method: "GET", Path: "/api/memory"}, + {Method: "POST", Path: "/api/memory", Body: `{"mode":" Search "}`}, + {Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/memory", Body: `pas du json`}, + {Method: "POST", Path: "/api/memory", Body: `{"mode":"off"}`}, + {Method: "PUT", Path: "/api/memory", Body: `{"mode":"ondemand"}`}, + {Store: map[string]string{"state/active_project": "fantome"}, Method: "POST", Path: "/api/memory", Body: `{"mode":"off"}`}, + {Method: "GET", Path: "/api/memory"}, + {Store: map[string]string{"state/active_project": "perso"}, Method: "POST", Path: "/api/memory", Body: `{"mode":"ondemand"}`}, + {Store: map[string]string{"state/active_project": "generale"}, Method: "GET", Path: "/api/memory"}, + // --- trackers --- + {Method: "GET", Path: "/api/tracker"}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Poids","when":"2024-03-10","text":" 78,2 kg "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Poids","when":"2024-03-09 08:15","text":"78,9"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"poids","text":"77"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"ADD","name":"Réseau NAS","when":"2023","text":"installé & fini"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"","text":"x"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"x","text":" "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"x","when":"demain","text":"y"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"!!!","when":"2025-01","text":"z"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Même jour","when":"2025-01","text":"m"}`}, + {Method: "GET", Path: "/api/tracker?slug=poids"}, + {Method: "GET", Path: "/api/tracker?slug=absent"}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID2}}","text":" 78,0 kg "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID2}}","when":"2024-03-11 07:00"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID1}}","when":"hier","text":"perdu"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"999","text":"x"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"absent","id":"{{ID1}}","text":"x"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"poids","id":"{{ID1}}"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"poids","id":"{{ID1}}"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"reseau-nas","name":" Réseau NAS (bureau) "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"poids","name":"POIDS"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":"Poids"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":" "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":"???"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"absent","name":"Neuf"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"tracker","to":"perso"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"poids","to":"inconnu"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"poids","to":"generale"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"absent","to":"perso"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Même jour","when":"2025-02","text":"perso"}`, + Store: map[string]string{"state/active_project": "perso"}}, + {Store: map[string]string{"state/active_project": "generale"}, + Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"meme-jour","to":"perso"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete_tracker","slug":"poids"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete_tracker","slug":"absent"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"bidule"}`}, + {Method: "POST", Path: "/api/tracker", Body: `pas du json`}, + {Method: "POST", Path: "/api/tracker", Body: `"texte"`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":3}`}, + {Method: "PUT", Path: "/api/tracker"}, + {Store: map[string]string{"tracker/generale/casse": "pas du json"}, Method: "GET", Path: "/api/tracker"}, + {Method: "GET", Path: "/api/tracker?slug=casse"}, + {Store: map[string]string{"tracker/generale/vide": `{"name":"Vide","events":null}`}, Method: "GET", Path: "/api/tracker?slug=vide"}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"vide","name":"VIDE"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"vide","id":"1"}`}, + {Method: "GET", Path: "/api/tracker"}, + } { + fx.Steps = append(fx.Steps, run(st)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityMemFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityMemFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityMemFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if string(got) != string(want) { + t.Errorf("la mémoire ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMemoire,\n"+ + "puis cargo test --test memory dit si le port Rust suit.\n--- obtenu ---\n%s", parityMemFile, got) + } +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 0e34e26..9f2a1a5 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -38,6 +38,8 @@ pub struct Ctx { pub files: Option>, /// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). pub push: Option>, + /// Mémoire tenue par la façade (`LOKI_MEM=rust`). + pub mem: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -52,6 +54,7 @@ impl Ctx { || self.settings || self.files.is_some() || self.push.is_some() + || self.mem.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/gojson.rs b/rust/loki-rs/src/gojson.rs new file mode 100644 index 0000000..415b322 --- /dev/null +++ b/rust/loki-rs/src/gojson.rs @@ -0,0 +1,687 @@ +//! Décodage JSON au comportement d'`encoding/json` du Go +//! (`json.NewDecoder(r.Body).Decode(&req)`), pour les routes portées qui +//! renvoient le message d'erreur du Go au client (`{"error": err.Error()}`). +//! +//! Ce qui est reproduit : +//! - seule la PREMIÈRE valeur du corps compte (ce qui suit est ignoré) ; +//! - corps vide → `EOF`, valeur tronquée → `unexpected EOF` ; +//! - erreurs de syntaxe du scanner Go (`invalid character 'p' looking for +//! beginning of value`, `… after object key`, `… in string literal`…) ; +//! - `null` ne touche à rien ; noms de champs sans égard à la casse ; +//! - erreurs de type (`json: cannot unmarshal number into Go struct field +//! saveReq.name of type string`) : la première est rendue, les autres champs +//! sont décodés quand même, comme le Go. + +use serde::de::{Deserialize, Deserializer, MapAccess, Visitor}; +use serde_json::value::RawValue; +use serde_json::{Map, Value}; +use std::marker::PhantomData; + +/// Type Go d'un champ de la struct cible. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// `string` + Str, + /// `bool` + Bool, + /// `int` + Int, + /// `[]string` + StrList, + /// `map[string]string` + StrMap, + /// `*string` : présent même vide, `null` = absent. + OptStr, + /// `*bool` + OptBool, + /// `any` / `json.RawMessage` : tout est accepté tel quel. + Any, +} + +impl Kind { + fn go_type(self) -> &'static str { + match self { + Kind::Str => "string", + Kind::Bool => "bool", + Kind::Int => "int", + Kind::StrList => "[]string", + Kind::StrMap => "map[string]string", + Kind::OptStr => "*string", + Kind::OptBool => "*bool", + Kind::Any => "interface {}", + } + } +} + +/// Résultat d'un décodage : les champs posés (clé = nom JSON de la struct, +/// valeur déjà au bon type) et la première erreur, comme `Decode` la rend. +#[derive(Debug, Default)] +pub struct Decoded { + pub fields: Map, + pub err: Option, +} + +impl Decoded { + /// Champ texte (`""` si absent). + pub fn str(&self, name: &str) -> String { + self.fields + .get(name) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + } + + /// Champ `*string` : `None` si absent ou `null`. + pub fn opt_str(&self, name: &str) -> Option { + self.fields + .get(name) + .and_then(Value::as_str) + .map(str::to_string) + } + + /// Champ booléen (`false` si absent). + pub fn bool(&self, name: &str) -> bool { + self.fields + .get(name) + .and_then(Value::as_bool) + .unwrap_or(false) + } + + /// Champ `*bool`. + pub fn opt_bool(&self, name: &str) -> Option { + self.fields.get(name).and_then(Value::as_bool) + } + + /// Champ entier (`0` si absent). + pub fn int(&self, name: &str) -> i64 { + self.fields.get(name).and_then(Value::as_i64).unwrap_or(0) + } + + /// Champ `[]string` (`None` = nil côté Go). + pub fn str_list(&self, name: &str) -> Option> { + self.fields.get(name).and_then(Value::as_array).map(|a| { + a.iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + } + + /// Champ `map[string]string` (`None` = nil côté Go). + pub fn str_map(&self, name: &str) -> Option> { + self.fields.get(name).and_then(Value::as_object).map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect() + }) + } +} + +/// `json.NewDecoder(body).Decode(&v)` avec `v` une struct Go dont les +/// champs sont `fields` (nom JSON, type). `ty` : nom de la struct sans le +/// paquet (`saveReq`), ou, pour une struct anonyme, son type complet tel que +/// `reflect` l'écrit (`struct { On bool "json:\"on\"" }`). +pub fn decode_struct(body: &[u8], ty: &str, fields: &[(&str, Kind)]) -> Decoded { + let (prefix, full) = if ty.starts_with("struct {") { + ("", ty.to_string()) + } else { + (ty, format!("loki.{ty}")) + }; + let ty = prefix; + let mut out = Decoded::default(); + let raw = match first_value(body) { + Ok(r) => r, + Err(e) => { + out.err = Some(e); + return out; + } + }; + let first = raw.first().copied().unwrap_or(b' '); + if first != b'{' { + return match serde_json::from_slice::(raw) { + Ok(Value::Null) => out, + Ok(other) => { + out.err = Some(format!( + "json: cannot unmarshal {} into Go value of type {full}", + kind_of(&other) + )); + out + } + Err(e) => { + out.err = Some(e.to_string()); + out + } + }; + } + // Paires dans l'ordre du document (la dernière l'emporte, comme en Go), + // valeurs brutes (le texte d'un nombre entre dans le message d'erreur). + let obj = match serde_json::from_slice::(raw) { + Ok(p) => p.0, + Err(e) => { + out.err = Some(e.to_string()); + return out; + } + }; + let fail = |out: &mut Decoded, what: String, field: &str, kind: Kind| { + if out.err.is_none() { + out.err = Some(format!( + "json: cannot unmarshal {what} into Go struct field {ty}.{field} of type {}", + kind.go_type() + )); + } + }; + for (k, rawv) in obj { + let Ok(v) = serde_json::from_str::(rawv.get()) else { + continue; + }; + // Correspondance exacte d'abord, puis sans égard à la casse. + let f = fields + .iter() + .find(|(n, _)| *n == k) + .or_else(|| fields.iter().find(|(n, _)| n.eq_ignore_ascii_case(&k))); + let Some(&(name, kind)) = f else { continue }; + if v.is_null() { + if matches!(kind, Kind::OptStr | Kind::OptBool) { + out.fields.remove(name); + } + continue; + } + match kind { + Kind::Str | Kind::OptStr => match v { + Value::String(_) => { + out.fields.insert(name.into(), v); + } + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Str); + // Un pointeur est alloué avant l'échec : il reste posé. + if kind == Kind::OptStr { + out.fields + .entry(name) + .or_insert(Value::String(String::new())); + } + } + }, + Kind::Bool | Kind::OptBool => match v { + Value::Bool(_) => { + out.fields.insert(name.into(), v); + } + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Bool); + if kind == Kind::OptBool { + out.fields.entry(name).or_insert(Value::Bool(false)); + } + } + }, + Kind::Int => match &v { + Value::Number(n) => match n.as_i64() { + Some(_) => { + out.fields.insert(name.into(), v); + } + None => fail(&mut out, format!("number {}", rawv.get()), name, kind), + }, + other => fail(&mut out, kind_of(other).into(), name, kind), + }, + Kind::StrList => match v { + Value::Array(a) => { + let mut list = Vec::with_capacity(a.len()); + for e in a { + match e { + Value::String(s) => list.push(Value::String(s)), + Value::Null => list.push(Value::String(String::new())), + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Str); + list.push(Value::String(String::new())); + } + } + } + out.fields.insert(name.into(), Value::Array(list)); + } + other => fail(&mut out, kind_of(&other).into(), name, kind), + }, + Kind::StrMap => match v { + Value::Object(m) => { + let mut dst = match out.fields.remove(name) { + Some(Value::Object(d)) => d, + _ => Map::new(), + }; + for (mk, mv) in m { + match mv { + Value::String(s) => { + dst.insert(mk, Value::String(s)); + } + Value::Null => { + dst.insert(mk, Value::String(String::new())); + } + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Str); + dst.insert(mk, Value::String(String::new())); + } + } + } + out.fields.insert(name.into(), Value::Object(dst)); + } + other => fail(&mut out, kind_of(&other).into(), name, kind), + }, + Kind::Any => { + out.fields.insert(name.into(), v); + } + } + } + out +} + +/// Objet JSON en paires ordonnées, valeurs brutes. +struct Pairs<'a>(Vec<(String, &'a RawValue)>); + +impl<'de: 'a, 'a> Deserialize<'de> for Pairs<'a> { + fn deserialize>(d: D) -> Result { + struct V<'a>(PhantomData<&'a ()>); + impl<'de: 'a, 'a> Visitor<'de> for V<'a> { + type Value = Pairs<'a>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("un objet JSON") + } + fn visit_map>(self, mut m: A) -> Result, A::Error> { + let mut v = Vec::new(); + while let Some((k, val)) = m.next_entry::()? { + v.push((k, val)); + } + Ok(Pairs(v)) + } + } + d.deserialize_map(V(PhantomData)) + } +} + +fn kind_of(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// `quoteChar` du scanner Go. +fn quote_char(c: u8) -> String { + match c { + b'\'' => r"'\''".into(), + b'"' => "'\"'".into(), + _ => { + let ch = c as char; // octet → U+00XX, comme string(byte) en Go + let inner = match ch { + '\x07' => r"\a".to_string(), + '\x08' => r"\b".to_string(), + '\x0c' => r"\f".to_string(), + '\n' => r"\n".to_string(), + '\r' => r"\r".to_string(), + '\t' => r"\t".to_string(), + '\x0b' => r"\v".to_string(), + '\\' => r"\\".to_string(), + c if (c as u32) < 0x20 || c as u32 == 0x7f => format!(r"\x{:02x}", c as u32), + c if (0x80..0xa1).contains(&(c as u32)) || c as u32 == 0xad => { + format!(r"\u{:04x}", c as u32) + } + c => c.to_string(), + }; + format!("'{inner}'") + } + } +} + +fn syntax(c: u8, ctx: &str) -> String { + format!("invalid character {} {ctx}", quote_char(c)) +} + +fn is_space(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\r' | b'\n') +} + +/// Première valeur JSON du corps, validée par le scanner du Go +/// (`Decoder.readValue`) : la tranche de la valeur, ou l'erreur du Go. +pub fn first_value(body: &[u8]) -> Result<&[u8], String> { + let mut i = 0; + while i < body.len() && is_space(body[i]) { + i += 1; + } + if i == body.len() { + return Err("EOF".into()); + } + let start = i; + let mut p = Scan { + b: body, + i, + depth: 0, + }; + p.value()?; + Ok(&body[start..p.i]) +} + +struct Scan<'a> { + b: &'a [u8], + i: usize, + depth: usize, +} + +const MAX_DEPTH: usize = 10000; + +impl Scan<'_> { + fn peek(&self) -> Option { + self.b.get(self.i).copied() + } + + fn eof() -> String { + "unexpected EOF".into() + } + + fn skip_ws(&mut self) { + while let Some(c) = self.peek() { + if !is_space(c) { + break; + } + self.i += 1; + } + } + + fn value(&mut self) -> Result<(), String> { + self.skip_ws(); + let c = self.peek().ok_or_else(Self::eof)?; + match c { + b'{' => self.object(), + b'[' => self.array(), + b'"' => self.string(), + b'-' | b'0'..=b'9' => self.number(), + b't' => self.literal(b"true", "true"), + b'f' => self.literal(b"false", "false"), + b'n' => self.literal(b"null", "null"), + _ => Err(syntax(c, "looking for beginning of value")), + } + } + + fn push(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err("exceeded max depth".into()); + } + Ok(()) + } + + fn object(&mut self) -> Result<(), String> { + self.push()?; + self.i += 1; // { + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b'}') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + _ => {} + } + loop { + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b'"') => self.string()?, + Some(c) => return Err(syntax(c, "looking for beginning of object key string")), + } + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b':') => self.i += 1, + Some(c) => return Err(syntax(c, "after object key")), + } + self.value()?; + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b',') => self.i += 1, + Some(b'}') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + Some(c) => return Err(syntax(c, "after object key:value pair")), + } + } + } + + fn array(&mut self) -> Result<(), String> { + self.push()?; + self.i += 1; // [ + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b']') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + _ => {} + } + loop { + self.value()?; + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b',') => self.i += 1, + Some(b']') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + Some(c) => return Err(syntax(c, "after array element")), + } + } + } + + fn string(&mut self) -> Result<(), String> { + self.i += 1; // " + loop { + let c = self.peek().ok_or_else(Self::eof)?; + self.i += 1; + match c { + b'"' => return Ok(()), + b'\\' => { + let e = self.peek().ok_or_else(Self::eof)?; + self.i += 1; + match e { + b'b' | b'f' | b'n' | b'r' | b't' | b'\\' | b'/' | b'"' => {} + b'u' => { + for _ in 0..4 { + let h = self.peek().ok_or_else(Self::eof)?; + if !h.is_ascii_hexdigit() { + return Err(syntax(h, "in \\u hexadecimal character escape")); + } + self.i += 1; + } + } + _ => return Err(syntax(e, "in string escape code")), + } + } + c if c < 0x20 => return Err(syntax(c, "in string literal")), + _ => {} + } + } + } + + fn digits(&mut self) { + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.i += 1; + } + } + + fn number(&mut self) -> Result<(), String> { + if self.peek() == Some(b'-') { + self.i += 1; + match self.peek() { + None => return Err(Self::eof()), + Some(b'0'..=b'9') => {} + Some(c) => return Err(syntax(c, "in numeric literal")), + } + } + if self.peek() == Some(b'0') { + self.i += 1; + } else { + self.digits(); + } + if self.peek() == Some(b'.') { + self.i += 1; + match self.peek() { + // Valeur de tête tronquée : le Go la complète d'un espace, + // qui échoue ici. + None => return Err(Self::eof()), + Some(b'0'..=b'9') => self.digits(), + Some(c) => return Err(syntax(c, "after decimal point in numeric literal")), + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.i += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.i += 1; + } + match self.peek() { + None => return Err(Self::eof()), + Some(b'0'..=b'9') => self.digits(), + Some(c) => return Err(syntax(c, "in exponent of numeric literal")), + } + } + Ok(()) + } + + fn literal(&mut self, word: &[u8], name: &str) -> Result<(), String> { + self.i += 1; + for &want in &word[1..] { + match self.peek() { + None => return Err(Self::eof()), + Some(c) if c == want => self.i += 1, + Some(c) => { + return Err(syntax( + c, + &format!("in literal {name} (expecting {})", quote_char(want)), + )); + } + } + } + Ok(()) + } +} + +/// `json.Marshal` du Go : comme serde_json, avec l'échappement HTML du Go +/// (`<`, `>`, `&`, U+2028, U+2029), pour écrire en base les mêmes octets. +pub fn marshal(v: &T) -> String { + let s = serde_json::to_string(v).unwrap_or_default(); + if !s.contains(['<', '>', '&', '\u{2028}', '\u{2029}']) { + return s; + } + let mut out = String::with_capacity(s.len() + 16); + for c in s.chars() { + match c { + '<' => out.push_str("\\u003c"), + '>' => out.push_str("\\u003e"), + '&' => out.push_str("\\u0026"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + c => out.push(c), + } + } + out +} + +/// `unicode.IsPrint` (approché : catégories de contrôle, de format, privées, +/// séparateurs autres que l'espace ASCII → non imprimables). +pub fn is_print(c: char) -> bool { + let u = c as u32; + if c == ' ' { + return true; + } + if c.is_control() || c.is_whitespace() { + return false; + } + !matches!(u, + 0xAD | 0x600..=0x605 | 0x61C | 0x6DD | 0x70F | 0x890..=0x891 | 0x8E2 | 0x180E + | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F + | 0xFEFF | 0xFFF9..=0xFFFB | 0xE000..=0xF8FF | 0xFFFE | 0xFFFF + | 0x110BD | 0x110CD | 0x13430..=0x1343F | 0x1BCA0..=0x1BCA3 + | 0x1D173..=0x1D17A | 0xE0001 | 0xE0020..=0xE007F + | 0xF0000..=0x10FFFF) +} + +/// `strconv.Quote` du Go (`%q` des messages d'erreur). +pub fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\x07' => out.push_str("\\a"), + '\x08' => out.push_str("\\b"), + '\x0c' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\x0b' => out.push_str("\\v"), + c if is_print(c) => out.push(c), + c if (c as u32) < 0x80 => out.push_str(&format!("\\x{:02x}", c as u32)), + c if (c as u32) < 0x10000 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push_str(&format!("\\U{:08x}", c as u32)), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const T: &[(&str, Kind)] = &[("name", Kind::Str), ("on", Kind::Bool)]; + + #[test] + fn erreurs_du_scanner() { + let e = |b: &str| decode_struct(b.as_bytes(), "t", T).err.unwrap_or_default(); + assert_eq!(e(""), "EOF"); + assert_eq!(e(" \n"), "EOF"); + assert_eq!( + e("pas du json"), + "invalid character 'p' looking for beginning of value" + ); + assert_eq!(e("{\"name\""), "unexpected EOF"); + assert_eq!(e("{\"name\" 1}"), "invalid character '1' after object key"); + assert_eq!( + e("{'a':1}"), + r"invalid character '\'' looking for beginning of object key string" + ); + assert_eq!(e("tru"), "unexpected EOF"); + assert_eq!( + e("trux"), + "invalid character 'x' in literal true (expecting 'e')" + ); + } + + #[test] + fn premiere_valeur_seulement() { + let d = decode_struct(br#"{"name":"a"} et la suite"#, "t", T); + assert_eq!(d.err, None); + assert_eq!(d.str("name"), "a"); + } + + #[test] + fn erreurs_de_type_et_casse() { + let d = decode_struct(br#"{"NAME":"x","on":"oui"}"#, "saveReq", T); + assert_eq!(d.str("name"), "x"); + assert_eq!( + d.err.as_deref(), + Some("json: cannot unmarshal string into Go struct field saveReq.on of type bool") + ); + let d = decode_struct(b"[1]", "saveReq", T); + assert_eq!( + d.err.as_deref(), + Some("json: cannot unmarshal array into Go value of type loki.saveReq") + ); + assert!(decode_struct(b"null", "t", T).err.is_none()); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index f3d2198..185dc36 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -21,7 +21,9 @@ pub mod downloads; pub mod engine; pub mod engstatus; pub mod gguf; +pub mod gojson; pub mod hf; +pub mod memory; pub mod models; pub mod oai; pub mod paths; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index ee6d1c4..e736fc5 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -133,26 +133,25 @@ fn serve() -> ExitCode { // LOKI_PUSH=rust : les notifications Web Push (le Go lui délègue ses // envois de fin de tour et de fin de tâche). let push_rust = held_by_facade("LOKI_PUSH"); + // LOKI_MEM=rust : la mémoire (pages, index, trackers, mode mémoire, + // interrupteurs du mode agent). + let mem_rust = held_by_facade("LOKI_MEM"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; - if chat_rust { - env.push(("LOKI_CHAT".into(), "rust".into())); + let held = [ + ("LOKI_CHAT", chat_rust), + ("LOKI_MODELS", models_rust), + ("LOKI_DICTATE", dictate_rust), + ("LOKI_SETTINGS", settings_rust), + ("LOKI_FILES", files_rust), + ("LOKI_PUSH", push_rust), + ("LOKI_MEM", mem_rust), + ]; + for (var, on) in held { + if on { + env.push((var.into(), "rust".into())); + } } - if models_rust { - env.push(("LOKI_MODELS".into(), "rust".into())); - } - if dictate_rust { - env.push(("LOKI_DICTATE".into(), "rust".into())); - } - if settings_rust { - env.push(("LOKI_SETTINGS".into(), "rust".into())); - } - if files_rust { - env.push(("LOKI_FILES".into(), "rust".into())); - } - if push_rust { - env.push(("LOKI_PUSH".into(), "rust".into())); - } - if chat_rust || models_rust || dictate_rust || settings_rust || files_rust || push_rust { + if held.iter().any(|(_, on)| *on) { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -212,6 +211,7 @@ fn serve() -> ExitCode { } }; eprintln!("[loki-rs] façade : http://{addr} → {backend}"); + let mem_home = engine_paths.home.clone(); let cfg = proxy::Config { backend, internal_token: token, @@ -223,6 +223,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))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/memory.rs b/rust/loki-rs/src/memory.rs new file mode 100644 index 0000000..2b94a09 --- /dev/null +++ b/rust/loki-rs/src/memory.rs @@ -0,0 +1,1379 @@ +//! Mémoire de Loki — port de `chat_memory.go` (pages), `mem_index.go` +//! (index MEMORY.md tenu par le code), `mem_io.go` / `mem_fsutil.go` +//! (écritures sûres), `moveMemPage` (projects.go), `tracker.go` + +//! `web_tracker.go` (trackers) et des routes du mode agent (`handleAgent`, +//! `handleAgentToggle`, `handleCompactToggle`, `handleMemoryMode`) — lot 13 +//! de la migration, servi par la façade quand `LOKI_MEM=rust` (expérimental ; +//! le Go par défaut) : +//! +//! - `GET /api/mem?name=` : contenu d'une page ; `POST /api/mem/save`, +//! `/api/mem/delete`, `/api/mem/move` : éditeur web des pages ; +//! - `GET /api/agent` (alias `/api/tools`, `/api/skills`) : mode agent, +//! compactage, mode mémoire et liste des pages ; `POST /api/agent/toggle` +//! (et alias), `/api/agent/compact`, `GET|POST /api/memory` ; +//! - `GET|POST /api/tracker` : panneau des trackers. +//! +//! Les pages restent des fichiers `memory//.md` : le Go (outils +//! `mem_*` de sa boucle, index injecté au modèle) les lit et les écrit sur le +//! même disque. Le chiffrement reste au Go, seul à tenir la clé en mémoire : +//! une page qui porte le magic `LOKIMEMv1` est ouverte par +//! `/internal/v1/crypt/open`, et toute écriture passe par +//! `/internal/v1/crypt/seal` (clair rendu tel quel quand le chiffrement est +//! coupé). Les trackers vivent dans le bucket `tracker`, scellés de même. +//! +//! Hors périmètre (restent au Go) : coffre et chiffrement +//! (`/api/mem/encrypt|decrypt|unlock|lock`), santé, instantanés, sauvegarde, +//! hub des projets (`/api/projects`), dont la bascule recharge la discussion. + +use std::collections::HashMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; +use crate::store::{BK_CONFIG, BK_STATE, CryptError, Store}; + +/// Index du projet, tenu par le code. +pub const MEM_INDEX_FILE: &str = "MEMORY.md"; +/// Magic des contenus chiffrés (`memPageMagic`). +pub const PAGE_MAGIC: &[u8] = b"LOKIMEMv1"; +pub const BK_TRACKER: &str = "tracker"; +const ST_PROJECTS: &str = "projects"; +const ST_ACTIVE_PROJECT: &str = "active_project"; +const ST_AGENT: &str = "agent"; +const DEFAULT_PROJECT_SLUG: &str = "generale"; +const DEFAULT_PROJECT_NAME: &str = "Générale"; +const NEW_PAGE: &str = "# nouvelle page\n\nNote ici ce que loki doit retenir entre les sessions.\n"; +const LOCKED_TITLE: &str = "🔒 (chiffré — mémoire verrouillée)"; +const ERR_MEM_LOCKED: &str = "page chiffrée, mémoire verrouillée"; +const ERR_STORE_LOCKED: &str = "valeur chiffrée, mémoire verrouillée"; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/mem" + | "/api/mem/save" + | "/api/mem/delete" + | "/api/mem/move" + | "/api/agent" + | "/api/tools" + | "/api/skills" + | "/api/agent/toggle" + | "/api/tools/toggle" + | "/api/skills/toggle" + | "/api/agent/compact" + | "/api/memory" + | "/api/tracker" + ) +} + +/// `strings.TrimSpace`. +fn go_trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `memFileName` : ajoute `.md` au besoin et valide le nom. +pub fn mem_file_name(name: &str) -> Result { + let mut name = go_trim(name).to_string(); + if name.is_empty() { + return Err("nom vide".into()); + } + if !name.to_lowercase().ends_with(".md") { + name.push_str(".md"); + } + if !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err("nom invalide (alphanum, ._-)".into()); + } + Ok(name) +} + +/// `titleOf` : 1re ligne non vide, sans les `#` de tête. +pub fn title_of(content: &str) -> String { + for line in content.split('\n') { + let s = go_trim(go_trim(line).trim_start_matches('#')); + if !s.is_empty() { + return s.to_string(); + } + } + String::new() +} + +/// `isIndexFile`. +pub fn is_index_file(name: &str) -> bool { + go_trim(name).eq_ignore_ascii_case(MEM_INDEX_FILE) +} + +/// `memorySeed` : en-tête de l'index d'un projet. +pub fn memory_seed(name: &str) -> String { + format!( + "# Index mémoire — {name}\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). \ + Tenu à jour automatiquement à la création et à la suppression d'une page ; \ + l'accroche après le titre, elle, est libre.\n" + ) +} + +/// `normalizeMemMode`. +pub fn normalize_mem_mode(v: &str) -> &'static str { + match go_trim(v).to_lowercase().as_str() { + "off" => "off", + "ondemand" => "ondemand", + "search" => "search", + _ => "always", + } +} + +/// `slugifyIdent` : slug de fichier sûr (accents courants translittérés). +pub fn slugify_ident(name: &str) -> String { + let mut b = String::new(); + for c in go_trim(name).chars() { + // unicode.ToLower, rune par rune (pas de minuscule à plusieurs runes). + let lc = if c == 'İ' { + 'i' + } else { + let mut l = c.to_lowercase(); + match (l.next(), l.next()) { + (Some(x), None) => x, + _ => c, + } + }; + let rep = match lc { + 'à' | 'â' | 'ä' | 'á' | 'ã' => "a", + 'é' | 'è' | 'ê' | 'ë' => "e", + 'î' | 'ï' | 'í' => "i", + 'ô' | 'ö' | 'ó' | 'õ' => "o", + 'ù' | 'û' | 'ü' | 'ú' => "u", + 'ç' => "c", + 'ñ' => "n", + _ => { + b.push(lc); + continue; + } + }; + b.push_str(rep); + } + // [^a-z0-9]+ → « - » + let mut slug = String::with_capacity(b.len()); + let mut in_run = false; + for c in b.chars() { + if c.is_ascii_lowercase() || c.is_ascii_digit() { + slug.push(c); + in_run = false; + } else if !in_run { + slug.push('-'); + in_run = true; + } + } + let mut slug = slug.trim_matches('-').to_string(); + if slug.len() > 40 { + slug = slug[..40].trim_matches('-').to_string(); + } + slug +} + +// --------------------------------------------------------------------------- +// Dates des trackers +// --------------------------------------------------------------------------- + +/// Fuseau des dates saisies (`time.Local` du Go). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tz { + /// Fuseau de la machine (mktime). + Local, + /// Décalage fixe, en secondes à l'est d'UTC (essais). + Fixed(i64), +} + +/// Date-heure civile lue dans une saisie. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Civil { + pub year: i64, + pub month: u32, + pub day: u32, + pub hour: u32, + pub min: u32, + pub sec: u32, + pub nanos: u32, +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let m = i64::from(m); + let mp = if m > 2 { m - 3 } else { m + 9 }; + let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +fn is_leap(y: i64) -> bool { + y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) +} + +fn days_in(m: u32, y: i64) -> u32 { + match m { + 2 if is_leap(y) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +impl Tz { + /// Secondes Unix de la date civile dans ce fuseau. + pub fn to_unix(self, c: &Civil) -> i64 { + let naive = days_from_civil(c.year, c.month, c.day) * 86400 + + i64::from(c.hour) * 3600 + + i64::from(c.min) * 60 + + i64::from(c.sec); + match self { + Tz::Fixed(off) => naive - off, + Tz::Local => local_to_unix(c).unwrap_or(naive), + } + } +} + +#[cfg(unix)] +fn local_to_unix(c: &Civil) -> Option { + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + tm.tm_year = i32::try_from(c.year - 1900).ok()?; + tm.tm_mon = c.month as i32 - 1; + tm.tm_mday = c.day as i32; + tm.tm_hour = c.hour as i32; + tm.tm_min = c.min as i32; + tm.tm_sec = c.sec as i32; + tm.tm_isdst = -1; + // SAFETY : structure initialisée, pointeur valide. + let t = unsafe { libc::mktime(&mut tm) }; + (t != -1).then_some(t as i64) +} + +#[cfg(not(unix))] +fn local_to_unix(_: &Civil) -> Option { + None +} + +/// `getnum` du paquet time : 1 ou 2 chiffres (`fixed` : exactement 2). +fn getnum(v: &[u8], i: &mut usize, fixed: bool) -> Option { + let d0 = *v.get(*i)?; + if !d0.is_ascii_digit() { + return None; + } + match v.get(*i + 1) { + Some(d1) if d1.is_ascii_digit() => { + *i += 2; + Some(u32::from(d0 - b'0') * 10 + u32::from(d1 - b'0')) + } + _ if fixed => None, + _ => { + *i += 1; + Some(u32::from(d0 - b'0')) + } + } +} + +fn expect(v: &[u8], i: &mut usize, c: u8) -> Option<()> { + (v.get(*i) == Some(&c)).then(|| *i += 1) +} + +/// `time.ParseInLocation` restreint aux mises en page de `parseWhen` +/// (`2006[-01[-02[ 15[:04[:05]]]]]`). `parts` : nombre d'éléments après +/// l'année (0 à 5). +fn parse_layout(s: &str, parts: u8) -> Option { + let v = s.as_bytes(); + if v.len() < 4 || !v[0].is_ascii_digit() || !v[..4].iter().all(u8::is_ascii_digit) { + return None; + } + let mut c = Civil { + year: s[..4].parse().ok()?, + month: 1, + day: 1, + ..Civil::default() + }; + let mut i = 4; + if parts >= 1 { + expect(v, &mut i, b'-')?; + c.month = getnum(v, &mut i, true)?; + if !(1..=12).contains(&c.month) { + return None; + } + } + if parts >= 2 { + expect(v, &mut i, b'-')?; + c.day = getnum(v, &mut i, true)?; + } + if parts >= 3 { + // `skip` du paquet time : une espace de la mise en page vaut une + // suite d'espaces (ou rien en fin de saisie). + if i < v.len() && v[i] != b' ' { + return None; + } + while i < v.len() && v[i] == b' ' { + i += 1; + } + c.hour = getnum(v, &mut i, false)?; + if c.hour >= 24 { + return None; + } + } + if parts >= 4 { + expect(v, &mut i, b':')?; + c.min = getnum(v, &mut i, true)?; + if c.min >= 60 { + return None; + } + } + if parts >= 5 { + expect(v, &mut i, b':')?; + c.sec = getnum(v, &mut i, true)?; + if c.sec >= 60 { + return None; + } + // Fraction de seconde acceptée sans figurer dans la mise en page. + if v.len() >= i + 2 && matches!(v[i], b'.' | b',') && v[i + 1].is_ascii_digit() { + let mut n = i + 1; + while n < v.len() && v[n].is_ascii_digit() { + n += 1; + } + let digits = &s[i + 1..n.min(i + 10)]; + let mut ns: u32 = digits.parse().ok()?; + for _ in digits.len()..9 { + ns *= 10; + } + c.nanos = ns; + i = n; + } + } + if i != v.len() { + return None; + } + if c.day < 1 || c.day > days_in(c.month, c.year) { + return None; + } + Some(c) +} + +/// `parseWhen` : date à précision variable → (instant en ms, sans heure). +/// Vide ou « now » = `now_ms`. +pub fn parse_when(when: &str, now_ms: i64, tz: Tz) -> Result<(i64, bool), String> { + let w = go_trim(when); + if w.is_empty() || w.eq_ignore_ascii_case("now") || w.eq_ignore_ascii_case("maintenant") { + return Ok((now_ms, false)); + } + let w = w.replacen('T', " ", 1); + for (parts, date_only) in [ + (5, false), + (4, false), + (3, false), + (2, true), + (1, true), + (0, true), + ] { + if let Some(c) = parse_layout(&w, parts) { + return Ok(( + tz.to_unix(&c) * 1000 + i64::from(c.nanos / 1_000_000), + date_only, + )); + } + } + Err(format!( + "date non comprise : {} (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", + gojson::quote(when) + )) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) +} + +// --------------------------------------------------------------------------- +// Écritures sûres (mem_fsutil.go) +// --------------------------------------------------------------------------- + +static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + +#[cfg(unix)] +fn set_mode(path: &Path, perm: u32) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(perm)) +} + +#[cfg(not(unix))] +fn set_mode(_: &Path, _: u32) -> std::io::Result<()> { + Ok(()) +} + +/// `memWriteFileAtomic` : temporaire + fsync + rename. +pub fn write_file_atomic(path: &Path, data: &[u8], perm: u32) -> std::io::Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + std::fs::create_dir_all(dir)?; + let base = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (tmp_path, mut f) = loop { + let n = TMP_SEQ.fetch_add(1, Ordering::Relaxed); + let p = dir.join(format!(".tmp-{base}-{}{n}", now_nanos() % 1_000_000_000)); + let mut o = std::fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + match o.open(&p) { + Ok(f) => break (p, f), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + }; + let res = (|| { + f.write_all(data)?; + f.sync_all()?; + drop(f); + set_mode(&tmp_path, perm)?; + std::fs::rename(&tmp_path, path) + })(); + if res.is_err() { + let _ = std::fs::remove_file(&tmp_path); + } + res +} + +/// `memWriteFileVerified` : écriture atomique puis relecture octet par octet. +pub fn write_file_verified(path: &Path, data: &[u8], perm: u32) -> Result<(), String> { + write_file_atomic(path, data, perm).map_err(|e| e.to_string())?; + let back = + std::fs::read(path).map_err(|e| format!("relecture de vérification impossible : {e}"))?; + if back.len() != data.len() { + let _ = std::fs::remove_file(path); + return Err(format!( + "vérification échouée : {} octets relus, {} écrits", + back.len(), + data.len() + )); + } + if let Some(i) = back.iter().zip(data).position(|(a, b)| a != b) { + let _ = std::fs::remove_file(path); + return Err(format!("vérification échouée : octet {i} diffère")); + } + Ok(()) +} + +/// `os.WriteFile` : création avec `perm`, sinon le fichier garde ses droits. +fn write_file(path: &Path, data: &[u8], perm: u32) -> std::io::Result<()> { + let mut o = std::fs::OpenOptions::new(); + o.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(perm); + } + #[cfg(not(unix))] + let _ = perm; + o.open(path)?.write_all(data) +} + +// --------------------------------------------------------------------------- +// Projets (lecture du registre du Go) +// --------------------------------------------------------------------------- + +/// `Project` (bucket state, clé `projects`). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Project { + pub slug: String, + pub name: String, + #[serde(default)] + pub created_at: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub desc: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mem_mode: String, +} + +/// `listProjects` : le registre, le plus ancien d'abord ; au moins le +/// projet par défaut. +pub async fn list_projects(store: &Store) -> Vec { + let mut out: Vec = match store.get(BK_STATE, ST_PROJECTS).await { + Ok(Some(b)) if !b.is_empty() => serde_json::from_slice(&b).unwrap_or_default(), + _ => Vec::new(), + }; + if out.is_empty() { + out.push(Project { + slug: DEFAULT_PROJECT_SLUG.into(), + name: DEFAULT_PROJECT_NAME.into(), + created_at: now_ms(), + desc: String::new(), + mem_mode: String::new(), + }); + } + out.sort_by_key(|p| p.created_at); + out +} + +/// `activeProjectSlug` (valeur rangée ; le Go seul connaît la bascule +/// temporaire d'une tâche planifiée). +pub async fn active_project(store: &Store) -> String { + match go_trim(&store.get_str(BK_STATE, ST_ACTIVE_PROJECT).await) { + "" => DEFAULT_PROJECT_SLUG.into(), + s => s.into(), + } +} + +fn project_name(list: &[Project], slug: &str) -> String { + list.iter() + .find(|p| p.slug == slug) + .map_or_else(|| slug.to_string(), |p| p.name.clone()) +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// La mémoire tenue par la façade. +pub struct Memory { + home: PathBuf, + tz: Tz, +} + +/// Lecture d'une page. +#[derive(Debug)] +enum ReadErr { + Invalid, + Missing, + Locked, + Other, +} + +impl Memory { + pub fn new(home: PathBuf) -> Self { + Memory { + home, + tz: Tz::Local, + } + } + + /// Fuseau des dates de trackers (essais : décalage fixe). + pub fn with_tz(home: PathBuf, tz: Tz) -> Self { + Memory { home, tz } + } + + /// `projectMemoryDir`. + fn project_dir(&self, slug: &str) -> PathBuf { + let slug = if slug.is_empty() { + DEFAULT_PROJECT_SLUG + } else { + slug + }; + self.home.join("memory").join(slug) + } + + /// `safeMemPath`. + fn safe_path(dir: &Path, name: &str) -> Result { + let f = mem_file_name(name)?; + let p = dir.join(&f); + if p.parent() != Some(dir) { + return Err("path invalide".into()); + } + Ok(p) + } + + /// `decodeMemContent`. + async fn decode(store: &Store, raw: Vec) -> Result, ReadErr> { + if !raw.starts_with(PAGE_MAGIC) { + return Ok(raw); + } + match store.open(raw).await { + Ok(b) => Ok(b), + Err(CryptError::Locked) => Err(ReadErr::Locked), + Err(CryptError::Other(_)) => Err(ReadErr::Other), + } + } + + /// `memReadPage`. + async fn read_page(store: &Store, dir: &Path, name: &str) -> Result, ReadErr> { + let p = Self::safe_path(dir, name).map_err(|_| ReadErr::Invalid)?; + let raw = std::fs::read(&p).map_err(|_| ReadErr::Missing)?; + Self::decode(store, raw).await + } + + /// `MemContent` : contenu clair, vide si illisible. + async fn content(store: &Store, dir: &Path, name: &str) -> String { + match Self::read_page(store, dir, name).await { + Ok(b) => String::from_utf8_lossy(&b).into_owned(), + Err(_) => String::new(), + } + } + + /// `writeMemFile` : scellé selon le chiffrement, `.bak` de la version + /// précédente, écriture atomique vérifiée. + async fn write_page(store: &Store, dir: &Path, name: &str, plain: &[u8]) -> Result<(), String> { + let p = Self::safe_path(dir, name)?; + let out = store.seal(plain.to_vec()).await.map_err(|e| match e { + CryptError::Locked => ERR_MEM_LOCKED.to_string(), + CryptError::Other(e) => e, + })?; + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + if let Ok(old) = std::fs::read(&p) { + let mut bak = p.clone().into_os_string(); + bak.push(".bak"); + let _ = write_file_atomic(Path::new(&bak), &old, 0o600); + } + write_file_verified(&p, &out, 0o600) + } + + /// `MemList` : pages (nom, titre), triées par nom. + async fn list(store: &Store, dir: &Path) -> Vec<(String, String)> { + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec = rd + .filter_map(Result::ok) + .filter(|e| !e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.') && n.to_lowercase().ends_with(".md")) + .collect(); + names.sort(); + let mut out = Vec::with_capacity(names.len()); + for n in names { + let title = match Self::read_page(store, dir, &n).await { + Ok(b) => title_of(&String::from_utf8_lossy(&b)), + Err(_) => LOCKED_TITLE.to_string(), + }; + out.push((n, title)); + } + out + } + + // --- index ---------------------------------------------------------- + + /// `writeIndex` : en clair, comme le Go. + fn write_index(dir: &Path, body: &str) { + if std::fs::create_dir_all(dir).is_err() { + return; + } + let _ = write_file(&dir.join(MEM_INDEX_FILE), body.as_bytes(), 0o644); + } + + /// `ensureIndexSeed`. + async fn ensure_index_seed(store: &Store, dir: &Path, project: &str) { + if !go_trim(&Self::content(store, dir, MEM_INDEX_FILE).await).is_empty() { + return; + } + Self::write_index(dir, &memory_seed(project)); + } + + /// `indexLineFor`. + async fn index_line(store: &Store, dir: &Path, name: &str) -> String { + let t = title_of(&Self::content(store, dir, name).await); + let title = if t.is_empty() { name.to_string() } else { t }; + format!("- [{title}]({name})") + } + + /// `memIndexAdd`. + async fn index_add(store: &Store, dir: &Path, name: &str, project: &str) { + let Ok(f) = mem_file_name(name) else { return }; + if is_index_file(&f) { + return; + } + Self::ensure_index_seed(store, dir, project).await; + let content = Self::content(store, dir, MEM_INDEX_FILE).await; + let r = format!("]({f})"); + if content.split('\n').any(|l| l.contains(&r)) { + return; + } + let line = Self::index_line(store, dir, &f).await; + Self::write_index( + dir, + &format!("{}\n{line}\n", content.trim_end_matches('\n')), + ); + } + + /// `memIndexRemove`. + async fn index_remove(store: &Store, dir: &Path, name: &str) { + let Ok(f) = mem_file_name(name) else { return }; + if is_index_file(&f) { + return; + } + let content = Self::content(store, dir, MEM_INDEX_FILE).await; + if content.is_empty() { + return; + } + let r = format!("]({f})"); + let lines: Vec<&str> = content.split('\n').collect(); + let kept: Vec<&str> = lines.iter().copied().filter(|l| !l.contains(&r)).collect(); + if kept.len() == lines.len() { + return; + } + Self::write_index( + dir, + &format!("{}\n", kept.join("\n").trim_end_matches('\n')), + ); + } + + // --- pages ---------------------------------------------------------- + + /// `MemSave` : crée ou écrase ; `old` différent : l'ancienne est retirée. + async fn save( + store: &Store, + dir: &Path, + project: &str, + name: &str, + old: &str, + content: &str, + ) -> Result<(), String> { + if !old.is_empty() + && let Ok(of) = mem_file_name(old) + && let Ok(nf) = mem_file_name(name) + && of != nf + && let Ok(od) = Self::safe_path(dir, old) + { + let _ = std::fs::remove_file(od); + } + let body = format!("{}\n", content.trim_end_matches('\n')); + Self::write_page(store, dir, name, body.as_bytes()).await?; + // memIndexRename : ancienne ligne retirée (même nom compris), nouvelle + // ajoutée. + if !old.is_empty() { + Self::index_remove(store, dir, old).await; + } + Self::index_add(store, dir, name, project).await; + Ok(()) + } + + /// `MemDelete`. + async fn delete(store: &Store, dir: &Path, name: &str) -> Result<(), String> { + let p = Self::safe_path(dir, name)?; + if std::fs::metadata(&p).is_err() { + return Err("introuvable".into()); + } + std::fs::remove_file(&p).map_err(|e| e.to_string())?; + Self::index_remove(store, dir, name).await; + Ok(()) + } + + /// `moveMemPage`. + async fn move_page( + &self, + store: &Store, + name: &str, + from: &str, + to: &str, + ) -> Result<(), String> { + if go_trim(from) == go_trim(to) { + return Err("déjà dans ce projet".into()); + } + let projects = list_projects(store).await; + if !projects.iter().any(|p| p.slug == to) { + return Err("projet cible introuvable".into()); + } + let f = mem_file_name(name)?; + if is_index_file(&f) { + return Err("l'index du projet n'est pas déplaçable".into()); + } + let (fd, td) = (self.project_dir(from), self.project_dir(to)); + let raw = std::fs::read(fd.join(&f)).map_err(|_| "page introuvable".to_string())?; + if std::fs::metadata(td.join(&f)).is_ok() { + return Err("une page du même nom existe déjà dans le projet cible".into()); + } + std::fs::create_dir_all(&td).map_err(|e| e.to_string())?; + write_file(&td.join(&f), &raw, 0o600).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(fd.join(&f)); + Self::index_remove(store, &fd, &f).await; + Self::index_add(store, &td, &f, &project_name(&projects, to)).await; + Ok(()) + } + + // --- mode mémoire --------------------------------------------------- + + /// `memMode` : mode du projet actif, repli sur `MEM_MODE`. + async fn mem_mode(store: &Store) -> &'static str { + let active = active_project(store).await; + let projects = list_projects(store).await; + if let Some(p) = projects.iter().find(|p| p.slug == active) + && !p.mem_mode.is_empty() + { + return normalize_mem_mode(&p.mem_mode); + } + normalize_mem_mode( + store + .config() + .await + .get("MEM_MODE") + .map_or("", String::as_str), + ) + } + + /// `setMemMode` → `setProjectMemMode(activeProjectSlug(), mode)`. + async fn set_mem_mode(store: &Store, mode: &str) -> Result<(), String> { + let mode = go_trim(mode).to_lowercase(); + if !matches!(mode.as_str(), "" | "off" | "ondemand" | "always" | "search") { + return Err(format!("mode mémoire invalide: {mode}")); + } + let active = active_project(store).await; + let mut list = list_projects(store).await; + let Some(p) = list.iter_mut().find(|p| p.slug == active) else { + return Err("projet introuvable".into()); + }; + p.mem_mode = mode; + store + .put(BK_STATE, ST_PROJECTS, gojson::marshal(&list).into_bytes()) + .await + } + + // --- trackers ------------------------------------------------------- + + async fn tracker_load(store: &Store, proj: &str, slug: &str) -> Option { + let b = store + .get_open(BK_TRACKER, &format!("{proj}/{slug}")) + .await + .ok() + .flatten()?; + serde_json::from_slice(&b).ok() + } + + async fn tracker_save( + store: &Store, + proj: &str, + slug: &str, + t: &mut Tracker, + ) -> Result<(), String> { + if let Some(ev) = t.events.as_mut() { + ev.sort_by_key(|e| e.ts); + } + store + .put_seal( + BK_TRACKER, + &format!("{proj}/{slug}"), + gojson::marshal(t).into_bytes(), + ) + .await + .map_err(|e| match e { + CryptError::Locked => ERR_STORE_LOCKED.to_string(), + CryptError::Other(e) => e, + }) + } + + /// `trackerList` : métadonnées, le plus récemment alimenté d'abord. + async fn tracker_list(store: &Store, proj: &str) -> Vec { + let prefix = format!("{proj}/"); + let keys: Vec = store + .all(BK_TRACKER) + .await + .unwrap_or_default() + .into_keys() + .filter(|k| k.starts_with(&prefix)) + .collect(); + let mut rows: Vec<(i64, String, String, Value)> = Vec::new(); + for k in keys { + let slug = k[prefix.len()..].to_string(); + let Some(t) = Self::tracker_load(store, proj, &slug).await else { + continue; + }; + let d = TrackerDto::of(&slug, &t, false); + rows.push((d.last_ts, d.name.clone(), slug, json!(d))); + } + rows.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + rows.into_iter().map(|r| r.3).collect() + } + + async fn tracker_action( + &self, + store: &Store, + proj: &str, + req: &gojson::Decoded, + ) -> Result<(), String> { + let slug = go_trim(&req.str("slug")).to_string(); + match go_trim(&req.str("action")).to_lowercase().as_str() { + "add" => self + .tracker_add( + store, + proj, + &req.str("name"), + &req.str("when"), + &req.str("text"), + ) + .await + .map(|_| ()), + "edit" => { + let mut t = Self::tracker_load(store, proj, &slug) + .await + .ok_or("tracker introuvable")?; + let id = req.str("id"); + let (when, text) = (req.str("when"), req.str("text")); + 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".into()); + }; + if !go_trim(&text).is_empty() { + e.text = go_trim(&text).to_string(); + } + if !go_trim(&when).is_empty() { + let (ts, date_only) = parse_when(&when, now_ms(), self.tz)?; + e.ts = ts; + e.date_only = date_only; + } + Self::tracker_save(store, proj, &slug, &mut t).await + } + "delete" => { + let mut t = Self::tracker_load(store, proj, &slug) + .await + .ok_or("tracker introuvable")?; + let id = req.str("id"); + let Some(ev) = t.events.as_mut() else { + return Err("événement introuvable".into()); + }; + let n = ev.len(); + ev.retain(|e| e.id != id); + if ev.len() == n { + return Err("événement introuvable".into()); + } + Self::tracker_save(store, proj, &slug, &mut t).await + } + "delete_tracker" => store.del(BK_TRACKER, &format!("{proj}/{slug}")).await, + "rename" => { + let name = go_trim(&req.str("name")).to_string(); + if name.is_empty() { + return Err("nom vide".into()); + } + let mut t = Self::tracker_load(store, proj, &slug) + .await + .ok_or("tracker introuvable")?; + let new_slug = slugify_ident(&name); + if new_slug.is_empty() { + return Err("nom invalide".into()); + } + t.name = name; + if new_slug == slug { + return Self::tracker_save(store, proj, &slug, &mut t).await; + } + if Self::tracker_load(store, proj, &new_slug).await.is_some() { + return Err("un tracker du même nom existe déjà".into()); + } + Self::tracker_save(store, proj, &new_slug, &mut t).await?; + store.del(BK_TRACKER, &format!("{proj}/{slug}")).await + } + "move" => { + let to = go_trim(&req.str("to")).to_string(); + if !list_projects(store).await.iter().any(|p| p.slug == to) { + return Err("projet cible introuvable".into()); + } + if proj == to { + return Ok(()); + } + let (fk, tk) = (format!("{proj}/{slug}"), format!("{to}/{slug}")); + let raw = store.get(BK_TRACKER, &fk).await?.unwrap_or_default(); + if raw.is_empty() { + return Err("tracker introuvable".into()); + } + if store + .get(BK_TRACKER, &tk) + .await? + .is_some_and(|v| !v.is_empty()) + { + return Err("un tracker du même nom existe déjà dans le projet cible".into()); + } + store.put(BK_TRACKER, &tk, raw).await?; + store.del(BK_TRACKER, &fk).await + } + _ => Err(format!( + "action inconnue : {}", + gojson::quote(&req.str("action")) + )), + } + } + + /// `trackerAdd` : ajoute un point (tracker créé au besoin) ; rend l'id. + async fn tracker_add( + &self, + store: &Store, + proj: &str, + name: &str, + when: &str, + text: &str, + ) -> Result { + let name = go_trim(name).to_string(); + if name.is_empty() { + return Err("nom de tracker vide".into()); + } + if go_trim(text).is_empty() { + return Err("contenu vide".into()); + } + let (ts, date_only) = parse_when(when, now_ms(), self.tz)?; + let mut slug = slugify_ident(&name); + if slug.is_empty() { + slug = "tracker".into(); + } + let mut t = Self::tracker_load(store, proj, &slug) + .await + .unwrap_or_else(|| Tracker { + name: name.clone(), + events: None, + }); + if go_trim(&t.name).is_empty() { + t.name = name; + } + let id = now_nanos().to_string(); + t.events.get_or_insert_with(Vec::new).push(TrackerEvent { + id: id.clone(), + ts, + text: go_trim(text).to_string(), + date_only, + }); + Self::tracker_save(store, proj, &slug, &mut t).await?; + Ok(id) + } +} + +/// Point daté d'un tracker (`TrackerEvent`). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TrackerEvent { + #[serde(default)] + pub id: String, + #[serde(default)] + pub ts: i64, + #[serde(default)] + pub text: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub date_only: bool, +} + +/// `Tracker` : `events` à `None` = `nil` du Go (`null` en JSON). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Tracker { + #[serde(default)] + pub name: String, + #[serde(default)] + pub events: Option>, +} + +/// `trackerDTO`. +#[derive(Serialize)] +struct TrackerDto { + slug: String, + name: String, + count: usize, + first_ts: i64, + last_ts: i64, + last_text: String, + #[serde(skip_serializing_if = "Option::is_none")] + events: Option>, +} + +impl TrackerDto { + fn of(slug: &str, t: &Tracker, with_events: bool) -> Self { + let ev = t.events.as_deref().unwrap_or(&[]); + TrackerDto { + slug: slug.into(), + name: t.name.clone(), + count: ev.len(), + first_ts: ev.first().map_or(0, |e| e.ts), + last_ts: ev.last().map_or(0, |e| e.ts), + last_text: ev.last().map_or_else(String::new, |e| e.text.clone()), + events: (with_events && !ev.is_empty()).then(|| ev.to_vec()), + } + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +async fn read_body(body: Incoming) -> Vec { + Limited::new(body, 8 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default() +} + +/// Types des structs anonymes des handlers Go (messages d'erreur de type). +const MOVE_REQ_TYPE: &str = r#"struct { Name string "json:\"name\""; To string "json:\"to\"" }"#; +const TRACKER_REQ_TYPE: &str = r#"struct { Action string "json:\"action\""; Slug string "json:\"slug\""; Name string "json:\"name\""; When string "json:\"when\""; Text string "json:\"text\""; ID string "json:\"id\""; To string "json:\"to\"" }"#; + +const SAVE_REQ: &[(&str, Kind)] = &[ + ("name", Kind::Str), + ("old", Kind::Str), + ("content", Kind::Str), +]; + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + mem: &Memory, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let store = &ctx.store; + let path = parts.uri.path(); + // `r.URL.Query().Get` : la première valeur d'un paramètre. + let mut query: HashMap = HashMap::new(); + for (k, v) in url::form_urlencoded::parse(parts.uri.query().unwrap_or("").as_bytes()) { + query + .entry(k.into_owned()) + .or_insert_with(|| v.into_owned()); + } + let active = active_project(store).await; + let dir = mem.project_dir(&active); + match path { + "/api/mem" => { + let name = go_trim(query.get("name").map_or("", String::as_str)).to_string(); + if name.is_empty() { + return send_json(StatusCode::OK, &json!({ "name": "", "content": NEW_PAGE })); + } + let c = Memory::content(store, &dir, &name).await; + if c.is_empty() { + return send_json(StatusCode::NOT_FOUND, &json!({ "error": "not found" })); + } + send_json(StatusCode::OK, &json!({ "name": name, "content": c })) + } + "/api/mem/save" => { + let d = gojson::decode_struct(&read_body(body).await, "saveReq", SAVE_REQ); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + let pname = project_name(&list_projects(store).await, &active); + match Memory::save( + store, + &dir, + &pname, + &d.str("name"), + &d.str("old"), + &d.str("content"), + ) + .await + { + Ok(()) => send_json( + StatusCode::OK, + &json!({ "ok": true, "name": d.str("name") }), + ), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/mem/delete" => { + let d = gojson::decode_struct(&read_body(body).await, "saveReq", SAVE_REQ); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + match Memory::delete(store, &dir, &d.str("name")).await { + Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/mem/move" => { + if parts.method != Method::POST { + return fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"); + } + let d = gojson::decode_struct( + &read_body(body).await, + MOVE_REQ_TYPE, + &[("name", Kind::Str), ("to", Kind::Str)], + ); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + match mem + .move_page(store, &d.str("name"), &active, go_trim(&d.str("to"))) + .await + { + Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/agent" | "/api/tools" | "/api/skills" => { + let pages: Vec = Memory::list(store, &dir) + .await + .into_iter() + .map(|(n, t)| json!({ "name": n, "desc": t })) + .collect(); + send_json( + StatusCode::OK, + &json!({ + "enabled": agent_enabled(store).await, + "compact": compact_enabled(store).await, + "mem_mode": Memory::mem_mode(store).await, + "pages": pages, + "skills": pages, + }), + ) + } + "/api/agent/toggle" | "/api/tools/toggle" | "/api/skills/toggle" => { + let d = gojson::decode_struct(&read_body(body).await, "", &[("on", Kind::Bool)]); + let res = if d.bool("on") { + store.put(BK_STATE, ST_AGENT, b"1".to_vec()).await + } else { + store.del(BK_STATE, ST_AGENT).await + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "enabled": agent_enabled(store).await }), + ) + } + "/api/agent/compact" => { + let d = gojson::decode_struct(&read_body(body).await, "", &[("on", Kind::Bool)]); + let res = if d.bool("on") { + store.del(BK_CONFIG, "COMPACT").await + } else { + store.put(BK_CONFIG, "COMPACT", b"off".to_vec()).await + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "compact": compact_enabled(store).await }), + ) + } + "/api/memory" => { + if parts.method == Method::POST { + let d = gojson::decode_struct(&read_body(body).await, "", &[("mode", Kind::Str)]); + if let Err(e) = + Memory::set_mem_mode(store, normalize_mem_mode(&d.str("mode"))).await + { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "mode": Memory::mem_mode(store).await }), + ) + } + "/api/tracker" => match parts.method { + Method::GET => { + let slug = go_trim(query.get("slug").map_or("", String::as_str)).to_string(); + if slug.is_empty() { + return send_json( + StatusCode::OK, + &json!({ "ok": true, "trackers": Memory::tracker_list(store, &active).await }), + ); + } + match Memory::tracker_load(store, &active, &slug).await { + Some(t) => send_json( + StatusCode::OK, + &json!({ "ok": true, "tracker": TrackerDto::of(&slug, &t, true) }), + ), + None => send_json( + StatusCode::NOT_FOUND, + &json!({ "ok": false, "error": "tracker introuvable" }), + ), + } + } + Method::POST => { + let d = gojson::decode_struct( + &read_body(body).await, + TRACKER_REQ_TYPE, + &[ + ("action", Kind::Str), + ("slug", Kind::Str), + ("name", Kind::Str), + ("when", Kind::Str), + ("text", Kind::Str), + ("id", Kind::Str), + ("to", Kind::Str), + ], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + if let Err(e) = mem.tracker_action(store, &active, &d).await { + return fail(StatusCode::BAD_REQUEST, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "trackers": Memory::tracker_list(store, &active).await }), + ) + } + _ => fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"), + }, + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// `agentEnabled`. +async fn agent_enabled(store: &Store) -> bool { + store.get_str(BK_STATE, ST_AGENT).await == "1" +} + +/// `compactEnabled`. +async fn compact_enabled(store: &Store) -> bool { + !matches!( + go_trim( + store + .config() + .await + .get("COMPACT") + .map_or("", String::as_str) + ) + .to_lowercase() + .as_str(), + "off" | "false" | "0" | "no" | "non" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noms_et_titres() { + assert_eq!(mem_file_name(" notes ").unwrap(), "notes.md"); + assert_eq!(mem_file_name("A.MD").unwrap(), "A.MD"); + assert!(mem_file_name("a/b").is_err()); + assert_eq!(title_of("\n\n## Titre \nx"), "Titre"); + assert_eq!(slugify_ident("Réseau NAS"), "reseau-nas"); + assert_eq!(slugify_ident("İstanbul"), "istanbul"); + } + + #[test] + fn dates() { + let tz = Tz::Fixed(7200); + assert_eq!( + parse_when("2024-03-10", 0, tz).unwrap(), + (1_710_021_600_000, true) + ); + assert!(!parse_when("2024-03-10T08:15", 0, tz).unwrap().1); + assert!(parse_when("2024-02-30", 0, tz).is_err()); + assert_eq!(parse_when(" now ", 42, tz).unwrap(), (42, false)); + } +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index bd38044..71e77f4 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -75,6 +75,8 @@ pub struct Config { pub files: Option>, /// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). pub push: Option>, + /// Mémoire (pages, trackers, mode) tenue par la façade (`LOKI_MEM=rust`). + pub mem: Option>, } struct Proxy { @@ -107,6 +109,7 @@ pub async fn serve( settings: cfg.settings, files: cfg.files, push: cfg.push, + mem: cfg.mem, internal_token: cfg.internal_token.clone(), }, }); @@ -194,6 +197,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(m) = &self.ctx.mem + && crate::memory::handles(path) + { + return crate::memory::serve(&self.ctx, m, req, from_go).await; + } if let Some(p) = &self.ctx.push && crate::push::handles(path) { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 08a3b24..6f133d4 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -16,6 +16,18 @@ use tokio::sync::oneshot; pub const JETON: &str = "0123456789abcdef0123456789abcdef"; +/// Scellement simulé par le faux Go. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CryptMode { + /// « SCELLE: » + clair (historique des essais de la conversation). + #[default] + Scelle, + /// Chiffrement coupé : rien ne change. + Plain, + /// Magic des pages mémoire (`LOKIMEMv1`) + clair. + Magic, +} + #[derive(Default)] pub struct FakeGo { /// bucket → clé → valeur. @@ -62,6 +74,8 @@ pub struct FakeGo { pub turn_view: Mutex>, pub turn_verifier: Mutex>, pub tool_results: Mutex>, + /// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut). + pub crypt_mode: Mutex, /// Réponse de /internal/v1/conv/workspace (None : 404). pub conv_workspace: Mutex>, } @@ -148,6 +162,24 @@ async fn handle(st: Arc, req: Request) -> Response return resp(423, r#"{"error":"mémoire verrouillée"}"#); } let body = req.into_body().collect().await.unwrap().to_bytes(); + match *st.crypt_mode.lock().unwrap() { + // Chiffrement coupé : clair rendu tel quel. + CryptMode::Plain => return resp(200, body), + // Enveloppe au magic des pages (`LOKIMEMv1`). + CryptMode::Magic => { + return if path.ends_with("seal") { + let mut out = b"LOKIMEMv1".to_vec(); + out.extend_from_slice(&body); + resp(200, out) + } else { + resp( + 200, + body.strip_prefix(b"LOKIMEMv1").unwrap_or(&body).to_vec(), + ) + }; + } + CryptMode::Scelle => {} + } if path.ends_with("seal") { let mut out = b"SCELLE:".to_vec(); out.extend_from_slice(&body); @@ -474,6 +506,7 @@ pub struct Opts { pub settings: bool, pub files: Option>, pub push: Option>, + pub mem: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -568,6 +601,7 @@ pub async fn start_with( settings: opts.settings, files: opts.files, push: opts.push, + mem: opts.mem, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/gojson.rs b/rust/loki-rs/tests/gojson.rs new file mode 100644 index 0000000..2870052 --- /dev/null +++ b/rust/loki-rs/tests/gojson.rs @@ -0,0 +1,41 @@ +//! Décodage JSON des requêtes au comportement d'`encoding/json` : le journal +//! tiré du Go (`internal/loki/parity_gojson_test.go` → +//! `tests/parity_gojson/decode.json`) est rejoué contre `gojson`. + +use loki_rs::gojson::{Kind, decode_struct}; +use serde_json::{Value, json}; + +const FIELDS: &[(&str, Kind)] = &[ + ("name", Kind::Str), + ("on", Kind::Bool), + ("n", Kind::Int), + ("args", Kind::StrList), + ("env", Kind::StrMap), + ("opt", Kind::OptStr), + ("flag", Kind::OptBool), +]; + +#[test] +fn parite_du_decodage() { + let fx: Value = + serde_json::from_str(include_str!("parity_gojson/decode.json")).expect("journal"); + for c in fx["cases"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + let d = decode_struct(input.as_bytes(), "parityDecodeT", FIELDS); + let out = json!({ + "name": d.str("name"), + "on": d.bool("on"), + "n": d.int("n"), + "args": d.str_list("args"), + "env": d.str_map("env").map(|m| m.into_iter().map(|(k, v)| (k, Value::String(v))).collect::>()), + "opt": d.opt_str("opt"), + "flag": d.opt_bool("flag"), + }); + assert_eq!(out, c["out"], "{input:?} : champs"); + assert_eq!( + d.err.as_deref().unwrap_or(""), + c["err"].as_str().unwrap_or(""), + "{input:?} : erreur" + ); + } +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 826f9a8..166e75e 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -99,6 +99,7 @@ async fn banc(cle: Option<&str>) -> Banc { settings: false, files: None, push: None, + mem: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/memory.rs b/rust/loki-rs/tests/memory.rs new file mode 100644 index 0000000..071525d --- /dev/null +++ b/rust/loki-rs/tests/memory.rs @@ -0,0 +1,394 @@ +//! Lot 13 : mémoire tenue par la façade (`LOKI_MEM=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_mem_test.go` → +//! `tests/parity_mem/mem.json`) est rejoué : noms et titres de pages, slugs, +//! modes mémoire, dates des trackers (fuseau fixe UTC+2), puis la suite +//! d'appels aux routes par la façade, avec après chacun le dossier memory/ et +//! les clés touchées en base. + +mod common; + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; + +use common::fakego::{self, CryptMode}; +use loki_rs::memory::{self, Memory, Tz}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_mem/mem.json")).expect("journal") +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} + +/// Masque identifiants de points et instants « maintenant », dans l'ordre +/// d'apparition (même règle que le test Go). +#[derive(Default)] +struct Masker { + ids: Vec<(String, String)>, +} + +impl Masker { + fn mask(&mut self, s: &str) -> String { + let id_re = regex::Regex::new(r#""id":"(\d{15,})""#).unwrap(); + let mut out = String::new(); + let mut last = 0; + for c in id_re.captures_iter(s) { + let m = c.get(0).unwrap(); + let id = c[1].to_string(); + let ph = match self.ids.iter().find(|(i, _)| *i == id) { + Some((_, p)) => p.clone(), + None => { + let p = format!("{{{{ID{}}}}}", self.ids.len() + 1); + self.ids.push((id, p.clone())); + p + } + }; + out.push_str(&s[last..m.start()]); + out.push_str(&format!(r#""id":"{ph}""#)); + last = m.end(); + } + out.push_str(&s[last..]); + let now_re = regex::Regex::new(r#""(ts|first_ts|last_ts)":(\d{12,14})"#).unwrap(); + let now = now_ms(); + now_re + .replace_all(&out, |c: ®ex::Captures| { + let v: i64 = c[2].parse().unwrap(); + if (v - now).abs() < 86_400_000 { + format!(r#""{}":"{{{{NOW}}}}""#, &c[1]) + } else { + c[0].to_string() + } + }) + .into_owned() + } + + fn unmask(&self, s: &str) -> String { + let mut s = s.to_string(); + for (id, ph) in &self.ids { + s = s.replace(ph, id); + } + s + } + + fn canon(&mut self, v: &Value) -> Value { + serde_json::from_str(&self.mask(&serde_json::to_string(v).unwrap())).unwrap() + } +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for c in fx["names"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + match memory::mem_file_name(input) { + Ok(o) => assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}"), + Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or(""), "{input:?}"), + } + } + for (input, want) in fx["titles"].as_object().unwrap() { + assert_eq!(memory::title_of(input), want.as_str().unwrap(), "{input:?}"); + } + for (input, want) in fx["slugs"].as_object().unwrap() { + assert_eq!( + memory::slugify_ident(input), + want.as_str().unwrap(), + "{input:?}" + ); + } + for (input, want) in fx["modes"].as_object().unwrap() { + assert_eq!( + memory::normalize_mem_mode(input), + want.as_str().unwrap(), + "{input:?}" + ); + } + for c in fx["when"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + let now = now_ms(); + match memory::parse_when(input, now, Tz::Fixed(7200)) { + Ok((ts, date_only)) => { + assert!(c.get("err").is_none(), "{input:?} : erreur attendue"); + let got = if (ts - now).abs() < 86_400_000 { + json!("{{NOW}}") + } else { + json!(ts) + }; + assert_eq!(got, c["ts"], "{input:?}"); + assert_eq!( + date_only, + c["date_only"].as_bool().unwrap_or(false), + "{input:?}" + ); + } + Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or(""), "{input:?}"), + } + } +} + +fn apply_store(go: &fakego::FakeGo, m: &serde_json::Map) { + for (k, v) in m { + let (b, key) = k.split_once('/').unwrap(); + let v = v.as_str().unwrap(); + let mut s = go.store.lock().unwrap(); + let bucket = s.entry(b.to_string()).or_default(); + if v.is_empty() { + bucket.remove(key); + } else { + bucket.insert(key.to_string(), v.as_bytes().to_vec()); + } + } +} + +fn disk(root: &Path) -> BTreeMap { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + if e.file_type().unwrap().is_dir() { + walk(root, &p, out); + } else { + let rel = p + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert( + rel, + String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn state(go: &fakego::FakeGo) -> Value { + let get = |b: &str, k: &str| go.get(b, k).unwrap_or_default(); + let projects = go + .get("state", "projects") + .and_then(|p| serde_json::from_str::(&p).ok()) + .unwrap_or(Value::Null); + let mut trackers = serde_json::Map::new(); + let mut raw = serde_json::Map::new(); + if let Some(b) = go.store.lock().unwrap().get("tracker") { + for (k, v) in b { + let s = String::from_utf8_lossy(v).into_owned(); + match serde_json::from_str::(&s) { + Ok(x) => { + trackers.insert(k.clone(), x); + } + Err(_) => { + raw.insert(k.clone(), Value::String(s)); + } + } + } + } + json!({ + "agent": get("state", "agent"), + "active": get("state", "active_project"), + "compact": get("config", "COMPACT"), + "mem_mode": get("config", "MEM_MODE"), + "projects": projects, + "trackers": trackers, + "tracker_raw": raw, + }) +} + +async fn start(home: &Path, tz: Tz) -> (std::net::SocketAddr, Arc, impl Drop) { + let opts = fakego::Opts { + mem: Some(Arc::new(Memory::with_tz(home.to_path_buf(), tz))), + ..Default::default() + }; + let (facade, go, stop, _) = fakego::start_with(home.to_path_buf(), opts).await; + struct Stop(Option>); + impl Drop for Stop { + fn drop(&mut self) { + if let Some(s) = self.0.take() { + let _ = s.send(()); + } + } + } + (facade, go, Stop(Some(stop))) +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let (facade, go, _stop) = start(&home, Tz::Fixed(7200)).await; + *go.crypt_mode.lock().unwrap() = CryptMode::Plain; + apply_store(&go, fx["seed"].as_object().unwrap()); + let root = home.join("memory"); + let mut mk = Masker::default(); + for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() { + if let Some(files) = st.get("files").and_then(Value::as_object) { + for (rel, content) in files { + let p = root.join(rel); + let content = content.as_str().unwrap(); + if content.is_empty() { + let _ = std::fs::remove_dir_all(&p); + let _ = std::fs::remove_file(&p); + } else if rel.ends_with('/') { + std::fs::create_dir_all(&p).unwrap(); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, content).unwrap(); + } + } + } + if let Some(s) = st.get("store").and_then(Value::as_object) { + apply_store(&go, s); + } + let (m, p) = (st["method"].as_str().unwrap(), st["path"].as_str().unwrap()); + let body = st.get("body").and_then(Value::as_str).unwrap_or(""); + let (path, body) = (mk.unmask(p), mk.unmask(body)); + let (code, b) = fakego::send(facade, m, &path, &[], &body).await; + let ctx = format!("étape {i} : {m} {p} {body}"); + assert_eq!( + u64::from(code.as_u16()), + st["status"].as_u64().unwrap(), + "{ctx} : {}", + String::from_utf8_lossy(&b) + ); + let got: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(mk.canon(&got), st["out"], "{ctx}"); + let want_disk: BTreeMap = + serde_json::from_value(st["disk"].clone()).unwrap(); + assert_eq!(disk(&root), want_disk, "{ctx} (disque)"); + assert_eq!(mk.canon(&state(&go)), st["state"], "{ctx} (base)"); + } +} + +/// Pages chiffrées : ouvertes par le Go, écrites scellées ; mémoire +/// verrouillée : listées sans contenu, jamais réécrites en clair. +#[tokio::test] +async fn pages_et_trackers_chiffres() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let (facade, go, _stop) = start(&home, Tz::Fixed(0)).await; + *go.crypt_mode.lock().unwrap() = CryptMode::Magic; + let dir = home.join("memory").join("generale"); + let (code, _) = fakego::send( + facade, + "POST", + "/api/mem/save", + &[], + r##"{"name":"secret","content":"# Secret\nmot de passe"}"##, + ) + .await; + assert_eq!(code, 200); + let raw = std::fs::read(dir.join("secret.md")).unwrap(); + assert_eq!(raw, b"LOKIMEMv1# Secret\nmot de passe\n"); + // L'index, lui, reste en clair (comme le Go). + let idx = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap(); + assert!(idx.ends_with("- [Secret](secret.md)\n"), "{idx}"); + let (_, b) = fakego::send(facade, "GET", "/api/mem?name=secret", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["content"], "# Secret\nmot de passe\n"); + let (_, b) = fakego::send( + facade, + "POST", + "/api/tracker", + &[], + r#"{"action":"add","name":"Tension","when":"2024-01-02","text":"12/8"}"#, + ) + .await; + assert_eq!(serde_json::from_slice::(&b).unwrap()["ok"], true); + let sealed = go.get("tracker", "generale/tension").unwrap(); + assert!(sealed.starts_with("LOKIMEMv1{"), "{sealed}"); + + // Verrouillée. + *go.locked.lock().unwrap() = true; + let (_, b) = fakego::send(facade, "GET", "/api/agent", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + let pages = v["pages"].as_array().unwrap(); + let secret = pages.iter().find(|p| p["name"] == "secret.md").unwrap(); + assert_eq!(secret["desc"], "🔒 (chiffré — mémoire verrouillée)"); + let (code, _) = fakego::send(facade, "GET", "/api/mem?name=secret", &[], "").await; + assert_eq!(code, 404); + let (code, b) = fakego::send( + facade, + "POST", + "/api/mem/save", + &[], + r#"{"name":"secret","content":"écrasé"}"#, + ) + .await; + assert_eq!(code, 400); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "page chiffrée, mémoire verrouillée" + ); + assert_eq!( + std::fs::read(dir.join("secret.md")).unwrap(), + raw, + "page réécrite alors que la mémoire est verrouillée" + ); + let (code, b) = fakego::send( + facade, + "POST", + "/api/tracker", + &[], + r#"{"action":"add","name":"Autre","text":"x"}"#, + ) + .await; + assert_eq!(code, 400); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "valeur chiffrée, mémoire verrouillée" + ); + // Les trackers scellés ne sont plus lisibles : la liste les tait. + let (_, b) = fakego::send(facade, "GET", "/api/tracker", &[], "").await; + assert_eq!( + serde_json::from_slice::(&b).unwrap()["trackers"], + json!([]) + ); +} + +#[tokio::test] +async fn sans_la_variable_la_memoire_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/mem", + "/api/mem/save", + "/api/mem/move", + "/api/agent", + "/api/skills/toggle", + "/api/memory", + "/api/tracker", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} + +/// Le coffre, les instantanés et les projets restent au Go même avec la +/// variable. +#[tokio::test] +async fn coffre_et_projets_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = start(tmp.path(), Tz::Local).await; + for p in [ + "/api/mem/health", + "/api/mem/unlock", + "/api/mem/snapshots", + "/api/projects", + "/api/backup/export", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} diff --git a/rust/loki-rs/tests/parity_gojson/decode.json b/rust/loki-rs/tests/parity_gojson/decode.json new file mode 100644 index 0000000..570cb2b --- /dev/null +++ b/rust/loki-rs/tests/parity_gojson/decode.json @@ -0,0 +1,1052 @@ +{ + "cases": [ + { + "in": "", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "EOF" + }, + { + "in": " ", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "EOF" + }, + { + "in": "\n\t", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "EOF" + }, + { + "in": "pas du json", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'p' looking for beginning of value" + }, + { + "in": "{", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\"", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\":", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\":\"a\"", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\":\"a\",}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' looking for beginning of object key string" + }, + { + "in": "{\"name\" \"a\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\"' after object key" + }, + { + "in": "{\"name\":\"a\" \"on\":true}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\"' after object key:value pair" + }, + { + "in": "{name:\"a\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'n' looking for beginning of object key string" + }, + { + "in": "{'name':\"a\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\'' looking for beginning of object key string" + }, + { + "in": "[", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "[1,]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ']' looking for beginning of value" + }, + { + "in": "[1 2]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '2' after array element" + }, + { + "in": "{\"name\":\"a\"} et la suite", + "out": { + "name": "a", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"name\":\"a\"}{\"name\":\"b\"}", + "out": { + "name": "a", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "null", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "true", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal bool into Go value of type loki.parityDecodeT" + }, + { + "in": "\"texte\"", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go value of type loki.parityDecodeT" + }, + { + "in": "12", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go value of type loki.parityDecodeT" + }, + { + "in": "[1,2]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal array into Go value of type loki.parityDecodeT" + }, + { + "in": "{\"name\":5}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":true}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal bool into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":{}}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal object into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":[]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal array into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":null}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"NAME\":\"maj\"}", + "out": { + "name": "maj", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"Name\":\"a\",\"name\":\"b\"}", + "out": { + "name": "b", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"on\":\"oui\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.on of type bool" + }, + { + "in": "{\"on\":1}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.on of type bool" + }, + { + "in": "{\"on\":true,\"name\":3,\"n\":4}", + "out": { + "name": "", + "on": true, + "n": 4, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"n\":1.5}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number 1.5 into Go struct field parityDecodeT.n of type int" + }, + { + "in": "{\"n\":\"3\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.n of type int" + }, + { + "in": "{\"n\":-7}", + "out": { + "name": "", + "on": false, + "n": -7, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"n\":1e2}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number 1e2 into Go struct field parityDecodeT.n of type int" + }, + { + "in": "{\"args\":[\"a\",\"b\"]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [ + "a", + "b" + ], + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"args\":[\"a\",1,\"c\"]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [ + "a", + "", + "c" + ], + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.args of type string" + }, + { + "in": "{\"args\":\"x\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.args of type []string" + }, + { + "in": "{\"args\":[]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [], + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"args\":[null]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [ + "" + ], + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"env\":{\"A\":\"1\",\"B\":2}}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": { + "A": "1", + "B": "" + }, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.env of type string" + }, + { + "in": "{\"env\":[]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal array into Go struct field parityDecodeT.env of type map[string]string" + }, + { + "in": "{\"opt\":\"\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": "", + "flag": null + } + }, + { + "in": "{\"opt\":null}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"opt\":\"x\",\"flag\":false}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": "x", + "flag": false + } + }, + { + "in": "{\"flag\":\"non\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": false + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.flag of type bool" + }, + { + "in": "{\"name\":\"été\"}", + "out": { + "name": "été", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"name\":\"a\\nb\"}", + "out": { + "name": "a\nb", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"name\":\"a\u0001b\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\x01' in string literal" + }, + { + "in": "{\"name\":\"\\x\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in string escape code" + }, + { + "in": "{\"name\":\"\\u12G4\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'G' in \\u hexadecimal character escape" + }, + { + "in": "tru", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "trux", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in literal true (expecting 'e')" + }, + { + "in": "nul", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "nulx", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in literal null (expecting 'l')" + }, + { + "in": "fals", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "falsy", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'y' in literal false (expecting 'e')" + }, + { + "in": "-", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "-x", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in numeric literal" + }, + { + "in": "1.", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "1.x", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' after decimal point in numeric literal" + }, + { + "in": "1e", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "1ex", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in exponent of numeric literal" + }, + { + "in": "{\"a\":1.}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' after decimal point in numeric literal" + }, + { + "in": "{\"a\":01}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '1' after object key:value pair" + }, + { + "in": "{\"a\":-}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' in numeric literal" + }, + { + "in": "[1,2,3}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' after array element" + }, + { + "in": "{\"a\":1]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ']' after object key:value pair" + }, + { + "in": "\u0001", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\x01' looking for beginning of value" + }, + { + "in": "", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\x7f' looking for beginning of value" + }, + { + "in": "é", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'Ã' looking for beginning of value" + }, + { + "in": "'x'", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\'' looking for beginning of value" + }, + { + "in": "\"x", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"a\":\"b\"}}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' looking for beginning of value" + }, + { + "in": "]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ']' looking for beginning of value" + }, + { + "in": ",", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ',' looking for beginning of value" + }, + { + "in": ":", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ':' looking for beginning of value" + }, + { + "in": "{\"a\" : [ { \"b\" : null } ] , \"name\" : \"ok\" }", + "out": { + "name": "ok", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + } + ] +} diff --git a/rust/loki-rs/tests/parity_mem/mem.json b/rust/loki-rs/tests/parity_mem/mem.json new file mode 100644 index 0000000..ae37f42 --- /dev/null +++ b/rust/loki-rs/tests/parity_mem/mem.json @@ -0,0 +1,7597 @@ +{ + "names": [ + { + "in": "", + "err": "nom vide" + }, + { + "in": " ", + "err": "nom vide" + }, + { + "in": "notes", + "out": "notes.md" + }, + { + "in": "notes.md", + "out": "notes.md" + }, + { + "in": "NOTES.MD", + "out": "NOTES.MD" + }, + { + "in": "a b", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "../x", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "x/y", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "é", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "a.b-c_d", + "out": "a.b-c_d.md" + }, + { + "in": ".md", + "out": ".md" + }, + { + "in": ".", + "out": "..md" + }, + { + "in": "..", + "out": "...md" + }, + { + "in": "x.md.md", + "out": "x.md.md" + }, + { + "in": " pad ", + "out": "pad.md" + }, + { + "in": "tab\t", + "out": "tab.md" + }, + { + "in": "MEMORY", + "out": "MEMORY.md" + }, + { + "in": "memory.md", + "out": "memory.md" + }, + { + "in": "x.Md", + "out": "x.Md" + } + ], + "titles": { + "": "", + "\t# tab": "tab", + "\n\n": "", + "\n \n# A\n# B": "A", + " ### ": "", + "#\n#\nVrai titre": "Vrai titre", + "# # double": "# double", + "# Titre": "Titre", + "#  espace insécable ": "espace insécable", + "## Titre ##": "Titre ##", + "sans dièse\nsuite": "sans dièse" + }, + "slugs": { + "": "", + " Hello World ": "hello-world", + "!!!": "", + "---x---": "x", + "Perso & Cie": "perso-cie", + "Réseau NAS": "reseau-nas", + "Straße": "stra-e", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bcd": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "abababababababababababababababababababababababababababababab": "abababababababababababababababababababab", + "Ça_va?": "ca-va", + "ÉCOLE": "ecole", + "ÎLE ÔÔ ÙÛ Ñ Ç": "ile-oo-uu-n-c", + "àâäáã éèêë": "aaaaa-eeee", + "İstanbul": "istanbul", + "Ωmega": "mega" + }, + "modes": { + "": "always", + " OFF ": "off", + "always": "always", + "auto": "always", + "bizarre": "always", + "off": "off", + "on-demand": "always", + "ondemand": "ondemand", + "search": "search" + }, + "when": [ + { + "in": "", + "ts": "{{NOW}}" + }, + { + "in": "now", + "ts": "{{NOW}}" + }, + { + "in": "NOW", + "ts": "{{NOW}}" + }, + { + "in": " Maintenant ", + "ts": "{{NOW}}" + }, + { + "in": "2024-03-10", + "ts": 1710021600000, + "date_only": true + }, + { + "in": "2024-03-10T08:15", + "ts": 1710051300000 + }, + { + "in": "2024-03-10 7:05", + "ts": 1710047100000 + }, + { + "in": "2024-03-10 07:5", + "err": "date non comprise : \"2024-03-10 07:5\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 07:05:09", + "ts": 1710047109000 + }, + { + "in": "2024-03-10 07:05:09.5", + "ts": 1710047109500 + }, + { + "in": "2024-03-10 07:05:09,123456789999", + "ts": 1710047109123 + }, + { + "in": "2024-03-10 24:00", + "err": "date non comprise : \"2024-03-10 24:00\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 23:60", + "err": "date non comprise : \"2024-03-10 23:60\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-13-01", + "err": "date non comprise : \"2024-13-01\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-02-29", + "ts": 1709157600000, + "date_only": true + }, + { + "in": "2023-02-29", + "err": "date non comprise : \"2023-02-29\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-04-31", + "err": "date non comprise : \"2024-04-31\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03", + "ts": 1709244000000, + "date_only": true + }, + { + "in": "2024", + "ts": 1704060000000, + "date_only": true + }, + { + "in": "24", + "err": "date non comprise : \"24\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-3", + "err": "date non comprise : \"2024-3\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-1", + "err": "date non comprise : \"2024-03-1\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": " 2024-03-10 ", + "ts": 1710021600000, + "date_only": true + }, + { + "in": "2024-03-10 07:05", + "ts": 1710047100000 + }, + { + "in": "demain", + "err": "date non comprise : \"demain\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10T07:05Z", + "err": "date non comprise : \"2024-03-10T07:05Z\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "today", + "err": "date non comprise : \"today\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "+024", + "err": "date non comprise : \"+024\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "0000-01-01", + "ts": -62167226400000, + "date_only": true + }, + { + "in": "9999-12-31 23:59:59", + "ts": 253402293599000 + }, + { + "in": "1234", + "err": "date non comprise : \"1234\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 07", + "ts": 1710046800000 + }, + { + "in": "2024-03-10 7", + "ts": 1710046800000 + }, + { + "in": "x\u0001y", + "err": "date non comprise : \"x\\x01y\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10T", + "err": "date non comprise : \"2024-03-10T\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 07:05:60", + "err": "date non comprise : \"2024-03-10 07:05:60\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + } + ], + "seed": { + "config/MEM_MODE": "ondemand", + "state/active_project": "generale", + "state/projects": "[{\"slug\":\"generale\",\"name\":\"Générale\",\"created_at\":1000},{\"slug\":\"perso\",\"name\":\"Perso & Cie\",\"created_at\":2000,\"desc\":\"à moi\"}]" + }, + "steps": [ + { + "method": "GET", + "path": "/api/mem", + "status": 200, + "out": { + "content": "# nouvelle page\n\nNote ici ce que loki doit retenir entre les sessions.\n", + "name": "" + }, + "disk": {}, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=absente", + "status": 404, + "out": { + "error": "not found" + }, + "disk": {}, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [], + "skills": [] + }, + "disk": {}, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"courses\",\"content\":\"# Courses\\n\\nlait\\n\\n\\n\"}", + "status": 200, + "out": { + "name": "courses", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n", + "generale/courses.md": "# Courses\n\nlait\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"notes.md\",\"content\":\"Sans titre\"}", + "status": 200, + "out": { + "name": "notes.md", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"../evil\",\"content\":\"x\"}", + "status": 400, + "out": { + "error": "nom invalide (alphanum, ._-)", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\" \",\"content\":\"x\"}", + "status": 400, + "out": { + "error": "nom vide", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "pas du json", + "status": 400, + "out": { + "error": "invalid character 'p' looking for beginning of value", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":5}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field saveReq.name of type string", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "[1]", + "status": 400, + "out": { + "error": "json: cannot unmarshal array into Go value of type loki.saveReq", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "status": 400, + "out": { + "error": "EOF", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"courses-v2\",\"old\":\"courses\",\"content\":\"# Courses v2\\nœufs & \"}", + "status": 200, + "out": { + "name": "courses-v2", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Sans titre](notes.md)\n- [Courses v2](courses-v2.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"notes\",\"old\":\"notes\",\"content\":\"## Titre\\nmaj\"}", + "status": 200, + "out": { + "name": "notes", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=courses-v2", + "status": 200, + "out": { + "content": "# Courses v2\nœufs & \n", + "name": "courses-v2" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=+courses-v2.md+", + "status": 200, + "out": { + "content": "# Courses v2\nœufs & \n", + "name": "courses-v2.md" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=a&name=courses-v2", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=../x", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "files": { + "generale/.cache.md": "caché", + "generale/Zeta.MD": "# Zêta\n", + "generale/dossier.md/": "-", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "method": "GET", + "path": "/api/tools", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v2", + "name": "courses-v2.md" + }, + { + "desc": "Titre", + "name": "notes.md" + }, + { + "desc": "", + "name": "vide.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v2", + "name": "courses-v2.md" + }, + { + "desc": "Titre", + "name": "notes.md" + }, + { + "desc": "", + "name": "vide.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{\"name\":\"absente\"}", + "status": 400, + "out": { + "error": "introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{\"name\":\"bad/name\"}", + "status": 400, + "out": { + "error": "nom invalide (alphanum, ._-)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{\"name\":\"vide\"}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{nope}", + "status": 400, + "out": { + "error": "invalid character 'n' looking for beginning of object key string", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "files": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v2](courses-v2.md) — la liste\n- [Titre](notes.md)\n" + }, + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"courses-v2\",\"old\":\"courses-v2\",\"content\":\"# Courses v3\"}", + "status": 200, + "out": { + "name": "courses-v2", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Titre](notes.md)\n- [Courses v3](courses-v2.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"neuve\",\"content\":\"# Neuve\"}", + "status": 200, + "out": { + "name": "neuve", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Titre](notes.md)\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"notes\",\"to\":\"perso\"}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"notes\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "page introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"courses-v2\",\"to\":\"generale\"}", + "status": 400, + "out": { + "error": "déjà dans ce projet", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"courses-v2\",\"to\":\" generale \"}", + "status": 400, + "out": { + "error": "déjà dans ce projet", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"courses-v2\",\"to\":\"inconnu\"}", + "status": 400, + "out": { + "error": "projet cible introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"MEMORY\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "l'index du projet n'est pas déplaçable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"x/y\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "nom invalide (alphanum, ._-)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "[]", + "status": 400, + "out": { + "error": "json: cannot unmarshal array into Go value of type struct { Name string \"json:\\\"name\\\"\"; To string \"json:\\\"to\\\"\" }", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem/move", + "status": 405, + "out": { + "error": "méthode non autorisée", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "files": { + "generale/doublon.md": "# A", + "perso/doublon.md": "# B" + }, + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"doublon\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "une page du même nom existe déjà dans le projet cible", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "perso" + }, + "method": "GET", + "path": "/api/skills", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Perso & Cie", + "name": "MEMORY.md" + }, + { + "desc": "B", + "name": "doublon.md" + }, + { + "desc": "Titre", + "name": "notes.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Perso & Cie", + "name": "MEMORY.md" + }, + { + "desc": "B", + "name": "doublon.md" + }, + { + "desc": "Titre", + "name": "notes.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"perso-1\",\"content\":\"Première\\nligne\"}", + "status": 200, + "out": { + "name": "perso-1", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"doublon\",\"to\":\"generale\"}", + "status": 400, + "out": { + "error": "une page du même nom existe déjà dans le projet cible", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "generale" + }, + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/toggle", + "body": "{\"on\":true}", + "status": 200, + "out": { + "enabled": true, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "1", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/tools", + "status": 200, + "out": { + "compact": true, + "enabled": true, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "1", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/skills/toggle", + "body": "{\"on\":\"oui\"}", + "status": 200, + "out": { + "enabled": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/tools/toggle", + "body": "{\"on\":true}", + "status": 200, + "out": { + "enabled": true, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "1", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/toggle", + "body": "pas du json", + "status": 200, + "out": { + "enabled": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/compact", + "body": "{\"on\":false}", + "status": 200, + "out": { + "compact": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "off", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": false, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "off", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/compact", + "body": "{}", + "status": 200, + "out": { + "compact": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "off", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "config/COMPACT": " NON " + }, + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": false, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": " NON ", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/compact", + "body": "{\"on\":true}", + "status": 200, + "out": { + "compact": true, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/memory", + "status": 200, + "out": { + "mode": "ondemand", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\" Search \"}", + "status": 200, + "out": { + "mode": "search", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "search", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "search", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "search", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/memory", + "body": "pas du json", + "status": 200, + "out": { + "mode": "always", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "always", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\"off\"}", + "status": 200, + "out": { + "mode": "off", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "PUT", + "path": "/api/memory", + "body": "{\"mode\":\"ondemand\"}", + "status": 200, + "out": { + "mode": "off", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "fantome" + }, + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\"off\"}", + "status": 500, + "out": { + "error": "projet introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "fantome", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/memory", + "status": 200, + "out": { + "mode": "ondemand", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "fantome", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "perso" + }, + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\"ondemand\"}", + "status": 200, + "out": { + "mode": "ondemand", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "generale" + }, + "method": "GET", + "path": "/api/memory", + "status": 200, + "out": { + "mode": "off", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/tracker", + "status": 200, + "out": { + "ok": true, + "trackers": [] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Poids\",\"when\":\"2024-03-10\",\"text\":\" 78,2 kg \"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1710021600000, + "last_text": "78,2 kg", + "last_ts": 1710021600000, + "name": "Poids", + "slug": "poids" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + } + ], + "name": "Poids" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Poids\",\"when\":\"2024-03-09 08:15\",\"text\":\"78,9\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1709964900000, + "last_text": "78,2 kg", + "last_ts": 1710021600000, + "name": "Poids", + "slug": "poids" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + } + ], + "name": "Poids" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"poids\",\"text\":\"77\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"ADD\",\"name\":\"Réseau NAS\",\"when\":\"2023\",\"text\":\"installé & fini\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"\",\"text\":\"x\"}", + "status": 400, + "out": { + "error": "nom de tracker vide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"x\",\"text\":\" \"}", + "status": 400, + "out": { + "error": "contenu vide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"x\",\"when\":\"demain\",\"text\":\"y\"}", + "status": 400, + "out": { + "error": "date non comprise : \"demain\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"!!!\",\"when\":\"2025-01\",\"text\":\"z\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Même jour\",\"when\":\"2025-01\",\"text\":\"m\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker?slug=poids", + "status": 200, + "out": { + "ok": true, + "tracker": { + "count": 3, + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + } + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker?slug=absent", + "status": 404, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"{{ID2}}\",\"text\":\" 78,0 kg \"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"{{ID2}}\",\"when\":\"2024-03-11 07:00\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1710021600000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"{{ID1}}\",\"when\":\"hier\",\"text\":\"perdu\"}", + "status": 400, + "out": { + "error": "date non comprise : \"hier\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"999\",\"text\":\"x\"}", + "status": 400, + "out": { + "error": "événement introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"absent\",\"id\":\"{{ID1}}\",\"text\":\"x\"}", + "status": 400, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete\",\"slug\":\"poids\",\"id\":\"{{ID1}}\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete\",\"slug\":\"poids\",\"id\":\"{{ID1}}\"}", + "status": 400, + "out": { + "error": "événement introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"reseau-nas\",\"name\":\" Réseau NAS (bureau) \"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"poids\",\"name\":\"POIDS\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "POIDS", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"tracker\",\"name\":\"Poids\"}", + "status": 400, + "out": { + "error": "un tracker du même nom existe déjà", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"tracker\",\"name\":\" \"}", + "status": 400, + "out": { + "error": "nom vide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"tracker\",\"name\":\"???\"}", + "status": 400, + "out": { + "error": "nom invalide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"absent\",\"name\":\"Neuf\"}", + "status": 400, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"tracker\",\"to\":\"perso\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "POIDS", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"poids\",\"to\":\"inconnu\"}", + "status": 400, + "out": { + "error": "projet cible introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"poids\",\"to\":\"generale\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "POIDS", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"absent\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "state/active_project": "perso" + }, + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Même jour\",\"when\":\"2025-02\",\"text\":\"perso\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1738360800000, + "last_text": "perso", + "last_ts": 1738360800000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "state/active_project": "generale" + }, + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"meme-jour\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "un tracker du même nom existe déjà dans le projet cible", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete_tracker\",\"slug\":\"poids\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete_tracker\",\"slug\":\"absent\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"bidule\"}", + "status": 400, + "out": { + "error": "action inconnue : \"bidule\"", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "pas du json", + "status": 400, + "out": { + "error": "invalid character 'p' looking for beginning of value", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "\"texte\"", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go value of type struct { Action string \"json:\\\"action\\\"\"; Slug string \"json:\\\"slug\\\"\"; Name string \"json:\\\"name\\\"\"; When string \"json:\\\"when\\\"\"; Text string \"json:\\\"text\\\"\"; ID string \"json:\\\"id\\\"\"; To string \"json:\\\"to\\\"\" }", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":3}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .name of type string", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "PUT", + "path": "/api/tracker", + "status": 405, + "out": { + "error": "méthode non autorisée", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "tracker/generale/casse": "pas du json" + }, + "method": "GET", + "path": "/api/tracker", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker?slug=casse", + "status": 404, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "tracker/generale/vide": "{\"name\":\"Vide\",\"events\":null}" + }, + "method": "GET", + "path": "/api/tracker?slug=vide", + "status": 200, + "out": { + "ok": true, + "tracker": { + "count": 0, + "first_ts": 0, + "last_text": "", + "last_ts": 0, + "name": "Vide", + "slug": "vide" + } + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "Vide" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"vide\",\"name\":\"VIDE\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + }, + { + "count": 0, + "first_ts": 0, + "last_text": "", + "last_ts": 0, + "name": "VIDE", + "slug": "vide" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "VIDE" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete\",\"slug\":\"vide\",\"id\":\"1\"}", + "status": 400, + "out": { + "error": "événement introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "VIDE" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + }, + { + "count": 0, + "first_ts": 0, + "last_text": "", + "last_ts": 0, + "name": "VIDE", + "slug": "vide" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "VIDE" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 347d2aa..8bdaadb 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -105,6 +105,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { settings: false, files: None, push: None, + mem: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await;