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;