Files
Loki/internal/loki/lsp.go
T
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
2026-08-20 09:31:59 +00:00

417 lines
10 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"
"strings"
"sync"
"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
)
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
diagCh chan string // uri fraîchement publié
lastUsed time.Time
dead 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 ""
}
if len(diags) > lspMaxDiags {
diags = diags[:lspMaxDiags]
}
var b strings.Builder
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 {
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{},
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 {
return nil, false
}
s.lastUsed = time.Now()
content, err := os.ReadFile(absPath)
if err != nil {
return nil, false
}
uri := fileURI(absPath)
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
s.diagMu.Lock()
delete(s.diags, uri)
s.diagMu.Unlock()
v, open := s.version[uri]
if !open {
s.version[uri] = 1
s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": s.lang, "version": 1, "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)}},
})
}
// Attend la publication pour CET uri, bornée.
deadline := time.After(lspDiagWait)
for {
select {
case got := <-s.diagCh:
if got != uri {
continue
}
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
return d, true
case <-deadline:
// Pas de publication à temps : certains serveurs ne publient rien
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
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"`
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
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
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.mu.Lock()
s.dead = true
s.mu.Unlock()
}
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).