mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
- LSP (lsp.go) : un signal resté dans le tampon par une republication tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même que le serveur ait vu la nouvelle version. Le canal est vidé, une publication d'une version antérieure est ignorée, et check attend 350 ms de calme après la dernière publication (liste vide puis analyse : c'est l'analyse qui compte), toujours borné par lspDiagWait. Au passage : interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par check) — dead devient atomique. - run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui l'effaçait (AJEAN 7403a8a0). - Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par toute écriture du bucket d'état, par la date/taille du fichier (autre process) et au bout d'une seconde. - MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil, plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8). - Recall : les blocs d'une compaction s'archivent en une seule transaction au lieu d'une par bloc (AJEAN #130). - Compactage en cours de tour : après un essai qui n'a rien réduit, pas de nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN 68659e73). - Motifs de relance : un motif vide (qui correspondait à tout) est écarté, une surcharge sans motif utilisable retombe sur l'embarqué, et un changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869, c52c5bf7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// chat_recall.go — mémoire longue de la conversation.
|
|
//
|
|
// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé
|
|
// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt
|
|
// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le
|
|
// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id
|
|
// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le
|
|
// rappeler VERBATIM quand il en a besoin :
|
|
//
|
|
// recall(id) → ramène le bloc exact, tel qu'il était.
|
|
// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très
|
|
// ancien et disparu du résumé courant.
|
|
//
|
|
// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée
|
|
// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours.
|
|
// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au
|
|
// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le
|
|
// résumé courant : sa taille reste plate même sur une conversation infinie,
|
|
// pendant que l'archive grossit tranquillement sur disque (bon marché).
|
|
//
|
|
// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou
|
|
// un diff produit tôt dans la session n'est plus perdu au compactage suivant.
|
|
|
|
const (
|
|
// Un bloc du torse plus long que ça (en caractères) est archivé et remplacé
|
|
// par son id. En dessous, le résumé le porte très bien tout seul : pas la peine
|
|
// de créer un id pour trois lignes.
|
|
recallArchiveMinLen = 800
|
|
// Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de
|
|
// quoi le résumer fidèlement, le reste étant récupérable par recall.
|
|
recallSummaryHeadLen = 400
|
|
// Nombre de résultats par défaut / max pour recall_search.
|
|
recallSearchDefault = 5
|
|
recallSearchMax = 20
|
|
)
|
|
|
|
// recallBlock est un bloc archivé, adressable par son id.
|
|
type recallBlock struct {
|
|
ID string `json:"id"` // "r<seq>"
|
|
Seq uint64 `json:"seq"` // séquence brute (tri, clé)
|
|
Label string `json:"label"` // libellé court lisible (rôle + première ligne)
|
|
Role string `json:"role"` // rôle d'origine (tool/user/assistant)
|
|
Content string `json:"content"` // contenu VERBATIM
|
|
Created int64 `json:"created"` // unix, pour un éventuel ménage futur
|
|
}
|
|
|
|
// recallKey encode une séquence en clé bbolt triable (big-endian).
|
|
func recallKey(seq uint64) []byte {
|
|
k := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(k, seq)
|
|
return k
|
|
}
|
|
|
|
// recallIDToSeq lit "r<n>" → n. Tolère un id sans préfixe et les espaces : le
|
|
// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 ").
|
|
func recallIDToSeq(id string) (uint64, bool) {
|
|
s := strings.TrimSpace(id)
|
|
s = strings.TrimPrefix(s, "r")
|
|
s = strings.TrimPrefix(s, "R")
|
|
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
|
|
type recallArchiveInput struct {
|
|
Label string
|
|
Role string
|
|
Content string
|
|
}
|
|
|
|
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
|
|
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
|
|
// sont alloués par NextSequence dans la transaction : monotones, sans
|
|
// collision, même si deux compactages se croisent. Une transaction par bloc
|
|
// payait une ouverture de la base et une synchronisation disque chacune, sous
|
|
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
|
|
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
|
|
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
|
|
// classique (troncature sans id).
|
|
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
|
|
if len(blocks) == 0 {
|
|
return nil, nil
|
|
}
|
|
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
|
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
|
encode, err := memEncoderNow()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().Unix()
|
|
var entries []recallEntry
|
|
err = withDB(func(d *bolt.DB) error {
|
|
return d.Update(func(tx *bolt.Tx) error {
|
|
entries = entries[:0]
|
|
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, in := range blocks {
|
|
seq, err := b.NextSequence()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
blk := recallBlock{
|
|
ID: "r" + strconv.FormatUint(seq, 10),
|
|
Seq: seq,
|
|
Label: in.Label,
|
|
Role: in.Role,
|
|
Content: in.Content,
|
|
Created: now,
|
|
}
|
|
raw, err := json.Marshal(blk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Un bloc archivé, c'est du verbatim de conversation : chiffré
|
|
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
|
|
// inchangé si le chiffrement est inactif ; verrouillé,
|
|
// memEncoderNow a déjà refusé plus haut).
|
|
enc, err := encode(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := b.Put(recallKey(seq), enc); err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cacheBust(bkRecall)
|
|
return entries, nil
|
|
}
|
|
|
|
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
|
|
func archiveRecallBlock(label, role, content string) (string, error) {
|
|
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return e[0].id, nil
|
|
}
|
|
|
|
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
|
func recallGet(id string) (recallBlock, bool) {
|
|
seq, ok := recallIDToSeq(id)
|
|
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 {
|
|
v := b.Get(recallKey(seq))
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
dec, err := decodeMemContent(v)
|
|
if err != nil {
|
|
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
|
}
|
|
found = json.Unmarshal(dec, &blk) == nil
|
|
return nil
|
|
})
|
|
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).
|
|
// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du
|
|
// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante).
|
|
func recallSearch(query string, limit int) []recallBlock {
|
|
terms := recallTokens(query)
|
|
if len(terms) == 0 {
|
|
return nil
|
|
}
|
|
if limit <= 0 {
|
|
limit = recallSearchDefault
|
|
}
|
|
if limit > recallSearchMax {
|
|
limit = recallSearchMax
|
|
}
|
|
type scored struct {
|
|
blk recallBlock
|
|
score int
|
|
}
|
|
var hits []scored
|
|
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 strings.Contains(body, t) {
|
|
score++
|
|
}
|
|
}
|
|
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
|
|
}
|
|
return hits[i].blk.Seq > hits[j].blk.Seq
|
|
})
|
|
if len(hits) > limit {
|
|
hits = hits[:limit]
|
|
}
|
|
out := make([]recallBlock, len(hits))
|
|
for i, h := range hits {
|
|
out[i] = h.blk
|
|
}
|
|
return out
|
|
}
|
|
|
|
// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2
|
|
// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre.
|
|
func recallTokens(s string) map[string]struct{} {
|
|
out := map[string]struct{}{}
|
|
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
|
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ')
|
|
}) {
|
|
if len([]rune(w)) >= 2 {
|
|
out[w] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son
|
|
// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne.
|
|
func recallLabel(role, toolName, content string) string {
|
|
head := ""
|
|
for _, line := range strings.Split(content, "\n") {
|
|
if s := strings.TrimSpace(line); s != "" {
|
|
head = s
|
|
break
|
|
}
|
|
}
|
|
if r := []rune(head); len(r) > 70 {
|
|
head = strings.TrimSpace(string(r[:70])) + "…"
|
|
}
|
|
tag := role
|
|
if role == "tool" && toolName != "" {
|
|
tag = toolName
|
|
}
|
|
if head == "" {
|
|
return tag
|
|
}
|
|
return tag + ": " + head
|
|
}
|
|
|
|
// toolRecall ramène un bloc archivé pour le modèle (outil recall).
|
|
func toolRecall(args map[string]any) string {
|
|
id, _ := args["id"].(string)
|
|
if strings.TrimSpace(id) == "" {
|
|
return "[erreur] id manquant"
|
|
}
|
|
blk, ok := recallGet(id)
|
|
if !ok {
|
|
return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id)
|
|
}
|
|
return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content)
|
|
}
|
|
|
|
// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search).
|
|
func toolRecallSearch(args map[string]any) string {
|
|
q, _ := args["query"].(string)
|
|
if strings.TrimSpace(q) == "" {
|
|
return "[erreur] query manquante"
|
|
}
|
|
lim := 0
|
|
if v, ok := args["limit"].(float64); ok {
|
|
lim = int(v)
|
|
}
|
|
hits := recallSearch(q, lim)
|
|
if len(hits) == 0 {
|
|
return "[aucun bloc archivé ne correspond]"
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n")
|
|
for _, h := range hits {
|
|
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160))
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche.
|
|
func recallSnippet(s string, n int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if r := []rune(s); len(r) > n {
|
|
return strings.TrimSpace(string(r[:n])) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent.
|
|
func recallTool() Tool {
|
|
return Tool{
|
|
Type: "function",
|
|
Function: ToolFunction{
|
|
Name: "recall",
|
|
Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."},
|
|
},
|
|
"required": []string{"id"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func recallSearchTool() Tool {
|
|
return Tool{
|
|
Type: "function",
|
|
Function: ToolFunction{
|
|
Name: "recall_search",
|
|
Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]any{"type": "string", "description": "Keywords describing the block."},
|
|
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
}
|
|
}
|