Files
Loki/internal/loki/lsp.go
T
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- 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>
2026-10-16 15:05:04 +02:00

505 lines
14 KiB
Go

package loki
// lsp.go — client LSP minimal pour le VÉRIFICATEUR DE CODE : après chaque
// write/edit en mode code, les diagnostics du serveur de langage (erreurs de
// compilation, types, imports) sont ajoutés AU RÉSULTAT DE L'OUTIL — le modèle
// apprend immédiatement que son code ne compile pas, sans lancer de build.
// Repris de l'intégration LSP d'OpenFox (manager + languages.json — voir
// NOTICE.md), réécrit en Go réduit à l'essentiel : initialize, didOpen/
// didChange, publishDiagnostics. Pas de complétion, pas de hover — Loki n'est
// pas un éditeur.
//
// La table langage → serveur vit dans lsp_languages.json (embarquée). Un
// serveur absent du PATH désactive silencieusement son langage : le mode code
// marche sans LSP, il vérifie juste moins tôt.
import (
"bufio"
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
//go:embed lsp_languages.json
var lspLanguagesJSON []byte
type lspLanguage struct {
ID string `json:"id"`
Command []string `json:"command"`
Extensions []string `json:"extensions"`
}
var (
lspLangsOnce sync.Once
lspLangs []lspLanguage
)
func lspLanguages() []lspLanguage {
lspLangsOnce.Do(func() {
_ = json.Unmarshal(lspLanguagesJSON, &lspLangs)
})
return lspLangs
}
// lspLangFor renvoie la config du langage d'un fichier, nil si aucun.
func lspLangFor(path string) *lspLanguage {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return nil
}
for i := range lspLanguages() {
for _, e := range lspLangs[i].Extensions {
if e == ext {
return &lspLangs[i]
}
}
}
return nil
}
// --- Serveur -----------------------------------------------------------------
const (
lspDiagWait = 2500 * time.Millisecond // attente max des diagnostics
lspIdleTimeout = 5 * time.Minute // arrêt d'un serveur inactif
lspMaxDiags = 10 // diagnostics montrés au modèle
)
// lspDiagQuiet : calme attendu après une publication avant de la croire
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
// les tests.
var lspDiagQuiet = 350 * time.Millisecond
type lspServer struct {
lang string
root string
cmd *exec.Cmd
stdin io.WriteCloser
mu sync.Mutex // sérialise les requêtes (un didOpen+attente à la fois)
nextID int
version map[string]int // uri → version de document
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
diagMu sync.Mutex
diags map[string][]lspDiagnostic
// diagVer : version de document annoncée par la dernière publication de
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
diagVer map[string]int
diagCh chan string // uri fraîchement publié
lastUsed time.Time
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
dead atomic.Bool
}
type lspDiagnostic struct {
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
Severity int `json:"severity"`
Message string `json:"message"`
Source string `json:"source"`
}
var (
lspMu sync.Mutex
lspServers = map[string]*lspServer{} // clé : lang + racine
)
// lspDiagBlock : le bloc à AJOUTER au résultat d'un write/edit réussi.
// "" si pas de mode code, pas de serveur pour ce langage, ou aucun diagnostic.
func lspDiagBlock(absPath string, caps Caps) string {
if !caps.Code {
return ""
}
diags := lspDiagnosticsFor(absPath)
if diags == "" {
return ""
}
return "\n\n[diagnostics LSP]\n" + diags
}
// lspDiagnosticsFor ouvre (ou met à jour) le document auprès du serveur de son
// langage et attend ses diagnostics. Best-effort borné : au pire lspDiagWait.
func lspDiagnosticsFor(absPath string) string {
lang := lspLangFor(absPath)
if lang == nil {
return ""
}
if _, err := exec.LookPath(lang.Command[0]); err != nil {
return "" // serveur non installé : le mode code marche sans
}
root := agentCwd()
// Le dépôt cloné est la vraie racine projet quand le fichier est dedans :
// gopls a besoin du go.mod, tsserver du tsconfig.
if r := projectRootFor(absPath, root); r != "" {
root = r
}
srv, err := lspGet(lang, root)
if err != nil {
return ""
}
diags, ok := srv.check(absPath)
if !ok {
return ""
}
if len(diags) == 0 {
return ""
}
// Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà
// de lspMaxDiags on tronque, et des indices de style passaient devant
// l'erreur de compilation qui comptait. Le compte annonce le total.
rank := func(s int) int {
if s <= 0 {
return 1 // sévérité absente : traitée comme une erreur
}
return s
}
sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) })
nErr, nWarn := 0, 0
for _, d := range diags {
switch rank(d.Severity) {
case 1:
nErr++
case 2:
nWarn++
}
}
total := len(diags)
if len(diags) > lspMaxDiags {
diags = diags[:lspMaxDiags]
}
var b strings.Builder
fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn)
if total > len(diags) {
fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total)
}
b.WriteString("\n")
for _, d := range diags {
sev := "info"
switch d.Severity {
case 1:
sev = "error"
case 2:
sev = "warning"
}
fmt.Fprintf(&b, "%d:%d [%s] %s\n", d.Range.Start.Line+1, d.Range.Start.Character+1, sev, strings.TrimSpace(d.Message))
}
return strings.TrimRight(b.String(), "\n")
}
// projectRootFor remonte du fichier vers ws en cherchant un marqueur de racine
// de projet. Renvoie "" si rien trouvé au-dessus du fichier (dans la borne ws).
func projectRootFor(absPath, ws string) string {
dir := filepath.Dir(absPath)
for {
for _, marker := range []string{"go.mod", "package.json", "pyproject.toml", ".git"} {
if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
return dir
}
}
if rel, ok := relWithin(ws, dir); !ok || rel == "" || rel == "." {
return ""
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// lspGet renvoie le serveur (lang, racine), démarré au besoin.
func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
key := lang.ID + "\x00" + root
lspMu.Lock()
defer lspMu.Unlock()
if s := lspServers[key]; s != nil && !s.dead.Load() {
return s, nil
}
s, err := lspStart(lang, root)
if err != nil {
return nil, err
}
lspServers[key] = s
return s, nil
}
func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
cmd := exec.Command(lang.Command[0], lang.Command[1:]...)
cmd.Dir = root
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
return nil, err
}
s := &lspServer{
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(stdout)
go s.idleWatch()
// initialize / initialized. La réponse est consommée par readLoop ; on
// n'attend pas les capacités — on n'utilise que les diagnostics.
s.send("initialize", map[string]any{
"processId": nil,
"rootUri": fileURI(root),
"capabilities": map[string]any{
"textDocument": map[string]any{
"publishDiagnostics": map[string]any{},
"synchronization": map[string]any{"didSave": false},
},
},
"workspaceFolders": []map[string]any{{"uri": fileURI(root), "name": filepath.Base(root)}},
}, true)
s.notify("initialized", map[string]any{})
return s, nil
}
// check ouvre (ou met à jour) le document et attend ses diagnostics.
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dead.Load() {
return nil, false
}
s.lastUsed = time.Now()
content, err := os.ReadFile(absPath)
if err != nil {
return nil, false
}
uri := fileURI(absPath)
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
// que le serveur ait vu la nouvelle version.
for drained := false; !drained; {
select {
case <-s.diagCh:
default:
drained = true
}
}
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
s.diagMu.Lock()
delete(s.diags, uri)
delete(s.diagVer, uri)
s.diagMu.Unlock()
v, open := s.version[uri]
if !open {
v = 1
s.version[uri] = v
s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
},
})
} else {
v++
s.version[uri] = v
s.notify("textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": uri, "version": v},
"contentChanges": []map[string]any{{"text": string(content)}},
})
}
if s.dead.Load() {
return nil, false // écriture ratée : le serveur ne verra jamais cette version
}
// current : les diagnostics publiés pour CETTE version (ou sans version),
// pas ceux d'une version antérieure arrivés en retard.
current := func() ([]lspDiagnostic, bool) {
s.diagMu.Lock()
defer s.diagMu.Unlock()
d, ok := s.diags[uri]
if !ok {
return nil, false
}
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
return nil, false
}
return d, true
}
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
// nouvelle publication ; le tout borné par lspDiagWait.
deadline := time.NewTimer(lspDiagWait)
defer deadline.Stop()
var quiet <-chan time.Time
var quietT *time.Timer
defer func() {
if quietT != nil {
quietT.Stop()
}
}()
for {
select {
case got := <-s.diagCh:
if got != uri {
continue
}
if _, ok := current(); !ok {
continue // publication d'une version antérieure
}
if quietT == nil {
quietT = time.NewTimer(lspDiagQuiet)
} else {
quietT.Reset(lspDiagQuiet)
}
quiet = quietT.C
case <-quiet:
d, _ := current()
return d, true
case <-deadline.C:
// Pas de publication à temps : certains serveurs ne publient rien
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
d, _ := current()
return d, true
}
}
}
// readLoop parse les messages LSP (en-têtes Content-Length) et garde les
// publishDiagnostics ; tout le reste est ignoré.
func (s *lspServer) readLoop(r io.Reader) {
br := bufio.NewReaderSize(r, 1<<20)
for {
length := 0
for {
line, err := br.ReadString('\n')
if err != nil {
s.markDead()
return
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
fmt.Sscanf(strings.TrimSpace(v), "%d", &length)
}
}
if length <= 0 || length > 32<<20 {
s.markDead()
return
}
buf := make([]byte, length)
if _, err := io.ReadFull(br, buf); err != nil {
s.markDead()
return
}
var msg struct {
Method string `json:"method"`
Params struct {
URI string `json:"uri"`
Version *int `json:"version"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
} `json:"params"`
}
if json.Unmarshal(buf, &msg) != nil {
continue
}
if msg.Method == "textDocument/publishDiagnostics" {
s.diagMu.Lock()
s.diags[msg.Params.URI] = msg.Params.Diagnostics
ver := -1
if msg.Params.Version != nil {
ver = *msg.Params.Version
}
s.diagVer[msg.Params.URI] = ver
s.diagMu.Unlock()
select {
case s.diagCh <- msg.Params.URI:
default: // personne n'attend : la publication reste dans s.diags
}
}
}
}
func (s *lspServer) idleWatch() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
s.mu.Lock()
idle := time.Since(s.lastUsed) > lspIdleTimeout
dead := s.dead.Load()
s.mu.Unlock()
if dead {
return
}
if idle {
s.shutdown()
return
}
}
}
func (s *lspServer) shutdown() {
s.markDead()
if s.cmd.Process != nil {
// shutdown/exit protocolaires seraient plus polis, mais le process peut
// être déjà figé — kill borné est fiable et le serveur n'a pas d'état.
_ = s.cmd.Process.Kill()
}
lspMu.Lock()
delete(lspServers, s.lang+"\x00"+s.root)
lspMu.Unlock()
}
func (s *lspServer) markDead() { s.dead.Store(true) }
func (s *lspServer) send(method string, params any, isRequest bool) {
s.nextID++
msg := map[string]any{"jsonrpc": "2.0", "method": method, "params": params}
if isRequest {
msg["id"] = s.nextID
}
s.writeMsg(msg)
}
func (s *lspServer) notify(method string, params any) { s.send(method, params, false) }
func (s *lspServer) writeMsg(msg map[string]any) {
b, err := json.Marshal(msg)
if err != nil {
return
}
_, err = fmt.Fprintf(s.stdin, "Content-Length: %d\r\n\r\n%s", len(b), b)
if err != nil {
s.markDead()
}
}
// fileURI convertit un chemin absolu en URI file://.
func fileURI(path string) string {
p := filepath.ToSlash(path)
if !strings.HasPrefix(p, "/") {
p = "/" + p // Windows : C:\x → /C:/x
}
return "file://" + p
}
// Pas d'arrêt global : les serveurs LSP sont des enfants du process loki et
// meurent avec lui (et l'inactivité de 5 min couvre le reste — idleWatch).