diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md
new file mode 100644
index 0000000..5da22f9
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md
@@ -0,0 +1,97 @@
+# Lot 46 — Outils recall et recall_search dans la façade
+
+## Contexte
+
+La compaction résume les vieux tours d'une discussion. Avant de résumer un
+gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
+court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
+
+- `recall(id)` rend le bloc exact ;
+- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
+ sorti du résumé courant.
+
+La façade compacte elle-même et archive par le Go
+(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
+base, que seul le Go tient. Les deux outils passaient encore par
+`/internal/v1/tool/exec`.
+
+## Le lot
+
+`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
+(`chat_recall.go`) :
+
+- **l'id tolérant** (`recallIDToSeq`) :
+ - espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
+ - des chiffres seulement : ni signe, ni dépassement de 64 bits ;
+ - un id mal formé ne lit pas l'archive ;
+- **les mots-clés** (`recallTokens`) :
+ - mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
+ donne `i`, `Σ` donne `σ`) ;
+ - coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
+ - deux runes au moins ;
+- **le score** :
+ - un point par terme présent dans le contenu, deux dans le libellé ;
+ - à égalité, le plus récent d'abord ;
+ - 5 résultats par défaut, 20 au plus, limite tronquée comme
+ `int(float64)` ;
+- **les textes**, mêmes que le Go :
+ - id ou requête manquants ;
+ - bloc introuvable (id cité par `%q`) ;
+ - bloc rappelé verbatim ;
+ - liste des résultats avec un extrait d'une ligne de 160 runes ;
+ - aucun résultat.
+
+L'archive reste au Go, qui la sert à la façade :
+
+- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
+ absent ou chiffré sous une mémoire verrouillée ;
+- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
+ croissante.
+
+`recallGet` et `recallSearch` du Go passent par les mêmes lectures
+(`recallGetSeq`, `recallAll`).
+
+`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
+Si le Go ne répond pas, l'appel passe par lui.
+
+## Parité
+
+`internal/loki/parity_recall_test.go` archive 35 blocs variés :
+
+- accents et casse Unicode ;
+- balises et entités ;
+- bloc vide ;
+- premières lignes longues ;
+- vingt-cinq blocs semblables.
+
+Il tire ensuite du Go un journal de 41 appels
+(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
+sert :
+
+- `recall` :
+ - id absent, non texte, blanc ;
+ - variantes de préfixe et d'espaces ;
+ - zéros de tête ;
+ - mal formé, négatif, signe plus, zéro, absent, démesuré ;
+ - guillemets dans l'id ;
+- `recall_search` :
+ - requête absente, non texte, blanche ;
+ - mots trop courts ;
+ - casse, accents, `İ`, grec hors des mots ;
+ - souligné coupé ;
+ - extraits bornés ;
+ - limites : une, tronquée, négative, par défaut, au plus, en texte
+ ignorée ;
+ - aucun résultat ;
+ - ponctuation.
+
+`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
+aussi le repli quand le Go ne répond pas.
+
+D'autres tests complètent :
+
+- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
+ et cherche sans passer par `/tool/exec` ;
+- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
+ illisible, méthode refusée, ordre) ;
+- tests unitaires : ids, mots-clés, score, extraits, textes.
diff --git a/internal/loki/chat_recall.go b/internal/loki/chat_recall.go
index e439769..959093d 100644
--- a/internal/loki/chat_recall.go
+++ b/internal/loki/chat_recall.go
@@ -138,6 +138,11 @@ func recallGet(id string) (recallBlock, bool) {
if !ok {
return recallBlock{}, false
}
+ return recallGetSeq(seq)
+}
+
+// recallGetSeq ramène un bloc par sa séquence (aussi servi à la façade).
+func recallGetSeq(seq uint64) (recallBlock, bool) {
var blk recallBlock
found := false
_ = view(bkRecall, func(b *bolt.Bucket) error {
@@ -155,6 +160,27 @@ func recallGet(id string) (recallBlock, bool) {
return blk, found
}
+// recallAll : tous les blocs lisibles, par séquence croissante (un bloc
+// chiffré sous une mémoire verrouillée n'en fait pas partie).
+func recallAll() []recallBlock {
+ var out []recallBlock
+ _ = view(bkRecall, func(b *bolt.Bucket) error {
+ return b.ForEach(func(_, v []byte) error {
+ var blk recallBlock
+ dec, err := decodeMemContent(v)
+ if err != nil {
+ return nil
+ }
+ if json.Unmarshal(dec, &blk) != nil {
+ return nil
+ }
+ out = append(out, blk)
+ return nil
+ })
+ })
+ return out
+}
+
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
// Score = nombre de termes distincts de la requête présents dans (libellé +
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
@@ -176,33 +202,22 @@ func recallSearch(query string, limit int) []recallBlock {
score int
}
var hits []scored
- _ = view(bkRecall, func(b *bolt.Bucket) error {
- return b.ForEach(func(_, v []byte) error {
- var blk recallBlock
- dec, err := decodeMemContent(v)
- if err != nil {
- return nil // chiffré et verrouillé : ce bloc ne participe pas
+ for _, blk := range recallAll() {
+ label := strings.ToLower(blk.Label)
+ body := strings.ToLower(blk.Content)
+ score := 0
+ for t := range terms {
+ if strings.Contains(label, t) {
+ score += 2
}
- if json.Unmarshal(dec, &blk) != nil {
- return nil
+ if strings.Contains(body, t) {
+ score++
}
- label := strings.ToLower(blk.Label)
- body := strings.ToLower(blk.Content)
- score := 0
- for t := range terms {
- if strings.Contains(label, t) {
- score += 2
- }
- if strings.Contains(body, t) {
- score++
- }
- }
- if score > 0 {
- hits = append(hits, scored{blk, score})
- }
- return nil
- })
- })
+ }
+ if score > 0 {
+ hits = append(hits, scored{blk, score})
+ }
+ }
sort.Slice(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
diff --git a/internal/loki/parity_recall_test.go b/internal/loki/parity_recall_test.go
new file mode 100644
index 0000000..c662c0d
--- /dev/null
+++ b/internal/loki/parity_recall_test.go
@@ -0,0 +1,143 @@
+package loki
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// parity_recall_test.go — journal de référence des outils recall et
+// recall_search (lot 46 de la migration Rust) : une archive variée (accents,
+// casse Unicode, contenus longs, beaucoup de blocs), puis les appels de
+// l'agent, des ids tolérés aux limites de recherche. Rangé dans
+// rust/loki-rs/tests/parity_recall/recall.json avec les blocs tels que
+// l'archive les sert ; tests/recall.rs le rejoue contre le port.
+//
+// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteRecall
+
+const parityRecallFile = "../../rust/loki-rs/tests/parity_recall/recall.json"
+
+type parityRecallStep struct {
+ Name string `json:"name"`
+ Tool string `json:"tool"`
+ Args map[string]any `json:"args"`
+ Result string `json:"result"`
+}
+
+func TestPariteRecall(t *testing.T) {
+ testHome(t)
+ arch := func(role, tool, content string) {
+ t.Helper()
+ if _, err := archiveRecallBlock(recallLabel(role, tool, content), role, content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ arch("tool", "web_read", "Recette du gratin dauphinois\n"+strings.Repeat("pommes de terre, crème, ail ", 30))
+ arch("tool", "web_read", "Horaires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h")
+ arch("tool", "bash", "\n\n $ ls -la\na.go b.go\tc.go")
+ arch("assistant", "", "Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.")
+ arch("user", "", "İSTANBUL et ΣΟΦΙΑ en majuscules")
+ arch("tool", "", "outil sans nom : train de marchandises")
+ arch("tool", "read_file", strings.Repeat("é", 200)+" fin")
+ arch("tool", "web_read", "balises & entités & \"guillemets\"")
+ arch("tool", "bash", "")
+ arch("tool", "mem_search", strings.Repeat("très longue première ligne sans fin ", 5)+"\nsuite")
+ for i := 0; i < 25; i++ {
+ arch("tool", "bash", fmt.Sprintf("lot %d de la migration : sortie numéro %d", i, i))
+ }
+
+ var steps []parityRecallStep
+ run := func(name, tool string, args map[string]any) {
+ var r string
+ if tool == "recall" {
+ r = toolRecall(args)
+ } else {
+ r = toolRecallSearch(args)
+ }
+ steps = append(steps, parityRecallStep{Name: name, Tool: tool, Args: args, Result: r})
+ }
+ id := func(v any) map[string]any { return map[string]any{"id": v} }
+ q := func(v any, kv ...any) map[string]any {
+ m := map[string]any{"query": v}
+ for i := 0; i+1 < len(kv); i += 2 {
+ m[kv[i].(string)] = kv[i+1]
+ }
+ return m
+ }
+ run("sans id", "recall", map[string]any{})
+ run("id non texte", "recall", id(7.0))
+ run("id blanc", "recall", id(" "))
+ run("r1", "recall", id("r1"))
+ run("R2 majuscule", "recall", id("R2"))
+ run("espaces", "recall", id(" 3 "))
+ run("sans préfixe", "recall", id("4"))
+ run("espace après le préfixe", "recall", id("r 5"))
+ run("deux préfixes", "recall", id("rR6"))
+ run("préfixes à l'envers", "recall", id("Rr6"))
+ run("zéros de tête", "recall", id("r007"))
+ run("contenu long", "recall", id("r7"))
+ run("caractères spéciaux", "recall", id("r8"))
+ run("bloc vide", "recall", id("r9"))
+ run("mal formé", "recall", id("rr"))
+ run("négatif", "recall", id("r-1"))
+ run("signe plus", "recall", id("+1"))
+ run("zéro", "recall", id("r0"))
+ run("absent", "recall", id("r999"))
+ run("démesuré", "recall", id("r99999999999999999999"))
+ run("guillemets dans l'id", "recall", id(`r"9\`))
+
+ run("sans requête", "recall_search", map[string]any{})
+ run("requête non texte", "recall_search", q(3.0))
+ run("requête blanche", "recall_search", q(" "))
+ run("mots trop courts", "recall_search", q("a b ! ?"))
+ run("train horaires", "recall_search", q("train horaires"))
+ run("casse", "recall_search", q("TRAIN"))
+ run("accents majuscules", "recall_search", q("ÉTÉ"))
+ run("i turc", "recall_search", q("İstanbul"))
+ run("sigma", "recall_search", q("σοφια"))
+ run("souligné coupé", "recall_search", q("web_read"))
+ run("extrait borné", "recall_search", q("fin éé"))
+ run("première ligne longue", "recall_search", q("suite"))
+ run("limite 1", "recall_search", q("train", "limit", 1.0))
+ run("limite tronquée", "recall_search", q("train", "limit", 2.7))
+ run("limite négative", "recall_search", q("lot", "limit", -3.0))
+ run("défaut", "recall_search", q("migration"))
+ run("vingt au plus", "recall_search", q("lot migration", "limit", 50.0))
+ run("limite texte ignorée", "recall_search", q("lot", "limit", "3"))
+ run("rien ne correspond", "recall_search", q("motabsolumentabsent"))
+ run("ponctuation", "recall_search", q("«entités»,&"))
+
+ blocks := recallAll()
+ for i := range blocks {
+ blocks[i].Created = 0
+ }
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(map[string]any{"blocks": blocks, "steps": steps}); err != nil {
+ t.Fatal(err)
+ }
+ got := buf.Bytes()
+ if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
+ if err := os.MkdirAll(filepath.Dir(parityRecallFile), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(parityRecallFile, got, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ want, err := os.ReadFile(parityRecallFile)
+ if err != nil {
+ t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("recall ne produit plus le journal rangé dans %s.\n"+
+ "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityRecallFile)
+ }
+}
diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go
index 3a28ccb..7df4cbd 100644
--- a/internal/loki/web_internal.go
+++ b/internal/loki/web_internal.go
@@ -107,6 +107,10 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalEngineStop(w, r)
case "/internal/v1/recall/archive":
handleInternalRecallArchive(w, r)
+ case "/internal/v1/recall/block":
+ handleInternalRecallBlock(w, r)
+ case "/internal/v1/recall/blocks":
+ handleInternalRecallBlocks(w, r)
case "/internal/v1/turn/start":
handleInternalTurnStart(w, r)
case "/internal/v1/turn/view":
diff --git a/internal/loki/web_internal_recall.go b/internal/loki/web_internal_recall.go
index a811693..bd0a16f 100644
--- a/internal/loki/web_internal_recall.go
+++ b/internal/loki/web_internal_recall.go
@@ -5,6 +5,7 @@ import (
"errors"
"io"
"net/http"
+ "strconv"
)
// API interne de l'archive recall (lot 6 de la migration Rust).
@@ -16,6 +17,12 @@ import (
//
// POST /internal/v1/recall/archive {"label":…,"role":…,"content":…}
// → 200 {"id":"r7"} · 423 mémoire verrouillée (rien d'écrit en clair)
+//
+// Les outils recall et recall_search de la façade (lot 46) lisent l'archive
+// ici ; le reste (id tolérant, score, mise en forme) est chez elle :
+//
+// GET /internal/v1/recall/block?seq=7 → 200 bloc · 404 absent ou illisible
+// GET /internal/v1/recall/blocks → 200 [blocs lisibles, par séquence]
const internalRecallMaxBody = 32 << 20
@@ -46,3 +53,35 @@ func handleInternalRecallArchive(w http.ResponseWriter, r *http.Request) {
}
sendJSON(w, http.StatusOK, map[string]any{"id": id})
}
+
+func handleInternalRecallBlock(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", http.MethodGet)
+ sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
+ return
+ }
+ seq, err := strconv.ParseUint(r.URL.Query().Get("seq"), 10, 64)
+ if err != nil {
+ sendJSON(w, http.StatusBadRequest, map[string]any{"error": "seq illisible"})
+ return
+ }
+ blk, ok := recallGetSeq(seq)
+ if !ok {
+ sendJSON(w, http.StatusNotFound, map[string]any{"error": "bloc introuvable"})
+ return
+ }
+ sendJSON(w, http.StatusOK, blk)
+}
+
+func handleInternalRecallBlocks(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", http.MethodGet)
+ sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
+ return
+ }
+ blks := recallAll()
+ if blks == nil {
+ blks = []recallBlock{}
+ }
+ sendJSON(w, http.StatusOK, blks)
+}
diff --git a/internal/loki/web_internal_recall_test.go b/internal/loki/web_internal_recall_test.go
index 63ec8cd..ca5c20e 100644
--- a/internal/loki/web_internal_recall_test.go
+++ b/internal/loki/web_internal_recall_test.go
@@ -1,6 +1,7 @@
package loki
import (
+ "encoding/json"
"strings"
"testing"
)
@@ -27,3 +28,44 @@ func TestRecallInterneArchive(t *testing.T) {
}
}
}
+
+// Lot 46 : la façade lit l'archive (un bloc par sa séquence, ou tous les
+// blocs lisibles par séquence croissante) ; le reste est chez elle.
+func TestRecallInterneLecture(t *testing.T) {
+ testHome(t)
+ t.Setenv(internalTokenEnv, jetonTest)
+ if w := appelOAIInterne(t, "GET", "/internal/v1/recall/blocks", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
+ t.Fatalf("archive vide : %d %s", w.Code, w.Body.String())
+ }
+ for _, c := range []string{"premier ", "second bloc"} {
+ if _, err := archiveRecallBlock("bash: "+c, "tool", c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ w := appelOAIInterne(t, "GET", "/internal/v1/recall/block?seq=1", "")
+ if w.Code != 200 {
+ t.Fatalf("bloc 1 : %d %s", w.Code, w.Body.String())
+ }
+ var blk recallBlock
+ if err := json.Unmarshal(w.Body.Bytes(), &blk); err != nil || blk.ID != "r1" || blk.Content != "premier " || blk.Label != "bash: premier " {
+ t.Fatalf("bloc 1 : %v %+v", err, blk)
+ }
+ if w := appelOAIInterne(t, "GET", "/internal/v1/recall/block?seq=9", ""); w.Code != 404 {
+ t.Fatalf("absent : %d", w.Code)
+ }
+ for _, q := range []string{"", "?seq=", "?seq=r1", "?seq=-1"} {
+ if w := appelOAIInterne(t, "GET", "/internal/v1/recall/block"+q, ""); w.Code != 400 {
+ t.Fatalf("%q : %d", q, w.Code)
+ }
+ }
+ for _, p := range []string{"/internal/v1/recall/block?seq=1", "/internal/v1/recall/blocks"} {
+ if w := appelOAIInterne(t, "POST", p, ""); w.Code != 405 {
+ t.Fatalf("POST %s : %d", p, w.Code)
+ }
+ }
+ w = appelOAIInterne(t, "GET", "/internal/v1/recall/blocks", "")
+ var all []recallBlock
+ if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil || len(all) != 2 || all[0].Seq != 1 || all[1].Content != "second bloc" {
+ t.Fatalf("tous : %v %s", err, w.Body.String())
+ }
+}
diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs
index 5ff33ef..3d71b63 100644
--- a/rust/loki-rs/src/chat/host.rs
+++ b/rust/loki-rs/src/chat/host.rs
@@ -718,6 +718,16 @@ impl TurnHost for GoTurnHost {
..Default::default()
};
}
+ // Mémoire longue de la conversation (lot 46) : l'archive est au Go,
+ // l'id, le score et la mise en forme ici ; Go injoignable : par lui.
+ if crate::recall::is_tool(name)
+ && let Some(result) = crate::recall::exec(&self.shared.store, name, args).await
+ {
+ return ToolOutcome {
+ result,
+ ..Default::default()
+ };
+ }
// Outils d'image (lot 45) : see_image dans le dossier de la
// discussion, web_screenshot par Playwright ; l'image relayée au
// modèle s'il la voit.
diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs
index a1ee2a7..4040d2c 100644
--- a/rust/loki-rs/src/lib.rs
+++ b/rust/loki-rs/src/lib.rs
@@ -41,6 +41,7 @@ pub mod presets_api;
pub mod projects_api;
pub mod proxy;
pub mod push;
+pub mod recall;
pub mod serve;
pub mod settings;
pub mod store;
diff --git a/rust/loki-rs/src/recall.rs b/rust/loki-rs/src/recall.rs
new file mode 100644
index 0000000..bfdadd1
--- /dev/null
+++ b/rust/loki-rs/src/recall.rs
@@ -0,0 +1,194 @@
+//! Outils `recall` et `recall_search` de l'agent (lot 46) — port de
+//! `toolRecall` / `toolRecallSearch` (`chat_recall.go`).
+//!
+//! La compaction archive verbatim les gros blocs qu'elle résume ; l'archive
+//! reste au Go (numérotée par la séquence de sa base), qui sert les blocs
+//! lisibles (`/internal/v1/recall/block`, `/internal/v1/recall/blocks`).
+//! Ici : l'id tolérant, le score par mots-clés, la mise en forme.
+
+use std::collections::BTreeSet;
+
+use serde::Deserialize;
+use serde_json::{Map, Value};
+
+use crate::store::Store;
+
+/// `recallSearchDefault`, `recallSearchMax`.
+const SEARCH_DEFAULT: usize = 5;
+const SEARCH_MAX: usize = 20;
+
+/// `recallBlock`.
+#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
+#[serde(default)]
+pub struct Block {
+ pub id: String,
+ pub seq: u64,
+ pub label: String,
+ pub role: String,
+ pub content: String,
+}
+
+/// Les outils de ce module.
+pub fn is_tool(name: &str) -> bool {
+ name == "recall" || name == "recall_search"
+}
+
+/// `recallIDToSeq` : « r7 », « R7 », « 7 », espaces tolérés.
+pub fn id_to_seq(id: &str) -> Option {
+ let s = id.trim();
+ let s = s.strip_prefix('r').unwrap_or(s);
+ let s = s.strip_prefix('R').unwrap_or(s);
+ let s = s.trim();
+ // `strconv.ParseUint(s, 10, 64)` : des chiffres seulement (ni signe).
+ if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
+ return None;
+ }
+ s.parse().ok()
+}
+
+/// `strings.ToLower` du Go : rune par rune, sans casse contextuelle.
+pub fn go_lower(s: &str) -> String {
+ s.chars()
+ .map(|c| {
+ let mut l = c.to_lowercase();
+ match (l.next(), l.next()) {
+ (Some(a), None) => a,
+ // « İ » (U+0130), seule minuscule de deux runes : `i` en Go.
+ _ => 'i',
+ }
+ })
+ .collect()
+}
+
+/// `recallTokens` : mots d'au moins deux runes, coupés sur tout ce qui n'est
+/// ni a–z, ni 0–9, ni à–ÿ.
+pub fn tokens(s: &str) -> BTreeSet {
+ go_lower(s)
+ .split(|c: char| {
+ !(c.is_ascii_lowercase() || c.is_ascii_digit() || ('à'..='ÿ').contains(&c))
+ })
+ .filter(|w| w.chars().count() >= 2)
+ .map(str::to_string)
+ .collect()
+}
+
+/// `recallSearch` : score = termes présents, le libellé comptant double ; à
+/// égalité, le plus récent d'abord.
+pub fn search(blocks: Vec, query: &str, limit: i64) -> Vec {
+ let terms = tokens(query);
+ if terms.is_empty() {
+ return Vec::new();
+ }
+ let limit = if limit <= 0 {
+ SEARCH_DEFAULT
+ } else {
+ (limit as usize).min(SEARCH_MAX)
+ };
+ let mut hits: Vec<(Block, usize)> = blocks
+ .into_iter()
+ .filter_map(|b| {
+ let label = go_lower(&b.label);
+ let body = go_lower(&b.content);
+ let score: usize = terms
+ .iter()
+ .map(|t| {
+ 2 * usize::from(label.contains(t.as_str()))
+ + usize::from(body.contains(t.as_str()))
+ })
+ .sum();
+ (score > 0).then_some((b, score))
+ })
+ .collect();
+ hits.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.seq.cmp(&a.0.seq)));
+ hits.truncate(limit);
+ hits.into_iter().map(|h| h.0).collect()
+}
+
+/// `recallSnippet` : une ligne, bornée à `n` runes.
+pub fn snippet(s: &str, n: usize) -> String {
+ let s = s.split_whitespace().collect::>().join(" ");
+ if s.chars().count() > n {
+ let head: String = s.chars().take(n).collect();
+ return format!("{}…", head.trim());
+ }
+ s
+}
+
+/// `toolRecall`, le bloc lu (`None` : absent ou illisible).
+pub fn recall_text(args: &Map, blk: Option) -> String {
+ let id = args.get("id").and_then(Value::as_str).unwrap_or("");
+ let Some(b) = blk else {
+ return format!(
+ "[erreur] bloc {} introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés",
+ crate::gojson::quote(id)
+ );
+ };
+ format!("[{} — {}, rappelé verbatim]\n{}", b.id, b.label, b.content)
+}
+
+/// `toolRecallSearch`, sur les résultats déjà triés.
+pub fn search_text(hits: &[Block]) -> String {
+ if hits.is_empty() {
+ return "[aucun bloc archivé ne correspond]".into();
+ }
+ let mut out = String::from("Blocs trouvés (rappelle le bon avec recall(id)) :\n");
+ for h in hits {
+ out += &format!("- {} — {}\n {}\n", h.id, h.label, snippet(&h.content, 160));
+ }
+ out.trim_end_matches('\n').to_string()
+}
+
+/// `limit` du Go : `args["limit"].(float64)` tronqué.
+fn arg_limit(args: &Map) -> i64 {
+ args.get("limit")
+ .and_then(Value::as_f64)
+ .map_or(0, |f| f as i64)
+}
+
+/// Exécute `recall` ou `recall_search` sur l'archive du Go. `None` : le Go
+/// n'a pas répondu (l'appel passe alors par lui).
+pub async fn exec(store: &Store, name: &str, args: &Map) -> Option {
+ match name {
+ "recall" => {
+ let id = args.get("id").and_then(Value::as_str).unwrap_or("");
+ if id.trim().is_empty() {
+ return Some("[erreur] id manquant".into());
+ }
+ let Some(seq) = id_to_seq(id) else {
+ return Some(recall_text(args, None));
+ };
+ let (status, v) = store
+ .internal_get(&format!("/internal/v1/recall/block?seq={seq}"))
+ .await
+ .ok()?;
+ let blk = match status.as_u16() {
+ 200 => serde_json::from_value(v).ok(),
+ 404 => None,
+ _ => return None,
+ };
+ Some(recall_text(args, blk))
+ }
+ "recall_search" => {
+ let q = args.get("query").and_then(Value::as_str).unwrap_or("");
+ if q.trim().is_empty() {
+ return Some("[erreur] query manquante".into());
+ }
+ if tokens(q).is_empty() {
+ return Some(search_text(&[]));
+ }
+ let (status, v) = store
+ .internal_get("/internal/v1/recall/blocks")
+ .await
+ .ok()?;
+ if status.as_u16() != 200 {
+ return None;
+ }
+ let blocks: Vec = serde_json::from_value(v).ok()?;
+ Some(search_text(&search(blocks, q, arg_limit(args))))
+ }
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/rust/loki-rs/src/recall/tests.rs b/rust/loki-rs/src/recall/tests.rs
new file mode 100644
index 0000000..ae902d4
--- /dev/null
+++ b/rust/loki-rs/src/recall/tests.rs
@@ -0,0 +1,91 @@
+//! Tests unitaires de recall (lot 46) ; la parité avec le Go est dans
+//! `tests/recall.rs`.
+
+use super::*;
+
+fn blk(seq: u64, label: &str, content: &str) -> Block {
+ Block {
+ id: format!("r{seq}"),
+ seq,
+ label: label.into(),
+ role: "tool".into(),
+ content: content.into(),
+ }
+}
+
+#[test]
+fn id_tolerant() {
+ for s in ["r7", "R7", " r7 ", "7", "rR7", "r 7", "007"] {
+ assert_eq!(id_to_seq(s), Some(7), "{s}");
+ }
+ for s in [
+ "",
+ "rr",
+ "abc",
+ "r-1",
+ "+7",
+ "r+7",
+ "7a",
+ "Rr7",
+ "99999999999999999999",
+ ] {
+ assert_eq!(id_to_seq(s), None, "{s}");
+ }
+}
+
+#[test]
+fn mots_cles() {
+ let t: Vec = tokens("Le TRAIN de 14h12, quai n°3 — Été ÿ x")
+ .into_iter()
+ .collect();
+ assert_eq!(t, ["14h12", "de", "le", "quai", "train", "été"]);
+ assert_eq!(go_lower("İSTANBUL Σ"), "istanbul σ");
+ assert!(tokens("a b ! ?").is_empty());
+}
+
+#[test]
+fn score_et_ordre() {
+ let blocks = vec![
+ blk(1, "web_read: recette", "gratin dauphinois"),
+ blk(2, "web_read: horaires", "le train part quai 3"),
+ blk(3, "bash", "train train"),
+ blk(4, "autre", "rien"),
+ ];
+ let ids: Vec = search(blocks.clone(), "train horaires", 0)
+ .into_iter()
+ .map(|b| b.id)
+ .collect();
+ assert_eq!(ids, ["r2", "r3"]);
+ // À égalité, le plus récent d'abord ; limite respectée.
+ let ids: Vec = search(blocks, "web_read", 1)
+ .into_iter()
+ .map(|b| b.id)
+ .collect();
+ assert_eq!(ids, ["r2"]);
+}
+
+#[test]
+fn extraits() {
+ assert_eq!(snippet(" a\n\tb c ", 160), "a b c");
+ assert_eq!(snippet("abcdef", 3), "abc…");
+ assert_eq!(snippet("ab cdef", 3), "ab…");
+}
+
+#[test]
+fn textes() {
+ let mut args = Map::new();
+ args.insert("id".into(), Value::from("r\"9"));
+ assert_eq!(
+ recall_text(&args, None),
+ "[erreur] bloc \"r\\\"9\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
+ );
+ assert_eq!(
+ recall_text(&args, Some(blk(9, "bash: ls", "a\nb"))),
+ "[r9 — bash: ls, rappelé verbatim]\na\nb"
+ );
+ assert_eq!(search_text(&[]), "[aucun bloc archivé ne correspond]");
+ assert_eq!(
+ search_text(&[blk(2, "x", "y z")]),
+ "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r2 — x\n y z"
+ );
+}
diff --git a/rust/loki-rs/src/store.rs b/rust/loki-rs/src/store.rs
index 8d74dc3..2727932 100644
--- a/rust/loki-rs/src/store.rs
+++ b/rust/loki-rs/src/store.rs
@@ -104,6 +104,17 @@ impl Store {
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
}
+ /// GET à l'API interne, statut compris (404 à distinguer d'une panne).
+ /// Corps vide ou illisible → `Value::Null`.
+ pub async fn internal_get(
+ &self,
+ path: &str,
+ ) -> Result<(StatusCode, serde_json::Value), String> {
+ let (status, bytes) = self.call(Method::GET, path, Vec::new()).await?;
+ let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
+ Ok((status, v))
+ }
+
/// POST JSON à l'API interne, statut compris (refus métier à distinguer :
/// 409, 410…). Corps vide ou illisible → `Value::Null`.
pub async fn internal_post(
diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs
index a97ab71..c76e841 100644
--- a/rust/loki-rs/tests/common/fakego.rs
+++ b/rust/loki-rs/tests/common/fakego.rs
@@ -52,6 +52,10 @@ pub struct FakeGo {
/// Résultat imposé à tout outil (sinon « exec: »), et archive recall.
pub tool_result: Mutex