Mode Code : agent de code avec critères, vérification et LSP

Sélecteur Chat|Code par discussion dans le pied du composeur ; en mode
chat, une détection serveur suggère la bascule (puce ignorable, jamais
automatique). Conception reprise d'OpenFox (MIT), réécrite en Go — voir
NOTICE.md.

- Outils fichiers : read (lignes numérotées, borné), grep, glob, et le
  tracker « lu avant d'écrire » qui refuse write/edit sur un fichier non
  lu ou modifié depuis la lecture ; edit préserve les fins de ligne CRLF.
- Politique d'exécution : commandes catastrophiques refusées (rm -rf /,
  mkfs, reboot…), chemins bornés au dossier de la discussion en mode
  code, mutex par fichier.
- Critères d'acceptation : contrat posé via l'outil criteria (éditable
  dans l'UI), passe de vérification indépendante sur contexte isolé —
  seule habilitée à marquer « passed » — puis corrections plafonnées.
- Rôles embarqués (agents/*.md) : builder, planner, verifier, explorer,
  code-reviewer ; badge de rôle dans le fil.
- LSP : gopls / typescript-language-server / pyright (inclus dans
  l'image), diagnostics injectés dans le retour de write/edit.
- Git natif : git_status, git_diff, git_clone (borné à la discussion).
- Jobs d'arrière-plan bash_bg/bash_tail (serveur de dev, build long).
- Auto-retry : un appel d'outil écrit en texte (default_api:…,
  <tool_call>…) relance le tour une fois avec consigne corrective.
- Outil ask : question à choix rendue en carte à boutons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-18 21:58:57 +02:00
1 parent c6cc7d300b
commit 901d518f70
39 files changed
+3202 -8

No files matched your search

+9
View File
@@ -23,6 +23,9 @@ COPY cmd/ cmd/
COPY internal/ internal/
COPY tools/ tools/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
RUN GOBIN=/out go install golang.org/x/tools/gopls@latest
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -43,6 +46,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
@@ -71,6 +79,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
fi
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
+8
View File
@@ -19,6 +19,14 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
pour que l'UI puisse redémarrer le moteur (changement de modèle).
- **`loki config get/set`** : lecture/écriture non interactive de la
configuration, utilisée par l'entrypoint Docker.
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
code avec critères d'acceptation, passe de vérification indépendante,
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales.
## Services externes
+30
View File
@@ -138,6 +138,16 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
redémarrage de l'array). Vérifie avec la commande `docker inspect` ci-dessus :
il doit y avoir une ligne `… → /data` **et** une `… → /models`. S'il n'y en a
pas, ton conteneur a été lancé sans mapping (template Docker Unraid incomplet,
`docker run` sans `-v`) — recrée-le avec les volumes du compose fourni. Depuis
cette version, Loki le détecte au démarrage : bandeau rouge dans l'UI et
avertissement en tête du journal du conteneur.
Autre piège au redémarrage du serveur : Docker relance les conteneurs
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
@@ -162,6 +172,26 @@ Héritées d'AJEAN :
Ajoutées par ce fork :
- **Mode Code** : chaque discussion a un sélecteur **Chat | Code** dans le pied
de la carte de saisie. En mode Code, Loki devient un agent de code : outils
`read`/`grep`/`glob` (lecture bornée, numéros de ligne — et un fichier doit
avoir été LU avant d'être modifié), outils git (`git_status`, `git_diff`,
`git_clone` — le clone atterrit dans le dossier de la discussion), jobs
d'arrière-plan (`bash_bg`/`bash_tail` pour un serveur de dev ou un build
long), et **critères d'acceptation** : le modèle pose le contrat (2-6
critères testables, éditables dans le panneau au-dessus de la saisie), puis
une **passe de vérification indépendante** — même modèle, contexte isolé,
seule habilitée à marquer un critère « passé » — contrôle le diff et relance
la correction jusqu'à ce que tout passe (2 corrections max). Les fichiers
modifiés passent au **LSP** (gopls, typescript-language-server, pyright —
inclus dans l'image) : les erreurs de compilation reviennent dans le résultat
de l'outil, sans lancer de build. Sécurité : commandes catastrophiques
refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la
discussion en mode Code. En mode Chat, un message qui ressemble à une tâche
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
+13
View File
@@ -0,0 +1,13 @@
You are in CODE MODE: an autonomous coding agent working in this discussion's folder.
Method — in this order:
1. If the task has no acceptance criteria yet, set them FIRST with the criteria tool (action=add): 2-6 short, testable statements of what DONE means. For a trivial task (one obvious change), skip criteria and just do it.
2. Explore before you change: read the relevant files (read, grep, glob, git_status). Never edit a file you have not read.
3. Implement with edit (small patches) or write (new files). Match the style of the surrounding code.
4. Prove it: run the build/tests with bash. A change that was never run is not done.
5. Report briefly what changed and how you verified it.
Rules:
- You may NOT mark a criterion passed — the verification pass does that.
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
+5
View File
@@ -0,0 +1,5 @@
You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.
Look for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.
For each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.
+3
View File
@@ -0,0 +1,3 @@
You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.
Use read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.
+8
View File
@@ -0,0 +1,8 @@
You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.
Produce:
1. A short restatement of the goal (one sentence).
2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.
3. The implementation steps, ordered, with the files each step touches.
Explore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.
+12
View File
@@ -0,0 +1,12 @@
You are the VERIFICATION pass of code mode. You independently check the acceptance criteria against the actual code — you did not write it, trust nothing.
For each criterion still pending or failed:
1. Understand what it actually requires.
2. Check the real changes: git_diff, read the touched files, run the relevant build/test command with bash when applicable.
3. Record the verdict with the criteria tool: action=set, status=passed (satisfied) or failed (with a note saying exactly what is missing — actionable, one line).
Rules:
- Trivial or non-code criteria that are plainly satisfied: pass them without exploring.
- Only fail a criterion that genuinely misses its requirement; a different-but-valid implementation passes.
- Do NOT fix anything yourself. You only verify and report.
- Be fast: check the diff first, read only what the criteria require.
+29
View File
@@ -330,6 +330,15 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
// La détection de bascule (puce « passer en mode Code ? ») est émise après
// la bulle utilisateur, plus bas.
if caps.Code && caps.Role == "" {
caps.Role = "builder"
if wantsPlan(text) {
caps.Role = "planner"
}
}
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
@@ -344,6 +353,11 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
delta["files"] = files
}
c.appendDelta(epoch, delta)
// En mode chat, un message qui ressemble à une tâche de code fait
// apparaître la puce « passer en mode Code ? » (une fois par discussion).
if !caps.Code {
maybeCodeHint(c, epoch, text)
}
c.persist()
if temperature == 0 {
temperature = 0.7
@@ -455,6 +469,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.mu.Unlock()
}
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
case ev.Reasoning != "":
@@ -485,6 +502,18 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
stale := c.epoch != epoch
c.mu.Unlock()
// Boucle du contrat (mode code) : vérification indépendante des critères,
// corrections, re-vérification — AVANT de rendre la main. Voir
// code_verify.go. No-op hors mode code ou sans critères.
if !stale && ctx.Err() == nil {
c.codeVerifyLoop(ctx, caps, temperature, epoch)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
stale = c.epoch != epoch
c.mu.Unlock()
}
// Compaction de FIN DE TOUR. C'était LE trou : le seuil n'était testé qu'au
// DÉBUT d'un tour, avec le contexte du tour précédent. Le tour qui fait
// franchir le seuil se termine donc à 81% et… rien. La jauge reste haute, seul
+5
View File
@@ -232,6 +232,11 @@ func convDelete(id string) error {
}
convIndexSave(next)
_ = putBytes(bkChat, convKey(id), nil)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
stopConvJobs(id)
critDrop(id)
dropConvMode(id)
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
+35 -3
View File
@@ -44,6 +44,18 @@ func baseSystemPrompt(caps Caps) string {
// « Loki » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis loki »).
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
// de l'identité. Court par construction (voir code_roles.go) — la règle
// « ne pas regonfler » vaut aussi pour lui.
if caps.Code {
role := caps.Role
if role == "" {
role = "builder"
}
if rp := rolePrompt(role); rp != "" {
b.WriteString("\n\n" + rp)
}
}
if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
@@ -258,6 +270,10 @@ func fileWrite(path, content string) string {
return "[erreur] chemin vide"
}
path = resolveAgentPath(path)
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
defer mu.Unlock()
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "[erreur] " + err.Error()
@@ -292,16 +308,28 @@ func fileEdit(path, oldText, newText string) string {
return "[erreur] old vide"
}
path = resolveAgentPath(path)
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
defer mu.Unlock()
b, err := os.ReadFile(path)
if err != nil {
return "[erreur] " + err.Error()
}
content := string(b)
n := strings.Count(content, oldText)
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
crlf := strings.Contains(content, "\r\n")
search, oldN, newN := content, oldText, newText
if crlf && !strings.Contains(oldText, "\r\n") {
search = strings.ReplaceAll(content, "\r\n", "\n")
}
n := strings.Count(search, oldN)
if n == 0 {
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
// une erreur, sinon le modèle croit avoir échoué et recommence.
if newText != "" && strings.Contains(content, newText) {
if newN != "" && strings.Contains(search, newN) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
@@ -309,7 +337,11 @@ func fileEdit(path, oldText, newText string) string {
if n > 1 {
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(content, oldText, newText, 1)
updated := strings.Replace(search, oldN, newN, 1)
if search != content {
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
updated = strings.ReplaceAll(updated, "\n", "\r\n")
}
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
mode := os.FileMode(0o644)
if fi, err := os.Stat(path); err == nil {
+205
View File
@@ -0,0 +1,205 @@
package loki
// code_criteria.go — critères d'acceptation du mode code (contrat d'exécution,
// repris du session-metadata/criteria d'OpenFox, réécrit en Go — voir
// NOTICE.md). Les critères sont posés au début d'une tâche (par le modèle via
// l'outil `criteria`, ou à la main dans l'UI), puis la passe de vérification
// (code_verify.go) les fait passer à `passed` ou `failed`. Le tour de build ne
// se termine « vraiment » que quand tout est passed — c'est le contrat.
//
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
// la discussion, sa suppression les emporte.
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type Criterion struct {
ID int `json:"id"`
Text string `json:"text"`
Status string `json:"status"` // pending | passed | failed
Note string `json:"note,omitempty"`
}
const critMaxCount = 20
func critKey(convID string) string { return "crit:" + convID }
func critList(convID string) []Criterion {
var list []Criterion
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
_ = json.Unmarshal(b, &list)
}
return list
}
func critSave(convID string, list []Criterion) {
if len(list) == 0 {
_ = putBytes(bkChat, critKey(convID), nil) // nil supprime la clé
return
}
b, err := json.Marshal(list)
if err != nil {
return
}
_ = putBytes(bkChat, critKey(convID), b)
}
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
// critAllPassed : le contrat est rempli. Une liste vide ne compte pas comme
// remplie — il n'y a simplement pas de contrat.
func critAllPassed(list []Criterion) bool {
if len(list) == 0 {
return false
}
for _, c := range list {
if c.Status != "passed" {
return false
}
}
return true
}
func critPending(list []Criterion) int {
n := 0
for _, c := range list {
if c.Status != "passed" {
n++
}
}
return n
}
// critRender : la liste formatée pour un prompt (builder ou verifier).
func critRender(list []Criterion) string {
var b strings.Builder
for _, c := range list {
mark := "[ ]"
switch c.Status {
case "passed":
mark = "[✓]"
case "failed":
mark = "[✗]"
}
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
if c.Note != "" {
fmt.Fprintf(&b, " — %s", c.Note)
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func criteriaTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "criteria",
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
},
"required": []string{"action"},
},
},
}
}
// toolCriteria exécute l'outil. allowPass : seul le passage de VÉRIFICATION a
// le droit de marquer `passed` (sinon le builder s'auto-valide et le contrat ne
// contraint plus rien).
func toolCriteria(args map[string]any, allowPass bool) string {
convID := convEnsureActive()
action, _ := args["action"].(string)
list := critList(convID)
switch action {
case "list", "":
if len(list) == 0 {
return "[aucun critère]"
}
return critRender(list)
case "add":
raw, _ := args["texts"].([]any)
next := 1
for _, c := range list {
if c.ID >= next {
next = c.ID + 1
}
}
added := 0
for _, r := range raw {
s, ok := r.(string)
if !ok || strings.TrimSpace(s) == "" {
continue
}
if len(list) >= critMaxCount {
break
}
list = append(list, Criterion{ID: next, Text: strings.TrimSpace(s), Status: "pending"})
next++
added++
}
if added == 0 {
return "[erreur] aucun critère fourni (texts)"
}
critSave(convID, list)
critNotify(list)
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
case "set":
idf, _ := args["id"].(float64)
id := int(idf)
status, _ := args["status"].(string)
note, _ := args["note"].(string)
if status == "passed" && !allowPass {
return "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra."
}
for i := range list {
if list[i].ID != id {
continue
}
if status != "" {
list[i].Status = status
}
if note != "" {
list[i].Note = strings.TrimSpace(note)
}
critSave(convID, list)
critNotify(list)
return "[ok] critère #" + fmt.Sprint(id) + " → " + list[i].Status
}
return fmt.Sprintf("[erreur] critère #%d inconnu", id)
case "clear":
critDrop(convID)
critNotify(nil)
return "[ok] critères effacés"
}
return "[erreur] action inconnue : " + action
}
// critNotify pousse la liste à l'UI (panneau critères) via le journal de la
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
// on publie sur l'epoch courant.
func critNotify(list []Criterion) {
if conv == nil {
return
}
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
if list == nil {
list = []Criterion{}
}
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
}
+73
View File
@@ -0,0 +1,73 @@
package loki
import (
"strings"
"testing"
)
// Cycle de vie des critères : add → set → clear, avec la garde allowPass.
func TestCriteresCycleDeVie(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
out := toolCriteria(map[string]any{"action": "add", "texts": []any{"le test passe", "le build compile"}}, false)
if !strings.Contains(out, "2 critère(s)") {
t.Fatalf("ajout raté : %s", out)
}
list := critList(id)
if len(list) != 2 || list[0].ID != 1 || list[1].ID != 2 {
t.Fatalf("liste inattendue : %+v", list)
}
// Le builder (allowPass=false) ne peut PAS s'auto-valider.
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, false)
if !strings.Contains(out, "[refusé]") {
t.Fatalf("auto-validation acceptée : %s", out)
}
// Le vérificateur (allowPass=true), si.
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, true)
if !strings.Contains(out, "passed") || strings.Contains(out, "[refusé]") {
t.Fatalf("validation vérificateur refusée : %s", out)
}
// failed reste ouvert à tous (le builder peut constater un échec).
out = toolCriteria(map[string]any{"action": "set", "id": float64(2), "status": "failed", "note": "le build casse"}, false)
if !strings.Contains(out, "failed") {
t.Fatalf("échec non enregistré : %s", out)
}
list = critList(id)
if critAllPassed(list) {
t.Fatal("allPassed avec un failed")
}
if critPending(list) != 1 {
t.Fatalf("pending = %d, attendu 1", critPending(list))
}
// Rendu : les statuts sont visibles.
r := critRender(list)
if !strings.Contains(r, "[✓] #1") || !strings.Contains(r, "[✗] #2") || !strings.Contains(r, "le build casse") {
t.Fatalf("rendu incomplet :\n%s", r)
}
toolCriteria(map[string]any{"action": "clear"}, false)
if len(critList(id)) != 0 {
t.Fatal("clear sans effet")
}
}
// Une liste vide n'est pas un contrat rempli.
func TestContratVide(t *testing.T) {
if critAllPassed(nil) {
t.Fatal("liste vide considérée comme remplie")
}
}
// Le plafond de critères tient.
func TestCriteresPlafond(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
var texts []any
for i := 0; i < critMaxCount+10; i++ {
texts = append(texts, "critère")
}
toolCriteria(map[string]any{"action": "add", "texts": texts}, false)
if n := len(critList(id)); n != critMaxCount {
t.Fatalf("%d critères, plafond %d", n, critMaxCount)
}
}
+149
View File
@@ -0,0 +1,149 @@
package loki
// code_git.go — outils git natifs du mode code : git_status, git_diff,
// git_clone. Le modèle les avait via bash, mais des outils dédiés donnent une
// sortie bornée, un libellé propre dans le fil, et un clone qui atterrit
// TOUJOURS dans le dossier de la discussion (jamais ailleurs sur le disque).
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
const gitMaxOutput = 8000
func gitStatusTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_status",
Description: "git status of the working folder (branch + changed files, porcelain format).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
}
}
func gitDiffTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_diff",
Description: "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
},
},
},
}
}
func gitCloneTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_clone",
Description: "Clone a git repository (https:// or git@) INTO the working folder of this discussion.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "Repository URL"},
"dir": map[string]any{"type": "string", "description": "Target folder name (default: repo name)"},
},
"required": []string{"url"},
},
},
}
}
// runGit exécute git dans le dossier de la discussion, sortie bornée.
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
if _, err := exec.LookPath("git"); err != nil {
return "[erreur] git n'est pas installé sur cette machine"
}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, "git", args...)
cmd.Dir = convWorkspace()
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
out, err := cmd.CombinedOutput()
s := tailRunes(strings.TrimSpace(string(out)), gitMaxOutput)
if cctx.Err() == context.DeadlineExceeded {
return "[timeout] git " + strings.Join(args, " ")
}
if err != nil {
if s == "" {
s = err.Error()
}
return "[erreur] " + s
}
if s == "" {
s = "(aucune sortie)"
}
return s
}
func toolGitStatus(ctx context.Context) string {
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
}
func toolGitDiff(ctx context.Context, args map[string]any) string {
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
gitArgs := []string{"diff", "HEAD"}
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
gitArgs = append(gitArgs, "--", file)
}
out := runGit(ctx, 30*time.Second, gitArgs...)
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
fallback := []string{"diff"}
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
fallback = append(fallback, "--", file)
}
out = runGit(ctx, 30*time.Second, fallback...)
}
return out
}
// gitURLRe : https:// ou git@hôte: — pas de chemins locaux ni de protocoles
// exotiques (file://, ext:: qui exécute une commande arbitraire).
var gitURLRe = regexp.MustCompile(`^(https://[\w.\-]+(:\d+)?/|git@[\w.\-]+:)[\w.\-~/]+$`)
func toolGitClone(ctx context.Context, args map[string]any) string {
url, _ := args["url"].(string)
url = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(url), ".git")) + ".git"
if !gitURLRe.MatchString(strings.TrimSuffix(url, ".git")) && !gitURLRe.MatchString(url) {
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
}
dir, _ := args["dir"].(string)
dir = strings.TrimSpace(dir)
if dir == "" {
base := filepath.Base(strings.TrimSuffix(url, ".git"))
dir = base
}
// Le clone atterrit DANS la discussion, quoi qu'il arrive.
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
return "[erreur] dossier cible invalide"
}
target := filepath.Join(convWorkspace(), dir)
if _, err := os.Stat(target); err == nil {
return "[erreur] le dossier " + dir + " existe déjà"
}
out := runGit(ctx, 5*time.Minute, "clone", "--", url, target)
if strings.HasPrefix(out, "[erreur]") || strings.HasPrefix(out, "[timeout]") {
return out
}
return fmt.Sprintf("[ok] cloné dans %s/\n%s", dir, out)
}
+191
View File
@@ -0,0 +1,191 @@
package loki
// code_jobs.go — commandes shell d'ARRIÈRE-PLAN pour le mode code : bash_bg
// lance un process détaché (serveur de dev, watcher, build long) et rend la
// main tout de suite ; bash_tail relit sa sortie et son état. Sans ça, le
// modèle lance `./serveur &` dans bash et le tour reste suspendu aux tubes
// (voir le commentaire WaitDelay de runShell).
//
// La sortie va dans un fichier du dossier de la discussion
// (.loki/jobs/<id>.log) : elle survit au tour, et le panneau Fichiers la montre.
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type bgJob struct {
ID string
Command string
LogPath string
Cmd *exec.Cmd
Started time.Time
mu sync.Mutex
done bool
exit int
}
var (
jobsMu sync.Mutex
jobs = map[string]*bgJob{}
jobSeq int
)
func bashBgTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "bash_bg",
Description: "Start a shell command in the BACKGROUND (dev server, watcher, long build) and return immediately with a job id. Output goes to a log file; check it with bash_tail. Use this instead of appending '&' in bash.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "The command"},
},
"required": []string{"command"},
},
},
}
}
func bashTailTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "bash_tail",
Description: "Read the last lines of a background job's output and its status (running/exited). kill:true stops the job.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "Job id from bash_bg"},
"lines": map[string]any{"type": "integer", "description": "Lines to show (default 40, max 200)"},
"kill": map[string]any{"type": "boolean", "description": "Stop the job"},
},
"required": []string{"id"},
},
},
}
}
// toolBashBg lance la commande détachée. Même politique que bash (commandes
// dangereuses refusées), même dossier de départ (discussion courante).
func toolBashBg(args map[string]any) string {
command, _ := args["command"].(string)
if strings.TrimSpace(command) == "" {
return "[erreur] commande vide"
}
if reason := dangerousCommand(command); reason != "" {
return refusedCommandResult(reason)
}
ws := convWorkspace()
logDir := filepath.Join(ws, ".loki", "jobs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
return "[erreur] " + err.Error()
}
jobsMu.Lock()
jobSeq++
id := fmt.Sprintf("job%d", jobSeq)
jobsMu.Unlock()
logPath := filepath.Join(logDir, id+".log")
logFile, err := os.Create(logPath)
if err != nil {
return "[erreur] " + err.Error()
}
// Contexte de FOND, pas celui du tour : le job doit survivre à la fin du
// tour — c'est sa raison d'être. Il s'arrête par bash_tail kill:true, ou
// avec le conteneur.
cmd := newShellCmdDetached(command)
cmd.Dir = ws
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
return "[erreur] " + err.Error()
}
j := &bgJob{ID: id, Command: command, LogPath: logPath, Cmd: cmd, Started: time.Now()}
jobsMu.Lock()
jobs[id] = j
jobsMu.Unlock()
go func() {
err := cmd.Wait()
logFile.Close()
j.mu.Lock()
j.done = true
if ee, ok := err.(*exec.ExitError); ok {
j.exit = ee.ExitCode()
}
j.mu.Unlock()
}()
return fmt.Sprintf("[ok] job %s lancé (pid %d) — sortie dans .loki/jobs/%s.log, suis-la avec bash_tail", id, cmd.Process.Pid, id)
}
// toolBashTail relit la fin du journal d'un job et son état.
func toolBashTail(args map[string]any) string {
id, _ := args["id"].(string)
jobsMu.Lock()
j := jobs[id]
jobsMu.Unlock()
if j == nil {
return "[erreur] job inconnu : " + id
}
if kill, _ := args["kill"].(bool); kill {
j.mu.Lock()
running := !j.done
j.mu.Unlock()
if running && j.Cmd.Process != nil {
_ = j.Cmd.Process.Kill()
}
}
lines := 40
if v, ok := args["lines"].(float64); ok && int(v) > 0 {
lines = int(v)
}
if lines > 200 {
lines = 200
}
b, err := os.ReadFile(j.LogPath)
if err != nil {
return "[erreur] " + err.Error()
}
all := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
j.mu.Lock()
status := "en cours"
if j.done {
status = fmt.Sprintf("terminé (exit %d)", j.exit)
}
j.mu.Unlock()
out := fmt.Sprintf("job %s — %s — depuis %s\ncommande : %s\n", j.ID, status, time.Since(j.Started).Round(time.Second), j.Command)
tail := strings.Join(all, "\n")
if strings.TrimSpace(tail) == "" {
tail = "(aucune sortie pour l'instant)"
}
return out + "\n" + tailRunes(tail, toolMaxOutput)
}
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
// discussion : ses fichiers partent, ses process aussi).
func stopConvJobs(convID string) {
prefix := filepath.Join(agentWorkspace(), convFilesRoot, safeConvID(convID))
jobsMu.Lock()
defer jobsMu.Unlock()
for id, j := range jobs {
if strings.HasPrefix(j.LogPath, prefix) {
j.mu.Lock()
running := !j.done
j.mu.Unlock()
if running && j.Cmd.Process != nil {
_ = j.Cmd.Process.Kill()
}
delete(jobs, id)
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package loki
// code_mode.go — le mode Chat/Code d'une discussion. Bascule EXPLICITE
// (sélecteur dans le pied du composeur), persistée par discussion ; en mode
// chat, une détection côté serveur peut SUGGÉRER la bascule (puce ignorable
// dans l'UI) mais ne l'impose jamais — un faux positif coûterait des tours de
// planification/vérification sur un simple bonjour.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
func modeKey(convID string) string { return "mode:" + convID }
func hintKey(convID string) string { return "hinted:" + convID }
// convCodeMode : la discussion active est-elle en mode code ?
func convCodeMode() bool { return getStr(bkChat, modeKey(convEnsureActive())) == "code" }
func setConvMode(convID, mode string) error {
if mode != "code" {
mode = "" // chat = défaut = clé absente
}
return putStr(bkChat, modeKey(convID), mode)
}
// dropConvMode : appelé à la suppression d'une discussion.
func dropConvMode(convID string) {
_ = putStr(bkChat, modeKey(convID), "")
_ = putStr(bkChat, hintKey(convID), "")
}
// --- Détection « ça ressemble à du code » ------------------------------------
// Côté serveur, AUCUN token : ni consigne dans le prompt, ni appel modèle.
// Un indice suffit ; la puce de l'UI est gratuite à ignorer.
var codeHintRes = []*regexp.Regexp{
// Un bloc de code clôturé.
regexp.MustCompile("```"),
// Un chemin de fichier source.
regexp.MustCompile(`(?i)\b[\w./\\-]+\.(go|py|js|jsx|ts|tsx|rs|java|kt|c|h|cpp|hpp|cs|rb|php|swift|sh|ps1|sql|html|css|scss|vue|svelte|toml|yaml|yml|json|dockerfile|makefile)\b`),
// Verbes / vocabulaire de tâche de code.
regexp.MustCompile(`(?i)\b(corrige|implémente|implemente|refactor|refactorise|débogue|debug|compile|stack ?trace|segfault|exception|traceback|unit ?test|pull ?request|merge|commit|clone)\b`),
regexp.MustCompile(`(?i)\b(bug|erreur de compilation|tests? (échoue|cassé)|fonction|classe|module|api rest|endpoint)\b`),
}
// looksLikeCode dit si un message utilisateur ressemble à une tâche de code.
// Le dépôt git déjà cloné dans la discussion est aussi un indice fort.
func looksLikeCode(text string) bool {
if strings.TrimSpace(text) == "" {
return false
}
for _, re := range codeHintRes {
if re.MatchString(text) {
return true
}
}
return false
}
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
func convHasRepo() bool {
ws := convWorkspace()
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
return true
}
entries, err := os.ReadDir(ws)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(ws, e.Name(), ".git")); err == nil {
return true
}
}
return false
}
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
func maybeCodeHint(c *Conversation, epoch int, text string) {
if convCodeMode() || !agentEnabled() {
return
}
id := convEnsureActive()
if getBool(bkChat, hintKey(id)) {
return
}
if !looksLikeCode(text) && !convHasRepo() {
return
}
_ = putBool(bkChat, hintKey(id), true)
c.appendDelta(epoch, map[string]any{"code_hint": true})
}
// --- API ---------------------------------------------------------------------
// handleChatMode — GET : {mode, criteria} de la discussion active ;
// POST {"mode":"code"|"chat"} : bascule (et pousse le nouvel état aux abonnés).
func handleChatMode(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if body.Mode != "code" && body.Mode != "chat" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu (chat|code)"})
return
}
id := convEnsureActive()
if err := setConvMode(id, body.Mode); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
// suivre, comme le fil lui-même).
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
return
}
mode := "chat"
if convCodeMode() {
mode = "code"
}
sendJSON(w, 200, map[string]any{
"ok": true,
"mode": mode,
"criteria": critList(convEnsureActive()),
})
}
// handleChatCriteria — POST : édition manuelle des critères depuis l'UI.
// {"criteria":[{"id":1,"text":"…","status":"pending"},…]} remplace la liste.
func handleChatCriteria(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
return
}
var body struct {
Criteria []Criterion `json:"criteria"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
var clean []Criterion
next := 1
for _, c := range body.Criteria {
if strings.TrimSpace(c.Text) == "" || len(clean) >= critMaxCount {
continue
}
if c.ID <= 0 {
c.ID = next
}
if c.ID >= next {
next = c.ID + 1
}
switch c.Status {
case "pending", "passed", "failed":
default:
c.Status = "pending"
}
clean = append(clean, c)
}
critSave(convEnsureActive(), clean)
critNotify(clean)
sendJSON(w, 200, map[string]any{"ok": true, "criteria": clean})
}
+74
View File
@@ -0,0 +1,74 @@
package loki
import "testing"
// La bascule est persistée par discussion et retombe sur chat par défaut.
func TestModeParDiscussion(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
if convCodeMode() {
t.Fatal("mode code par défaut")
}
if err := setConvMode(id, "code"); err != nil {
t.Fatal(err)
}
if !convCodeMode() {
t.Fatal("bascule sans effet")
}
dropConvMode(id)
if convCodeMode() {
t.Fatal("mode code après nettoyage")
}
}
// La détection reconnaît une tâche de code, pas la conversation ordinaire.
func TestDetectionCode(t *testing.T) {
for _, s := range []string{
"corrige le bug dans main.go",
"j'ai une stack trace : Exception in thread",
"```python\nprint('x')\n```",
"implémente la fonction de tri",
"le build casse avec une erreur de compilation",
} {
if !looksLikeCode(s) {
t.Errorf("non détecté : %q", s)
}
}
for _, s := range []string{
"quelle est la capitale de l'Australie ?",
"raconte-moi une histoire",
"quel temps fait-il demain ?",
"",
} {
if looksLikeCode(s) {
t.Errorf("détecté à tort : %q", s)
}
}
}
// Les rôles embarqués existent tous, et le prompt du builder est court —
// la règle « ne pas regonfler » de baseSystemPrompt s'applique aussi ici.
func TestRolesEmbarques(t *testing.T) {
for _, r := range roleNames {
p := rolePrompt(r)
if p == "" {
t.Errorf("rôle %s : prompt vide", r)
}
if len(p) > 2500 {
t.Errorf("rôle %s : prompt trop long (%d octets)", r, len(p))
}
}
if rolePrompt("inexistant") != "" {
t.Error("rôle inconnu devrait rendre vide")
}
}
// wantsPlan : demande de plan reconnue, implémentation directe non.
func TestWantsPlan(t *testing.T) {
if !wantsPlan("Fais un plan avant de coder") {
t.Error("plan non reconnu")
}
if wantsPlan("corrige le bug maintenant") {
t.Error("plan détecté à tort")
}
}
+160
View File
@@ -0,0 +1,160 @@
package loki
// code_policy.go — politique d'exécution des outils de l'agent (repris dans
// l'esprit de tool-policy/path-security d'OpenFox, réécrit en Go — voir
// NOTICE.md).
//
// Deux gardes indépendantes :
// - dangerousCommand : liste courte de commandes CATASTROPHIQUES (effacement
// de disque, arrêt machine, fork bomb). Toujours active, mode chat compris :
// aucune de ces commandes n'a d'usage légitime lancée par un modèle. Le
// refus explique quoi faire (l'utilisateur la lance lui-même).
// - codePathAllowed : en MODE CODE, tous les chemins des outils fichiers sont
// bornés au dossier de la discussion — liens symboliques résolus des deux
// côtés, comme le panneau Fichiers (relWithin). Le mode chat garde le
// comportement historique (chemins absolus honorés) : c'est le mode code
// qui promet un bac à sable, pas l'agent généraliste.
import (
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// dangerousPatterns : motifs (insensibles à la casse) de commandes refusées.
// Volontairement ÉTROIT : viser la perte de données irréversible et l'arrêt de
// la machine, pas la moindre écriture. Un motif trop large rend l'agent
// inutilisable et pousse à tout désactiver.
var dangerousPatterns = []struct {
re *regexp.Regexp
reason string
}{
// rm -rf / (ou ~, ou une racine de lecteur) — pas un rm -rf d'un sous-dossier.
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$`), "efface la racine du disque ou le home"},
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s`), "efface la racine du disque ou le home"},
// Écriture directe sur un périphérique bloc / formatage.
{regexp.MustCompile(`(?i)\bmkfs(\.[a-z0-9]+)?\b`), "formate un système de fichiers"},
{regexp.MustCompile(`(?i)\bdd\b[^\n]*\bof=/dev/`), "écrit directement sur un périphérique disque"},
{regexp.MustCompile(`(?i)>\s*/dev/sd[a-z]\b`), "écrit directement sur un périphérique disque"},
// Arrêt / redémarrage de la machine (ici : le conteneur, donc tout Loki).
// Ancré en position de COMMANDE (début, après ; && | ou sudo) : le mot
// « reboot » dans un grep ou un texte n'est pas une commande.
{regexp.MustCompile(`(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b`), "arrête ou redémarre la machine"},
// Fork bomb classique.
{regexp.MustCompile(`:\(\)\s*\{\s*:\|:`), "fork bomb"},
// chmod/chown récursif sur la racine.
{regexp.MustCompile(`(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$`), "change récursivement les droits de la racine"},
// Windows : suppression récursive de la racine d'un lecteur, format.
{regexp.MustCompile(`(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$`), "efface la racine d'un lecteur"},
{regexp.MustCompile(`(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*`), "efface la racine d'un lecteur"},
{regexp.MustCompile(`(?i)\bformat\s+[a-z]:`), "formate un lecteur"},
}
// dangerousCommand renvoie la raison du refus si la commande correspond à un
// motif interdit, "" sinon.
func dangerousCommand(cmd string) string {
for _, p := range dangerousPatterns {
if p.re.MatchString(cmd) {
return p.reason
}
}
return ""
}
// refusedCommandResult formate le refus renvoyé au modèle : dire POURQUOI et
// QUOI FAIRE, sinon il réessaie en boucle avec des variantes.
func refusedCommandResult(reason string) string {
return "[refusé] Commande bloquée par la politique de sécurité : " + reason + ". " +
"Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
}
// codePathAllowed dit si un chemin (déjà résolu par resolveAgentPath) est DANS
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode
// code uniquement.
func codePathAllowed(abs string) bool {
ws := convWorkspace()
if ws == "" {
return false
}
abs = filepath.Clean(abs)
// Le fichier peut ne pas exister encore (write) : EvalSymlinks échouerait.
// On teste alors son ancêtre le plus proche existant — c'est LUI qui doit
// être dans le workspace, liens symboliques résolus.
probe := abs
for {
if _, err := os.Stat(probe); err == nil {
break
}
parent := filepath.Dir(probe)
if parent == probe {
break
}
probe = parent
}
if !sameOrWithin(ws, probe) {
return false
}
// L'ancêtre est dans le workspace ; le chemin FINAL ne doit pas en
// ressortir par des ".." lexicaux non encore résolus.
rel, err := filepath.Rel(ws, abs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
return true
}
// sameOrWithin : p est root lui-même, ou dedans — liens symboliques résolus
// des deux côtés (relWithin refuse « . », d'où ce cousin qui l'accepte : la
// racine du workspace est un chemin légitime pour grep/glob).
func sameOrWithin(root, p string) bool {
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if q, err := filepath.EvalSymlinks(p); err == nil {
p = q
}
rel, err := filepath.Rel(root, p)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// refusedPathResult : refus de chemin hors du bac à sable du mode code.
func refusedPathResult(path string) string {
return "[refusé] " + path + " est hors du dossier de cette discussion. " +
"En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
}
// codeWriteGuard regroupe les gardes du mode code AVANT une écriture locale
// (write/edit) : borne de chemin, puis tracker « lu avant d'écrire ».
// Renvoie "" si tout est bon, sinon le message de refus pour le modèle.
// En mode chat, aucune garde : comportement historique de l'agent.
func codeWriteGuard(caps Caps, file string, forWrite bool) string {
if !caps.Code {
return ""
}
path := resolveAgentPath(file)
if !codePathAllowed(path) {
return refusedPathResult(file)
}
return trackerCheck(path, forWrite)
}
// --- Mutex par fichier -------------------------------------------------------
// Sérialise les écritures concurrentes sur un même fichier (write/edit). Sans
// parallélisme d'outils aujourd'hui le coût est nul, mais le jour où deux tours
// ou un job d'arrière-plan écrivent le même fichier, la corruption est évitée.
var fileMuMap sync.Map // clé : chemin nettoyé → *sync.Mutex
func fileMu(path string) *sync.Mutex {
key := filepath.Clean(path)
if m, ok := fileMuMap.Load(key); ok {
return m.(*sync.Mutex)
}
m, _ := fileMuMap.LoadOrStore(key, &sync.Mutex{})
return m.(*sync.Mutex)
}
+101
View File
@@ -0,0 +1,101 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// La politique doit bloquer les commandes catastrophiques…
func TestCommandesDangereusesBloquees(t *testing.T) {
for _, cmd := range []string{
"rm -rf /",
"rm -fr / --no-preserve-root",
"sudo rm -rf /*",
"mkfs.ext4 /dev/sda1",
"dd if=/dev/zero of=/dev/sda",
"echo pwned > /dev/sda",
"shutdown -h now",
"reboot",
":(){ :|:& };:",
"format c:",
"rd /s /q C:\\",
} {
if dangerousCommand(cmd) == "" {
t.Errorf("aurait dû être bloquée : %q", cmd)
}
}
}
// … et laisser passer le travail normal, y compris les rm ciblés.
func TestCommandesNormalesAutorisees(t *testing.T) {
for _, cmd := range []string{
"ls -la",
"go test ./...",
"rm -rf node_modules",
"rm -rf ./dist",
"git rm --cached fichier.txt",
"grep -r 'reboot the router' docs/",
"npm run build",
"dd if=disque.img of=copie.img",
"echo bonjour > notes.txt",
} {
if r := dangerousCommand(cmd); r != "" {
t.Errorf("bloquée à tort (%s) : %q", r, cmd)
}
}
}
// En mode code, les chemins sont bornés au dossier de la discussion :
// dedans OK, dehors refusé, y compris par remontée de ../.
func TestCodePathBorne(t *testing.T) {
ws := withWorkspace(t)
inside := filepath.Join(ws, "src", "main.go")
if !codePathAllowed(inside) {
t.Error("chemin intérieur refusé")
}
if !codePathAllowed(filepath.Join(ws, "nouveau.txt")) {
t.Error("fichier à créer (inexistant) refusé")
}
outside := filepath.Join(filepath.Dir(ws), "ailleurs.txt")
if codePathAllowed(outside) {
t.Error("chemin extérieur accepté")
}
if codePathAllowed(filepath.Join(ws, "..", "..", "etc", "passwd")) {
t.Error("remontée ../ acceptée")
}
}
// codeWriteGuard : inactif en mode chat, actif en mode code (borne + tracker).
func TestCodeWriteGuard(t *testing.T) {
ws := withWorkspace(t)
chat := Caps{Agent: true}
code := Caps{Agent: true, Code: true}
// Mode chat : aucune garde, même hors workspace.
if msg := codeWriteGuard(chat, "/tmp/hors-discussion.txt", true); msg != "" {
t.Errorf("mode chat gardé à tort : %s", msg)
}
// Mode code : hors workspace refusé.
if msg := codeWriteGuard(code, filepath.Join(filepath.Dir(ws), "x.txt"), true); msg == "" {
t.Error("écriture hors workspace acceptée en mode code")
}
// Fichier existant jamais lu : write refusé (tracker).
target := filepath.Join(ws, "present.txt")
if err := os.WriteFile(target, []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if msg := codeWriteGuard(code, "present.txt", true); !strings.Contains(msg, "pas lu") {
t.Errorf("écrasement sans lecture accepté : %q", msg)
}
// Après lecture : autorisé.
trackerNoteRead(target)
if msg := codeWriteGuard(code, "present.txt", true); msg != "" {
t.Errorf("refusé après lecture : %s", msg)
}
// Création d'un fichier neuf : pas de lecture préalable exigée.
if msg := codeWriteGuard(code, "neuf.txt", true); msg != "" {
t.Errorf("création refusée : %s", msg)
}
}
+84
View File
@@ -0,0 +1,84 @@
package loki
// code_retry.go — filets « auto-retry » (repris des auto-retry patterns
// d'OpenFox, réécrits en Go — voir NOTICE.md) : quand le modèle termine son
// tour sur un texte qui contient un APPEL D'OUTIL TEXTUEL (halluciné, donc
// jamais exécuté), on relance le tour UNE fois avec une consigne corrective au
// lieu de laisser l'utilisateur lire un pseudo-JSON d'appel d'outil.
//
// Typique des petits modèles très quantifiés : `default_api:bash`,
// `<tool_call>{...}</tool_call>`, un bloc ```tool_code```… Le modèle VOULAIT
// agir ; il a juste raté la syntaxe du protocole. Le nudge « pensé sans agir »
// existant ne les attrapait pas : le tour a bien produit du contenu.
import (
"encoding/json"
"regexp"
"sync"
)
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
// serrés — un faux positif relancerait un tour parfaitement bon.
var defaultRetryPatterns = []string{
`<tool_call>`,
`</tool_call>`,
`<\|tool_call\|>`,
"```tool_code",
"```tool_call",
`\bdefault_api[.:]\w+`,
`\bfunctions\.\w+\s*\(`,
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
`\[TOOL_REQUEST\]`,
}
// Compilés à la première utilisation, pas à l'init du package : la surcharge
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
// choisi son rôle (CLI, serveur…).
var (
retryOnce sync.Once
retryRes []*regexp.Regexp
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
return retryRes
}
func compileRetryPatterns() []*regexp.Regexp {
pats := defaultRetryPatterns
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
if raw := getStr(bkState, "retry_patterns"); raw != "" {
var custom []string
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
pats = custom
}
}
var out []*regexp.Regexp
for _, p := range pats {
if re, err := regexp.Compile("(?mi)" + p); err == nil {
out = append(out, re)
}
}
return out
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
// écrit en toutes lettres au lieu d'être émis par le protocole.
func textualToolCall(content string) bool {
if content == "" {
return false
}
for _, re := range retryPatterns() {
if re.MatchString(content) {
return true
}
}
return false
}
// retryCorrective : le message réinjecté pour relancer le tour.
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
"Tool calls must go through the tool-call protocol, never in the answer text. " +
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
+34
View File
@@ -0,0 +1,34 @@
package loki
import "testing"
// Les appels d'outils écrits en texte doivent être détectés…
func TestAppelOutilTextuelDetecte(t *testing.T) {
for _, s := range []string{
"<tool_call>{\"name\":\"bash\"}</tool_call>",
"Je vais lancer default_api:bash pour vérifier.",
"default_api.run_shell(command='ls')",
"functions.bash({\"command\": \"ls\"})",
"```tool_code\nprint('x')\n```",
"{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}",
} {
if !textualToolCall(s) {
t.Errorf("non détecté : %q", s)
}
}
}
// … sans jamais relancer un tour normal (code cité, JSON quelconque).
func TestReponseNormaleNonRelancee(t *testing.T) {
for _, s := range []string{
"Voici la réponse : le fichier contient trois fonctions.",
"```go\nfunc main() {}\n```",
"Le JSON de config : {\"name\": \"loki\", \"port\": 8090}",
"La fonction bash() de ce script accepte un paramètre.",
"",
} {
if textualToolCall(s) {
t.Errorf("relance à tort : %q", s)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package loki
// code_roles.go — registre des rôles du mode code. Chaque rôle est un prompt
// Markdown EMBARQUÉ dans le binaire (agents/*.md), comme le catalogue MCP :
// pas d'appel réseau, pour en changer on édite le fichier et on recompile.
// Démarche reprise du registre d'agents d'OpenFox (builder/planner/explorer/
// verifier/code-reviewer — voir NOTICE.md), prompts réécrits COURTS : un
// préambule verbeux fait sur-raisonner les modèles locaux (voir le commentaire
// de baseSystemPrompt).
import (
"embed"
"strings"
)
//go:embed agents/*.md
var rolesFS embed.FS
// roleNames : les rôles connus. builder et verifier sont pilotés par la boucle
// du mode code (code_verify.go) ; planner s'active quand l'utilisateur demande
// un plan ; explorer et code-reviewer sont disponibles pour des passes
// manuelles futures.
var roleNames = []string{"builder", "planner", "explorer", "verifier", "code-reviewer"}
// rolePrompt renvoie le prompt d'un rôle, "" si inconnu.
func rolePrompt(name string) string {
b, err := rolesFS.ReadFile("agents/" + name + ".md")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// planIntentRe-like : l'utilisateur demande-t-il un PLAN plutôt qu'une
// implémentation ? Heuristique simple sur le début du message.
func wantsPlan(text string) bool {
low := strings.ToLower(text)
for _, kw := range []string{"fais un plan", "fait un plan", "propose un plan", "planifie", "plan d'implémentation", "plan d'action", "make a plan", "plan first"} {
if strings.Contains(low, kw) {
return true
}
}
return false
}
+373
View File
@@ -0,0 +1,373 @@
package loki
// code_tools.go — outils fichiers du MODE CODE : read, grep, glob, ask.
// Inspirés des outils d'OpenFox (read/grep/ask — voir NOTICE.md), réécrits en
// Go. Sans eux, le modèle lit les fichiers au `cat` via bash : sortie non
// bornée, pas de numéros de ligne, et aucun moyen de savoir ce qu'il a lu
// (voir code_tracker.go).
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
const (
readMaxLines = 1000 // lignes par appel (offset/limit pour la suite)
readDefLines = 400
readMaxLineLen = 500 // une ligne minifiée ne doit pas manger le contexte
grepMaxHits = 60
globMaxHits = 200
)
// skipDirs : dossiers ignorés par grep/glob — dépendances et artefacts, jamais
// du code à lire. `.git` compris : son contenu binaire pollue toute recherche.
var skipDirs = map[string]bool{
".git": true, "node_modules": true, "vendor": true, "dist": true,
"build": true, "target": true, "__pycache__": true, ".venv": true,
"venv": true, ".next": true, ".cache": true,
}
func readTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "read",
Description: "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Path (relative to the working folder)"},
"offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Lines (default %d, max %d)", readDefLines, readMaxLines)},
},
"required": []string{"file"},
},
},
}
}
func grepTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "grep",
Description: "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "Go regexp"},
"path": map[string]any{"type": "string", "description": "File or directory to search (default .)"},
"glob": map[string]any{"type": "string", "description": "Only files matching this glob (e.g. *.go)"},
},
"required": []string{"pattern"},
},
},
}
}
func globTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "glob",
Description: "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "Glob, e.g. src/**/*.ts"},
},
"required": []string{"pattern"},
},
},
}
}
func askTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "ask",
Description: "Ask the user a clarifying question with 2-4 short options, then END your turn. Their answer arrives as the next user message. Use when a decision is genuinely theirs (scope, destructive change, ambiguous requirement) — not for things you can check yourself.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"question": map[string]any{"type": "string", "description": "The question"},
"options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "2-4 short answer options"},
},
"required": []string{"question"},
},
},
}
}
// toolRead lit un fichier avec numéros de ligne. Enregistre la lecture dans le
// tracker (code_tracker.go) — c'est ce qui autorise edit/write ensuite.
func toolRead(args map[string]any, codeMode bool) string {
file, _ := args["file"].(string)
if strings.TrimSpace(file) == "" {
return "[erreur] chemin vide"
}
path := resolveAgentPath(file)
if codeMode && !codePathAllowed(path) {
return refusedPathResult(file)
}
offset, limit := 1, readDefLines
if v, ok := args["offset"].(float64); ok && int(v) > 0 {
offset = int(v)
}
if v, ok := args["limit"].(float64); ok && int(v) > 0 {
limit = int(v)
}
if limit > readMaxLines {
limit = readMaxLines
}
b, err := os.ReadFile(path)
if err != nil {
return "[erreur] " + err.Error()
}
if isBinary(b) {
fi, _ := os.Stat(path)
size := int64(len(b))
if fi != nil {
size = fi.Size()
}
return fmt.Sprintf("[binaire] %s (%d octets) — pas un fichier texte", file, size)
}
lines := strings.Split(string(b), "\n")
total := len(lines)
// Fichier fini par \n : Split laisse une dernière entrée vide, pas une ligne.
if total > 0 && lines[total-1] == "" {
total--
}
if offset > total {
return fmt.Sprintf("[erreur] offset %d > %d lignes", offset, total)
}
end := offset - 1 + limit
if end > total {
end = total
}
var out strings.Builder
for i := offset - 1; i < end; i++ {
l := lines[i]
if utf8.RuneCountInString(l) > readMaxLineLen {
r := []rune(l)
l = string(r[:readMaxLineLen]) + "…"
}
fmt.Fprintf(&out, "%d: %s\n", i+1, l)
}
if end < total {
fmt.Fprintf(&out, "… (%d lignes restantes — relis avec offset=%d)\n", total-end, end+1)
}
trackerNoteRead(path)
return strings.TrimRight(out.String(), "\n")
}
// toolGrep cherche un motif regexp dans les fichiers.
func toolGrep(args map[string]any, codeMode bool) string {
pattern, _ := args["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return "[erreur] motif vide"
}
re, err := regexp.Compile(pattern)
if err != nil {
return "[erreur] motif invalide : " + err.Error()
}
root := convWorkspace()
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
root = resolveAgentPath(p)
}
if codeMode && !codePathAllowed(root) {
return refusedPathResult(root)
}
globPat, _ := args["glob"].(string)
var hits []string
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // dossier illisible : on continue ailleurs
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
if globPat != "" {
if ok, _ := filepath.Match(globPat, d.Name()); !ok {
return nil
}
}
if len(hits) >= grepMaxHits {
return filepath.SkipAll
}
b, rerr := os.ReadFile(path)
if rerr != nil || isBinary(b) {
return nil
}
rel, rrr := filepath.Rel(root, path)
if rrr != nil {
rel = path
}
for i, line := range strings.Split(string(b), "\n") {
if re.MatchString(line) {
if utf8.RuneCountInString(line) > readMaxLineLen {
r := []rune(line)
line = string(r[:readMaxLineLen]) + "…"
}
hits = append(hits, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(rel), i+1, strings.TrimSpace(line)))
if len(hits) >= grepMaxHits {
return filepath.SkipAll
}
}
}
return nil
})
if walkErr != nil && len(hits) == 0 {
return "[erreur] " + walkErr.Error()
}
if len(hits) == 0 {
return "[aucun résultat]"
}
out := strings.Join(hits, "\n")
if len(hits) >= grepMaxHits {
out += fmt.Sprintf("\n… (plafond de %d résultats atteint — affine le motif)", grepMaxHits)
}
return out
}
// toolGlob liste les fichiers correspondant à un motif, plus récents d'abord.
func toolGlob(args map[string]any, codeMode bool) string {
pattern, _ := args["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return "[erreur] motif vide"
}
root := convWorkspace()
re, err := globToRegexp(pattern)
if err != nil {
return "[erreur] motif invalide : " + err.Error()
}
type hit struct {
rel string
mod int64
}
var hits []hit
filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return nil
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if !re.MatchString(rel) {
return nil
}
info, ierr := d.Info()
mod := int64(0)
if ierr == nil {
mod = info.ModTime().UnixNano()
}
hits = append(hits, hit{rel, mod})
return nil
})
if len(hits) == 0 {
return "[aucun fichier]"
}
sort.Slice(hits, func(i, j int) bool { return hits[i].mod > hits[j].mod })
if len(hits) > globMaxHits {
hits = hits[:globMaxHits]
}
var out strings.Builder
for _, h := range hits {
out.WriteString(h.rel + "\n")
}
return strings.TrimRight(out.String(), "\n")
}
// globToRegexp convertit un glob (avec **) en expression régulière sur des
// chemins en séparateurs '/'. `**` = n'importe quelle profondeur, `*` = tout
// sauf '/', `?` = un caractère sauf '/'.
func globToRegexp(pattern string) (*regexp.Regexp, error) {
pattern = strings.TrimPrefix(filepath.ToSlash(pattern), "./")
var b strings.Builder
b.WriteString("^")
i := 0
for i < len(pattern) {
c := pattern[i]
switch c {
case '*':
if strings.HasPrefix(pattern[i:], "**/") {
b.WriteString(`(?:[^/]+/)*`)
i += 3
continue
}
if strings.HasPrefix(pattern[i:], "**") {
b.WriteString(`.*`)
i += 2
continue
}
b.WriteString(`[^/]*`)
case '?':
b.WriteString(`[^/]`)
case '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$', '\\':
b.WriteString(`\` + string(c))
default:
b.WriteByte(c)
}
i++
}
b.WriteString("$")
return regexp.Compile(b.String())
}
// isBinary : heuristique simple — un NUL dans les premiers 8 Kio.
func isBinary(b []byte) bool {
n := len(b)
if n > 8192 {
n = 8192
}
for i := 0; i < n; i++ {
if b[i] == 0 {
return true
}
}
return false
}
// toolAsk formate la « réponse » de l'outil ask : la question part vers l'UI
// via un événement dédié (voir runChat) ; ici on dit au modèle de clore son
// tour et d'attendre.
func toolAsk(args map[string]any) string {
q, _ := args["question"].(string)
if strings.TrimSpace(q) == "" {
return "[erreur] question vide"
}
return "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message."
}
// askOptions extrait les options (chaînes) des arguments de l'outil ask.
func askOptions(args map[string]any) []string {
raw, _ := args["options"].([]any)
var opts []string
for _, o := range raw {
if s, ok := o.(string); ok && strings.TrimSpace(s) != "" {
opts = append(opts, s)
}
}
if len(opts) > 4 {
opts = opts[:4]
}
return opts
}
+159
View File
@@ -0,0 +1,159 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// read numérote depuis 1, respecte offset/limit et annonce le reste.
func TestReadOffsetLimit(t *testing.T) {
ws := withWorkspace(t)
var lines []string
for i := 1; i <= 10; i++ {
lines = append(lines, "ligne")
}
if err := os.WriteFile(filepath.Join(ws, "f.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
out := toolRead(map[string]any{"file": "f.txt", "offset": float64(3), "limit": float64(2)}, true)
if !strings.Contains(out, "3: ligne") || !strings.Contains(out, "4: ligne") {
t.Fatalf("lignes 3-4 attendues :\n%s", out)
}
if strings.Contains(out, "5: ligne") {
t.Fatalf("limite ignorée :\n%s", out)
}
if !strings.Contains(out, "restantes") {
t.Fatalf("suite non annoncée :\n%s", out)
}
}
// read refuse un binaire et le dit, au lieu de déverser du bruit.
func TestReadBinaire(t *testing.T) {
ws := withWorkspace(t)
if err := os.WriteFile(filepath.Join(ws, "bin.dat"), []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
t.Fatal(err)
}
if out := toolRead(map[string]any{"file": "bin.dat"}, true); !strings.Contains(out, "[binaire]") {
t.Fatalf("binaire non détecté : %s", out)
}
}
// read enregistre la lecture : le tracker doit ensuite autoriser l'écriture.
func TestReadNourritLeTracker(t *testing.T) {
ws := withWorkspace(t)
path := filepath.Join(ws, "suivi.txt")
if err := os.WriteFile(path, []byte("contenu"), 0o644); err != nil {
t.Fatal(err)
}
if msg := trackerCheck(path, true); msg == "" {
t.Fatal("écriture acceptée avant lecture")
}
toolRead(map[string]any{"file": "suivi.txt"}, true)
if msg := trackerCheck(path, true); msg != "" {
t.Fatalf("écriture refusée après lecture : %s", msg)
}
// Fichier modifié APRÈS la lecture : refus (lecture périmée).
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatal(err)
}
if msg := trackerCheck(path, true); !strings.Contains(msg, "changé") {
t.Fatalf("lecture périmée non détectée : %q", msg)
}
}
// grep trouve, borne et ignore les dossiers de dépendances.
func TestGrep(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "a.go"), "package main\nfunc Cible() {}\n")
writeFile(t, filepath.Join(ws, "node_modules", "x.js"), "Cible aussi\n")
out := toolGrep(map[string]any{"pattern": "Cible"}, true)
if !strings.Contains(out, "a.go:2") {
t.Fatalf("correspondance manquée :\n%s", out)
}
if strings.Contains(out, "node_modules") {
t.Fatalf("node_modules aurait dû être ignoré :\n%s", out)
}
if out := toolGrep(map[string]any{"pattern": "IntrouvableXYZ"}, true); !strings.Contains(out, "aucun résultat") {
t.Fatalf("absence non signalée : %s", out)
}
}
// glob : ** traverse les dossiers, tri par modification décroissante.
func TestGlob(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "src", "a.ts"), "a")
writeFile(t, filepath.Join(ws, "src", "sub", "b.ts"), "b")
writeFile(t, filepath.Join(ws, "autre.txt"), "c")
out := toolGlob(map[string]any{"pattern": "**/*.ts"}, true)
if !strings.Contains(out, "src/a.ts") || !strings.Contains(out, "src/sub/b.ts") {
t.Fatalf("fichiers .ts manquants :\n%s", out)
}
if strings.Contains(out, "autre.txt") {
t.Fatalf("autre.txt ne matche pas **/*.ts :\n%s", out)
}
}
// globToRegexp : cas aux limites du **.
func TestGlobToRegexp(t *testing.T) {
cases := []struct {
pattern, path string
want bool
}{
{"*.go", "main.go", true},
{"*.go", "src/main.go", false},
{"**/*.go", "src/deep/main.go", true},
{"**/*.go", "main.go", true}, // **/ = zéro dossier ou plus
{"src/**", "src/a/b/c.txt", true},
{"src/*.ts", "src/a.ts", true},
{"src/*.ts", "src/sub/a.ts", false},
}
for _, c := range cases {
re, err := globToRegexp(c.pattern)
if err != nil {
t.Fatalf("%s : %v", c.pattern, err)
}
if got := re.MatchString(c.path); got != c.want {
t.Errorf("%s sur %s : %v, attendu %v", c.pattern, c.path, got, c.want)
}
}
}
// fileEdit préserve les fins de ligne CRLF d'un fichier quand le modèle
// envoie du LF (et n'introduit jamais de mélange).
func TestEditPreserveCRLF(t *testing.T) {
ws := withWorkspace(t)
path := filepath.Join(ws, "w.txt")
if err := os.WriteFile(path, []byte("un\r\ndeux\r\ntrois\r\n"), 0o644); err != nil {
t.Fatal(err)
}
out := fileEdit(path, "deux", "DEUX")
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("édition refusée : %s", out)
}
b, _ := os.ReadFile(path)
if string(b) != "un\r\nDEUX\r\ntrois\r\n" {
t.Fatalf("fins de ligne perdues : %q", b)
}
}
// fileEdit multi-lignes en LF sur un fichier CRLF : la normalisation couvre
// aussi le old qui contient des sauts de ligne.
func TestEditCRLFMultiligne(t *testing.T) {
ws := withWorkspace(t)
path := filepath.Join(ws, "m.txt")
if err := os.WriteFile(path, []byte("a\r\nb\r\nc\r\n"), 0o644); err != nil {
t.Fatal(err)
}
out := fileEdit(path, "a\nb", "a\nB")
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("édition refusée : %s", out)
}
b, _ := os.ReadFile(path)
if string(b) != "a\r\nB\r\nc\r\n" {
t.Fatalf("résultat inattendu : %q", b)
}
}
+67
View File
@@ -0,0 +1,67 @@
package loki
// code_tracker.go — suivi « lu avant d'écrire » (file-tracker d'OpenFox, réécrit
// en Go — voir NOTICE.md). Le modèle doit avoir LU un fichier (outil read) avant
// de le modifier (edit) ou de l'écraser (write sur un fichier existant), et sa
// lecture doit être plus récente que la dernière modification du fichier sur le
// disque. Ça bloque les deux corruptions silencieuses classiques :
// - réécrire un fichier jamais ouvert (le modèle « devine » son contenu) ;
// - éditer d'après une lecture périmée (le fichier a changé entre-temps —
// autre outil, job d'arrière-plan, utilisateur).
//
// L'état est en mémoire, par discussion : un redémarrage oublie tout, et le
// modèle relit — c'est le comportement voulu, pas un bug.
import (
"os"
"path/filepath"
"sync"
"time"
)
var (
trackerMu sync.Mutex
trackerReads = map[string]time.Time{} // conv + chemin → instant de la lecture
)
func trackerKey(path string) string {
return convEnsureActive() + "\x00" + filepath.Clean(path)
}
// trackerNoteRead enregistre qu'un fichier vient d'être lu (outil read).
func trackerNoteRead(path string) {
trackerMu.Lock()
trackerReads[trackerKey(path)] = time.Now()
trackerMu.Unlock()
}
// trackerNoteWrite : après une écriture réussie, la version sur disque est celle
// que le modèle vient de produire — il la « connaît ». Sans cette note, éditer
// un fichier qu'on vient soi-même d'écrire serait refusé.
func trackerNoteWrite(path string) {
trackerNoteRead(path)
}
// trackerCheck vérifie qu'une modification est sûre. forWrite distingue write
// (autorisé sur un fichier INEXISTANT sans lecture préalable — création) de
// edit (le fichier doit exister ET avoir été lu).
// Renvoie "" si c'est bon, sinon le message de refus pour le modèle.
func trackerCheck(path string, forWrite bool) string {
fi, statErr := os.Stat(path)
if statErr != nil {
if forWrite {
return "" // création d'un fichier neuf : rien à relire
}
return "" // edit sur fichier absent : fileEdit renverra sa propre erreur claire
}
trackerMu.Lock()
readAt, seen := trackerReads[trackerKey(path)]
trackerMu.Unlock()
if !seen {
return "[refusé] " + path + " existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
}
if fi.ModTime().After(readAt) {
return "[refusé] " + path + " a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
}
return ""
}
+192
View File
@@ -0,0 +1,192 @@
package loki
// code_verify.go — la boucle du contrat en mode code : build → VÉRIFICATION →
// correction → re-vérification, jusqu'à ce que tous les critères passent ou
// que le plafond de cycles soit atteint. Reprise de la boucle
// builder/verifier d'OpenFox (voir NOTICE.md), adaptée au fil unique de Loki :
//
// - la passe de vérification tourne sur un CONTEXTE ISOLÉ (prompt du rôle +
// critères + tâche), pas sur l'historique complet — même modèle, mais il
// n'a pas « écrit » le code sous les yeux, et surtout ça ne gonfle pas le
// contexte de la discussion ;
// - seule cette passe a le droit de marquer un critère `passed`
// (toolCriteria, allowPass) ;
// - les corrections, elles, repartent dans l'historique NORMAL du fil : le
// builder doit se souvenir de ce qu'il a fait.
import (
"context"
"fmt"
"strings"
)
// codeMaxFixTurns : nombre de tours de CORRECTION après échec de vérification.
// Au-delà, on s'arrête et on dit ce qui manque — un modèle qui n'y arrive pas
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
const codeMaxFixTurns = 2
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
return
}
convID := convEnsureActive()
list := critList(convID)
if len(list) == 0 || critAllPassed(list) {
return
}
// En phase de PLAN, on ne vérifie pas : rien n'a été construit.
if caps.Role == "planner" {
return
}
for fix := 0; ; fix++ {
if ctx.Err() != nil {
return
}
c.runVerifyPass(ctx, caps, temperature, epoch)
list = critList(convID)
if critAllPassed(list) {
c.appendDelta(epoch, map[string]any{"verify_done": "passed"})
return
}
if fix >= codeMaxFixTurns {
c.appendDelta(epoch, map[string]any{"verify_done": "gave_up"})
// Le fil doit dire où on en est — sinon l'utilisateur voit une
// vérification échouée et aucun mot dessus.
c.appendDelta(epoch, map[string]any{"content": "\n\n_(vérification : " +
fmt.Sprint(critPending(list)) + " critère(s) non satisfaits après " +
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
return
}
c.runFixTurn(ctx, caps, temperature, epoch, list)
}
}
// runVerifyPass lance la passe de vérification sur un contexte ISOLÉ.
func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature float64, epoch int) {
vcaps := caps
vcaps.Role = "verifier"
// Marqueur de rôle pour l'UI : les bulles qui suivent portent le badge.
c.appendDelta(epoch, map[string]any{"role": "verifier"})
defer c.appendDelta(epoch, map[string]any{"role": ""})
convID := convEnsureActive()
list := critList(convID)
task := c.lastUserText()
var user strings.Builder
user.WriteString("Task:\n" + task + "\n\nAcceptance criteria:\n" + critRender(list))
user.WriteString("\n\nVerify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.")
msgs := []Message{
{Role: "system", Content: rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps)},
{Role: "user", Content: user.String()},
}
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
c.forwardStream(ev, epoch)
return true
})
}
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
// critères en échec. Sa trace est persistée comme un tour ordinaire.
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
var failed []Criterion
for _, cr := range list {
if cr.Status != "passed" {
failed = append(failed, cr)
}
}
c.appendDelta(epoch, map[string]any{"role": "builder"})
defer c.appendDelta(epoch, map[string]any{"role": ""})
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
c.mu.Lock()
if c.epoch != epoch {
c.mu.Unlock()
return
}
c.Messages = append(c.Messages, fixMsg)
msgs := append([]Message(nil), c.Messages...)
c.mu.Unlock()
final := msgs
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
}
var content strings.Builder
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
c.forwardStream(ev, epoch)
return true
})
c.mu.Lock()
if c.epoch == epoch {
c.Messages = append(c.Messages, extra...)
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
}
}
c.mu.Unlock()
c.persist()
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
// correction) vers le journal d'affichage — même mapping que generate(), sans
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
tu := map[string]any{
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
}
if ev.ToolUsed.Body != "" {
tu["body"] = ev.ToolUsed.Body
}
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
case ev.Ask != nil:
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.Stats != nil:
if ev.Stats.PromptTokensTotal > 0 {
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
}
c.mu.Unlock()
}
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
case ev.Reasoning != "":
c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning})
case ev.Content != "":
c.appendDelta(epoch, map[string]any{"content": ev.Content})
}
}
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
func (c *Conversation) lastUserText() string {
c.mu.Lock()
defer c.mu.Unlock()
for i := len(c.Messages) - 1; i >= 0; i-- {
if c.Messages[i].Role != "user" {
continue
}
if s, ok := c.Messages[i].Content.(string); ok {
return s
}
}
return "(tâche non retrouvée — vérifie d'après le diff)"
}
+104 -3
View File
@@ -194,6 +194,14 @@ type Caps struct {
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
// indépendant du mode agent. Voir MemMode.
Mem MemMode
// Code = mode code actif pour cette discussion (sélecteur Chat/Code) :
// ajoute les outils codeur (read/grep/glob, git, critères, jobs) et le
// prompt de rôle. Requiert Agent.
Code bool
// Role = rôle du tour en mode code : "" ou "builder" (défaut), "planner",
// "verifier" (passe de vérification, seule autorisée à marquer un critère
// passed), "explorer", "code-reviewer". Voir code_roles.go.
Role string
}
// globalCaps reads the machine-wide config — the default when a request doesn't
@@ -264,9 +272,20 @@ func steerSystem(msgs []Message, hint string) []Message {
// EnabledTools returns the tools to advertise on the next inference call.
func EnabledTools(caps Caps) []Tool {
tools := []Tool{}
// Passe de VÉRIFICATION du mode code : jeu d'outils fermé, en lecture
// seule + bash (lancer les tests) + critères. Ni write/edit (elle ne
// corrige pas), ni mémoire ni web (hors sujet, et chaque schéma coûte du
// contexte).
if caps.Code && caps.Role == "verifier" {
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
}
if caps.Agent {
tools = append(tools, bashTool(), writeTool(), editTool())
}
if caps.Code {
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
}
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
if caps.Mem != MemOff {
@@ -305,6 +324,7 @@ type StreamEvent struct {
Reasoning string
ToolUsed *ToolUsedEvent
Stats *StatsEvent
Ask *AskEvent
Err error
// DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
// le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
@@ -324,6 +344,13 @@ type StreamEvent struct {
// ajouter.
NewHistory []Message
}
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
// par l'UI comme une carte à boutons. Le tour se termine juste après.
type AskEvent struct {
Question string `json:"question"`
Options []string `json:"options,omitempty"`
}
type ToolUsedEvent struct {
Name string
Label string // user-visible summary (skill name or the command)
@@ -604,6 +631,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
// explicite au lieu d'afficher « pas de réponse ».
nudged := false
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
// relance par tour, comme le nudge.
patternRetried := false
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
@@ -799,10 +829,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
switch cur.Function.Name {
case "mem_search", "web_search":
key = "query"
case "mem_read", "mem_add", "mem_edit", "edit", "write":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
key = "file"
case "web_open", "web_read", "web_grep":
key = "url"
case "grep", "glob":
key = "pattern"
case "ask":
key = "question"
case "criteria":
key = "action"
case "bash_tail":
key = "id"
case "git_clone":
key = "url"
}
p := previewArg(cur.Function.Arguments, key)
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
@@ -973,10 +1013,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
switch tc.Function.Name {
case "mem_search", "web_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
label, _ = args["file"].(string)
case "bash":
case "bash", "bash_bg":
label, _ = args["command"].(string)
case "grep", "glob":
label, _ = args["pattern"].(string)
case "ask":
label, _ = args["question"].(string)
case "criteria":
label, _ = args["action"].(string)
case "bash_tail":
label, _ = args["id"].(string)
case "git_clone":
label, _ = args["url"].(string)
case "web_open", "web_read":
label, _ = args["url"].(string)
case "web_grep":
@@ -1063,10 +1113,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
// n'a pas l'ancien contenu du fichier distant).
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
} else if msg := codeWriteGuard(caps, label, true); msg != "" {
result = msg
} else {
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
}
case "edit":
@@ -1074,13 +1128,41 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
newText, _ := args["new"].(string)
if tgt := agentTargetSlug(); tgt != "" {
result = nodeEditRemote(tgt, label, oldText, newText)
} else if msg := codeWriteGuard(caps, label, false); msg != "" {
result = msg
} else {
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
}
case "read":
result = toolRead(args, caps.Code)
case "grep":
result = toolGrep(args, caps.Code)
case "glob":
result = toolGlob(args, caps.Code)
case "ask":
result = toolAsk(args)
if !strings.HasPrefix(result, "[erreur]") {
q, _ := args["question"].(string)
cb(StreamEvent{Ask: &AskEvent{Question: q, Options: askOptions(args)}})
}
case "bash_bg":
result = toolBashBg(args)
case "bash_tail":
result = toolBashTail(args)
case "git_status":
result = toolGitStatus(ctx)
case "git_diff":
result = toolGitDiff(ctx, args)
case "git_clone":
result = toolGitClone(ctx, args)
case "criteria":
result = toolCriteria(args, caps.Role == "verifier")
case "bash":
to := 0
switch v := args["timeout"].(type) {
@@ -1089,6 +1171,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case int:
to = v
}
// Politique de sécurité : les commandes catastrophiques sont
// refusées AVANT toute exécution, cible distante comprise.
if reason := dangerousCommand(label); reason != "" {
result = refusedCommandResult(reason)
break
}
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
@@ -1164,6 +1252,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
continue
}
// Appel d'outil TEXTUEL (halluciné, jamais exécuté) dans la réponse
// finale : on relance le tour UNE fois avec la consigne corrective
// (code_retry.go). Le texte fautif reste affiché — le remplacer serait
// mentir sur ce qui s'est passé — mais l'historique du modèle garde la
// trace ET la correction, donc la vraie réponse suit immédiatement.
if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) {
patternRetried = true
bad := Message{Role: "assistant", Content: assistantContent.String()}
fix := Message{Role: "user", Content: retryCorrective}
messages = append(messages, bad, fix)
extra = append(extra, bad, fix)
continue
}
// Normal end of turn. If the model produced no visible answer at all
// (empty content, e.g. it stopped right after a tool result), say so
// instead of leaving the user staring at a silent, finished chat.
+416
View File
@@ -0,0 +1,416 @@
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 := convWorkspace()
// 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).
+17
View File
@@ -0,0 +1,17 @@
[
{
"id": "go",
"command": ["gopls", "serve"],
"extensions": [".go"]
},
{
"id": "typescript",
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
},
{
"id": "python",
"command": ["pyright-langserver", "--stdio"],
"extensions": [".py"]
}
]
+6
View File
@@ -262,6 +262,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return exec.CommandContext(ctx, "/bin/bash", "-c", command)
}
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
func newShellCmdDetached(command string) *exec.Cmd {
return exec.Command("/bin/bash", "-c", command)
}
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram. Linux : /proc/meminfo.
// macOS : sysctl pour le total, vm_stat pour ce qui est réellement libre (pages
// free + inactive + speculative ; le reste — wired, active, compressé — est
+6
View File
@@ -391,6 +391,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
}
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
func newShellCmdDetached(command string) *exec.Cmd {
return hideCmd(exec.Command("cmd", "/C", command))
}
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram (GlobalMemoryStatusEx).
func ramUsageMB() (used, total int) {
var m struct {
+199 -1
View File
@@ -1993,6 +1993,39 @@ html[data-files="1"] #files-btn{color:var(--accent)}
dernière marge. */
.perf-row{margin:0 0 12px}
.perf-sub{padding:0 0 10px}
/* --- Mode Chat/Code (20-mode.js) ------------------------------------------
Sélecteur segmenté dans le pied de la carte du composeur, puce de
suggestion, panneau des critères, badge de rôle, carte de question. */
#mode-seg{display:inline-flex;border:1px solid var(--border);border-radius:3px;overflow:hidden;flex:none}
#mode-seg button{border:0;background:transparent;color:var(--dim);font-size:10px;letter-spacing:.04em;text-transform:uppercase;padding:2px 8px;cursor:pointer;font-family:inherit}
#mode-seg button.on{background:var(--accent-bg);color:var(--accent)}
#mode-seg button:not(.on):hover{color:var(--text)}
#code-hint{display:flex;align-items:center;gap:8px;margin:0 auto 6px;max-width:820px;padding:6px 10px;border:1px solid var(--border);border-left:2px solid var(--accent);border-radius:3px;background:var(--panel);color:var(--dim);font-size:12px}
#code-hint button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit}
#code-hint button:hover{background:var(--accent-bg)}
#code-hint #code-hint-no{border-color:var(--border);color:var(--dim);margin-left:auto}
#criteria-box{margin:0 auto 6px;max-width:820px;border:1px solid var(--border);border-radius:3px;background:var(--panel);font-size:12px;overflow:hidden}
#criteria-box.allpass{border-left:2px solid var(--accent)}
#criteria-head{padding:5px 10px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em}
#criteria-box.allpass #criteria-head{color:var(--accent)}
#criteria-box.folded #criteria-list{display:none}
#criteria-list{list-style:none;margin:0;padding:0 10px 6px}
#criteria-list li{padding:2px 0;color:var(--text)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
#criteria-list li.crit-pending{color:var(--dim)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
.msg[data-agent-role="verifier"]{border-left:2px solid var(--accent)}
.askcard .body{display:flex;flex-direction:column;gap:8px}
.askopts{display:flex;flex-wrap:wrap;gap:6px}
.askopts button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:4px 10px;font-size:13px;cursor:pointer;font-family:inherit}
.askopts button:hover:not(:disabled){background:var(--accent-bg)}
.askopts button:disabled{opacity:.5;cursor:default}
</style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -2358,6 +2391,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
<div id="ctxmeta">
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
de code : critères, vérification, LSP). Dans le pied de carte, pas
dans la barre de saisie déjà chargée — ça se règle par discussion,
pas par message. -->
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
</div>
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
@@ -5134,9 +5175,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
});
// Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à
// réception d'un delta {role}, repris par addMsg sur les bulles suivantes.
let ROLE_BADGE='';
function addMsg(role, text){
const el=document.createElement('div');
el.className='msg '+role;
if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE;
const collapsible = (role==='reasoning' || role==='tool');
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
if(collapsible){
@@ -5150,6 +5195,11 @@ function addMsg(role, text){
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
paintLabel(el.querySelector('.label'), role);
}
// Badge visible sur la bulle assistant pendant une passe de rôle.
if(ROLE_BADGE && role==='assistant'){
const b=document.createElement('span'); b.className='rolebadge'; b.textContent=ROLE_BADGE==='verifier'?'vérification':ROLE_BADGE;
el.querySelector('.label').appendChild(b);
}
}
el.querySelector('.body').textContent=text;
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
@@ -5804,7 +5854,14 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
if(d.role!==undefined){ ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
@@ -7740,4 +7797,145 @@ function bindZoom(img, name){
img.dataset.zoom='1';
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
}
// 20-mode.js — le mode Chat/Code : sélecteur du composeur, puce de suggestion,
// panneau des critères d'acceptation, carte de question (outil ask).
// La bascule est PAR DISCUSSION et vit côté serveur (/api/chat/mode) : tous
// les appareils voient le même mode, comme le fil lui-même.
let CURRENT_MODE = 'chat';
function modeSeg(){ return document.getElementById('mode-seg'); }
function paintMode(){
const seg = modeSeg();
if(!seg) return;
seg.querySelector('[data-mode="chat"]').classList.toggle('on', CURRENT_MODE!=='code');
seg.querySelector('[data-mode="code"]').classList.toggle('on', CURRENT_MODE==='code');
// Le panneau des critères n'a de sens qu'en mode code.
const box = document.getElementById('criteria-box');
if(box && CURRENT_MODE!=='code') box.style.display='none';
}
// Bascule demandée depuis CE navigateur.
async function setMode(mode){
if(mode===CURRENT_MODE) return;
try{
const r = await jfetch('/api/chat/mode', {method:'POST', body: JSON.stringify({mode})});
const j = await r.json();
if(j.ok){ CURRENT_MODE = j.mode; paintMode(); hideCodeHint(); }
}catch(e){ toast('bascule impossible : '+e); }
}
// Bascule reçue du serveur (autre appareil, ou écho de la nôtre).
function applyModeDelta(mode){
CURRENT_MODE = mode==='code' ? 'code' : 'chat';
paintMode();
if(CURRENT_MODE==='code') hideCodeHint();
}
// Recharge le mode + critères au chargement et à chaque bascule de discussion.
async function modeOnConvChange(){
try{
const r = await jfetch('/api/chat/mode');
const j = await r.json();
if(!j.ok) return;
CURRENT_MODE = j.mode==='code' ? 'code' : 'chat';
paintMode();
renderCriteria(j.criteria||[]);
hideCodeHint();
}catch(e){}
}
// --- Puce « passer en mode Code ? » -----------------------------------------
// Émise par le serveur (delta code_hint) quand un message de mode chat
// ressemble à une tâche de code. Ignorable, jamais bloquante.
function showCodeHint(){
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
const composer = document.getElementById('composer');
if(!composer) return;
const chip = document.createElement('div');
chip.id = 'code-hint';
chip.innerHTML = 'ça ressemble à une tâche de code — <button type="button" id="code-hint-yes">passer en mode Code</button><button type="button" id="code-hint-no" title="ignorer" aria-label="ignorer">✕</button>';
composer.parentNode.insertBefore(chip, composer);
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
}
function hideCodeHint(){
const chip = document.getElementById('code-hint');
if(chip) chip.remove();
}
// --- Panneau des critères ----------------------------------------------------
// Rendu par les deltas {criteria:[...]} (outil criteria, passe de vérif, ou
// édition manuelle). Repliable ; en-tête = compteur passés / total.
function renderCriteria(list){
let box = document.getElementById('criteria-box');
if(!list || !list.length){ if(box) box.remove(); return; }
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
if(!box){
const composer = document.getElementById('composer');
if(!composer) return;
box = document.createElement('div');
box.id = 'criteria-box';
box.innerHTML = '<div id="criteria-head"></div><ul id="criteria-list"></ul>';
composer.parentNode.insertBefore(box, composer);
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
}
box.style.display='';
const passed = list.filter(c=>c.status==='passed').length;
box.querySelector('#criteria-head').textContent =
'critères ' + passed + '/' + list.length + (passed===list.length ? ' ✓' : '');
box.classList.toggle('allpass', passed===list.length);
const ul = box.querySelector('#criteria-list');
ul.innerHTML='';
for(const c of list){
const li = document.createElement('li');
li.className = 'crit-'+(c.status||'pending');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
ul.appendChild(li);
}
}
function onVerifyDone(status){
if(status==='passed') toast('vérification : tous les critères passent ✓');
}
// --- Carte de question (outil ask) ------------------------------------------
// Le modèle a posé une question structurée et clos son tour : la carte propose
// ses options en boutons ; cliquer envoie la réponse comme un message normal.
function renderAskCard(ask){
if(!ask || !ask.question) return;
removeTyping();
const el = addMsg('assistant', '');
el.classList.add('askcard');
const body = el.querySelector('.body');
body.textContent = ask.question;
if(ask.options && ask.options.length){
const row = document.createElement('div');
row.className = 'askopts';
for(const opt of ask.options){
const b = document.createElement('button');
b.type = 'button';
b.textContent = opt;
b.onclick = ()=>{
// La réponse part comme un message utilisateur ordinaire.
const input = document.getElementById('input');
input.value = opt;
send();
row.querySelectorAll('button').forEach(x=>x.disabled=true);
};
row.appendChild(b);
}
body.appendChild(row);
}
scrollMaybe();
}
// Init : état du mode au chargement de la page.
window.addEventListener('DOMContentLoaded', modeOnConvChange);
</script></body></html>
+8
View File
@@ -395,6 +395,14 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
<div id="ctxmeta">
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
de code : critères, vérification, LSP). Dans le pied de carte, pas
dans la barre de saisie déjà chargée — ça se règle par discussion,
pas par message. -->
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
</div>
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
@@ -56,9 +56,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
});
// Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à
// réception d'un delta {role}, repris par addMsg sur les bulles suivantes.
let ROLE_BADGE='';
function addMsg(role, text){
const el=document.createElement('div');
el.className='msg '+role;
if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE;
const collapsible = (role==='reasoning' || role==='tool');
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
if(collapsible){
@@ -72,6 +76,11 @@ function addMsg(role, text){
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
paintLabel(el.querySelector('.label'), role);
}
// Badge visible sur la bulle assistant pendant une passe de rôle.
if(ROLE_BADGE && role==='assistant'){
const b=document.createElement('span'); b.className='rolebadge'; b.textContent=ROLE_BADGE==='verifier'?'vérification':ROLE_BADGE;
el.querySelector('.label').appendChild(b);
}
}
el.querySelector('.body').textContent=text;
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
+8 -1
View File
@@ -261,7 +261,14 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
if(d.role!==undefined){ ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
+141
View File
@@ -0,0 +1,141 @@
// 20-mode.js — le mode Chat/Code : sélecteur du composeur, puce de suggestion,
// panneau des critères d'acceptation, carte de question (outil ask).
// La bascule est PAR DISCUSSION et vit côté serveur (/api/chat/mode) : tous
// les appareils voient le même mode, comme le fil lui-même.
let CURRENT_MODE = 'chat';
function modeSeg(){ return document.getElementById('mode-seg'); }
function paintMode(){
const seg = modeSeg();
if(!seg) return;
seg.querySelector('[data-mode="chat"]').classList.toggle('on', CURRENT_MODE!=='code');
seg.querySelector('[data-mode="code"]').classList.toggle('on', CURRENT_MODE==='code');
// Le panneau des critères n'a de sens qu'en mode code.
const box = document.getElementById('criteria-box');
if(box && CURRENT_MODE!=='code') box.style.display='none';
}
// Bascule demandée depuis CE navigateur.
async function setMode(mode){
if(mode===CURRENT_MODE) return;
try{
const r = await jfetch('/api/chat/mode', {method:'POST', body: JSON.stringify({mode})});
const j = await r.json();
if(j.ok){ CURRENT_MODE = j.mode; paintMode(); hideCodeHint(); }
}catch(e){ toast('bascule impossible : '+e); }
}
// Bascule reçue du serveur (autre appareil, ou écho de la nôtre).
function applyModeDelta(mode){
CURRENT_MODE = mode==='code' ? 'code' : 'chat';
paintMode();
if(CURRENT_MODE==='code') hideCodeHint();
}
// Recharge le mode + critères au chargement et à chaque bascule de discussion.
async function modeOnConvChange(){
try{
const r = await jfetch('/api/chat/mode');
const j = await r.json();
if(!j.ok) return;
CURRENT_MODE = j.mode==='code' ? 'code' : 'chat';
paintMode();
renderCriteria(j.criteria||[]);
hideCodeHint();
}catch(e){}
}
// --- Puce « passer en mode Code ? » -----------------------------------------
// Émise par le serveur (delta code_hint) quand un message de mode chat
// ressemble à une tâche de code. Ignorable, jamais bloquante.
function showCodeHint(){
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
const composer = document.getElementById('composer');
if(!composer) return;
const chip = document.createElement('div');
chip.id = 'code-hint';
chip.innerHTML = 'ça ressemble à une tâche de code — <button type="button" id="code-hint-yes">passer en mode Code</button><button type="button" id="code-hint-no" title="ignorer" aria-label="ignorer">✕</button>';
composer.parentNode.insertBefore(chip, composer);
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
}
function hideCodeHint(){
const chip = document.getElementById('code-hint');
if(chip) chip.remove();
}
// --- Panneau des critères ----------------------------------------------------
// Rendu par les deltas {criteria:[...]} (outil criteria, passe de vérif, ou
// édition manuelle). Repliable ; en-tête = compteur passés / total.
function renderCriteria(list){
let box = document.getElementById('criteria-box');
if(!list || !list.length){ if(box) box.remove(); return; }
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
if(!box){
const composer = document.getElementById('composer');
if(!composer) return;
box = document.createElement('div');
box.id = 'criteria-box';
box.innerHTML = '<div id="criteria-head"></div><ul id="criteria-list"></ul>';
composer.parentNode.insertBefore(box, composer);
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
}
box.style.display='';
const passed = list.filter(c=>c.status==='passed').length;
box.querySelector('#criteria-head').textContent =
'critères ' + passed + '/' + list.length + (passed===list.length ? ' ✓' : '');
box.classList.toggle('allpass', passed===list.length);
const ul = box.querySelector('#criteria-list');
ul.innerHTML='';
for(const c of list){
const li = document.createElement('li');
li.className = 'crit-'+(c.status||'pending');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
ul.appendChild(li);
}
}
function onVerifyDone(status){
if(status==='passed') toast('vérification : tous les critères passent ✓');
}
// --- Carte de question (outil ask) ------------------------------------------
// Le modèle a posé une question structurée et clos son tour : la carte propose
// ses options en boutons ; cliquer envoie la réponse comme un message normal.
function renderAskCard(ask){
if(!ask || !ask.question) return;
removeTyping();
const el = addMsg('assistant', '');
el.classList.add('askcard');
const body = el.querySelector('.body');
body.textContent = ask.question;
if(ask.options && ask.options.length){
const row = document.createElement('div');
row.className = 'askopts';
for(const opt of ask.options){
const b = document.createElement('button');
b.type = 'button';
b.textContent = opt;
b.onclick = ()=>{
// La réponse part comme un message utilisateur ordinaire.
const input = document.getElementById('input');
input.value = opt;
send();
row.querySelectorAll('button').forEach(x=>x.disabled=true);
};
row.appendChild(b);
}
body.appendChild(row);
}
scrollMaybe();
}
// Init : état du mode au chargement de la page.
window.addEventListener('DOMContentLoaded', modeOnConvChange);
+33
View File
@@ -1962,3 +1962,36 @@ html[data-files="1"] #files-btn{color:var(--accent)}
dernière marge. */
.perf-row{margin:0 0 12px}
.perf-sub{padding:0 0 10px}
/* --- Mode Chat/Code (20-mode.js) ------------------------------------------
Sélecteur segmenté dans le pied de la carte du composeur, puce de
suggestion, panneau des critères, badge de rôle, carte de question. */
#mode-seg{display:inline-flex;border:1px solid var(--border);border-radius:3px;overflow:hidden;flex:none}
#mode-seg button{border:0;background:transparent;color:var(--dim);font-size:10px;letter-spacing:.04em;text-transform:uppercase;padding:2px 8px;cursor:pointer;font-family:inherit}
#mode-seg button.on{background:var(--accent-bg);color:var(--accent)}
#mode-seg button:not(.on):hover{color:var(--text)}
#code-hint{display:flex;align-items:center;gap:8px;margin:0 auto 6px;max-width:820px;padding:6px 10px;border:1px solid var(--border);border-left:2px solid var(--accent);border-radius:3px;background:var(--panel);color:var(--dim);font-size:12px}
#code-hint button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit}
#code-hint button:hover{background:var(--accent-bg)}
#code-hint #code-hint-no{border-color:var(--border);color:var(--dim);margin-left:auto}
#criteria-box{margin:0 auto 6px;max-width:820px;border:1px solid var(--border);border-radius:3px;background:var(--panel);font-size:12px;overflow:hidden}
#criteria-box.allpass{border-left:2px solid var(--accent)}
#criteria-head{padding:5px 10px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em}
#criteria-box.allpass #criteria-head{color:var(--accent)}
#criteria-box.folded #criteria-list{display:none}
#criteria-list{list-style:none;margin:0;padding:0 10px 6px}
#criteria-list li{padding:2px 0;color:var(--text)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
#criteria-list li.crit-pending{color:var(--dim)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
.msg[data-agent-role="verifier"]{border-left:2px solid var(--accent)}
.askcard .body{display:flex;flex-direction:column;gap:8px}
.askopts{display:flex;flex-wrap:wrap;gap:6px}
.askopts button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:4px 10px;font-size:13px;cursor:pointer;font-family:inherit}
.askopts button:hover:not(:disabled){background:var(--accent-bg)}
.askopts button:disabled{opacity:.5;cursor:default}
+4
View File
@@ -36,6 +36,10 @@ func capsFromBody(body chatReq) Caps {
if !caps.Agent {
caps.Internet = false
}
// Mode code : réglage PAR DISCUSSION (sélecteur Chat/Code du composeur),
// pas une surcharge de requête — même logique de fermeture que le reste :
// sans agent, pas de mode code.
caps.Code = caps.Agent && convCodeMode()
return caps
}
+7
View File
@@ -47,6 +47,11 @@ func cmdWeb(args []string) error {
}
}
fmt.Printf("[loki web] http://%s (Ctrl-C pour arrêter)\n", addr)
// Perte de données imminente ? Le dire AU DÉMARRAGE, en rouge, dans le
// journal du conteneur — l'endroit qu'on regarde quand ça va mal.
if dv := dataVolumeWarning(); dv != "" {
fmt.Println(red(dv))
}
if readWebKey() == "" {
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
fmt.Printf(" %s\n", bold("loki set-web-key"))
@@ -260,6 +265,8 @@ func newWebMux() *http.ServeMux {
api("/api/conversations/switch", handleConvSwitch)
api("/api/conversations/rename", handleConvRename)
api("/api/conversations/delete", handleConvDelete)
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)