From 901d518f708043356577ec7b40f7815f1d5b96b4 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 18 Aug 2026 21:57:46 +0200 Subject: [PATCH] =?UTF-8?q?Mode=20Code=20:=20agent=20de=20code=20avec=20cr?= =?UTF-8?q?it=C3=A8res,=20v=C3=A9rification=20et=20LSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:…, …) relance le tour une fois avec consigne corrective. - Outil ask : question à choix rendue en carte à boutons. Co-Authored-By: Claude Opus 5 --- Dockerfile | 9 + NOTICE.md | 8 + README.md | 30 ++ internal/loki/agents/builder.md | 13 + internal/loki/agents/code-reviewer.md | 5 + internal/loki/agents/explorer.md | 3 + internal/loki/agents/planner.md | 8 + internal/loki/agents/verifier.md | 12 + internal/loki/chat_conversation.go | 29 ++ internal/loki/chat_sessions.go | 5 + internal/loki/chat_tools.go | 38 +- internal/loki/code_criteria.go | 205 +++++++++++ internal/loki/code_criteria_test.go | 73 ++++ internal/loki/code_git.go | 149 ++++++++ internal/loki/code_jobs.go | 191 ++++++++++ internal/loki/code_mode.go | 181 ++++++++++ internal/loki/code_mode_test.go | 74 ++++ internal/loki/code_policy.go | 160 +++++++++ internal/loki/code_policy_test.go | 101 ++++++ internal/loki/code_retry.go | 84 +++++ internal/loki/code_retry_test.go | 34 ++ internal/loki/code_roles.go | 44 +++ internal/loki/code_tools.go | 373 +++++++++++++++++++ internal/loki/code_tools_test.go | 159 +++++++++ internal/loki/code_tracker.go | 67 ++++ internal/loki/code_verify.go | 192 ++++++++++ internal/loki/llm_client.go | 107 +++++- internal/loki/lsp.go | 416 ++++++++++++++++++++++ internal/loki/lsp_languages.json | 17 + internal/loki/sys_platform_unix.go | 6 + internal/loki/sys_platform_windows.go | 6 + internal/loki/ui/index.html | 200 ++++++++++- internal/loki/ui/src/index.tmpl.html | 8 + internal/loki/ui/src/js/08-chat-render.js | 9 + internal/loki/ui/src/js/09-stream.js | 9 +- internal/loki/ui/src/js/20-mode.js | 141 ++++++++ internal/loki/ui/src/styles.css | 33 ++ internal/loki/web_chat.go | 4 + internal/loki/web_server.go | 7 + 39 files changed, 3202 insertions(+), 8 deletions(-) create mode 100644 internal/loki/agents/builder.md create mode 100644 internal/loki/agents/code-reviewer.md create mode 100644 internal/loki/agents/explorer.md create mode 100644 internal/loki/agents/planner.md create mode 100644 internal/loki/agents/verifier.md create mode 100644 internal/loki/code_criteria.go create mode 100644 internal/loki/code_criteria_test.go create mode 100644 internal/loki/code_git.go create mode 100644 internal/loki/code_jobs.go create mode 100644 internal/loki/code_mode.go create mode 100644 internal/loki/code_mode_test.go create mode 100644 internal/loki/code_policy.go create mode 100644 internal/loki/code_policy_test.go create mode 100644 internal/loki/code_retry.go create mode 100644 internal/loki/code_retry_test.go create mode 100644 internal/loki/code_roles.go create mode 100644 internal/loki/code_tools.go create mode 100644 internal/loki/code_tools_test.go create mode 100644 internal/loki/code_tracker.go create mode 100644 internal/loki/code_verify.go create mode 100644 internal/loki/lsp.go create mode 100644 internal/loki/lsp_languages.json create mode 100644 internal/loki/ui/src/js/20-mode.js diff --git a/Dockerfile b/Dockerfile index 975fff4..9090cd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/NOTICE.md b/NOTICE.md index 47da96d..77cd63e 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -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 diff --git a/README.md b/README.md index be8270a..e2804e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/loki/agents/builder.md b/internal/loki/agents/builder.md new file mode 100644 index 0000000..60a47e0 --- /dev/null +++ b/internal/loki/agents/builder.md @@ -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. diff --git a/internal/loki/agents/code-reviewer.md b/internal/loki/agents/code-reviewer.md new file mode 100644 index 0000000..40c94bc --- /dev/null +++ b/internal/loki/agents/code-reviewer.md @@ -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. diff --git a/internal/loki/agents/explorer.md b/internal/loki/agents/explorer.md new file mode 100644 index 0000000..da3b95f --- /dev/null +++ b/internal/loki/agents/explorer.md @@ -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. diff --git a/internal/loki/agents/planner.md b/internal/loki/agents/planner.md new file mode 100644 index 0000000..543193a --- /dev/null +++ b/internal/loki/agents/planner.md @@ -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. diff --git a/internal/loki/agents/verifier.md b/internal/loki/agents/verifier.md new file mode 100644 index 0000000..e17e899 --- /dev/null +++ b/internal/loki/agents/verifier.md @@ -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. diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 10c30e2..a20119f 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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 diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index 649f14b..b31c963 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -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. diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index b309016..e236ea2 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -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 { diff --git a/internal/loki/code_criteria.go b/internal/loki/code_criteria.go new file mode 100644 index 0000000..c6e694e --- /dev/null +++ b/internal/loki/code_criteria.go @@ -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:) : 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()}) +} diff --git a/internal/loki/code_criteria_test.go b/internal/loki/code_criteria_test.go new file mode 100644 index 0000000..a492318 --- /dev/null +++ b/internal/loki/code_criteria_test.go @@ -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) + } +} diff --git a/internal/loki/code_git.go b/internal/loki/code_git.go new file mode 100644 index 0000000..a6f76d0 --- /dev/null +++ b/internal/loki/code_git.go @@ -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) +} diff --git a/internal/loki/code_jobs.go b/internal/loki/code_jobs.go new file mode 100644 index 0000000..58d871a --- /dev/null +++ b/internal/loki/code_jobs.go @@ -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/.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) + } + } +} diff --git a/internal/loki/code_mode.go b/internal/loki/code_mode.go new file mode 100644 index 0000000..41aa594 --- /dev/null +++ b/internal/loki/code_mode.go @@ -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}) +} diff --git a/internal/loki/code_mode_test.go b/internal/loki/code_mode_test.go new file mode 100644 index 0000000..dce8b12 --- /dev/null +++ b/internal/loki/code_mode_test.go @@ -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") + } +} diff --git a/internal/loki/code_policy.go b/internal/loki/code_policy.go new file mode 100644 index 0000000..d345a43 --- /dev/null +++ b/internal/loki/code_policy.go @@ -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) +} diff --git a/internal/loki/code_policy_test.go b/internal/loki/code_policy_test.go new file mode 100644 index 0000000..2535cd8 --- /dev/null +++ b/internal/loki/code_policy_test.go @@ -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) + } +} diff --git a/internal/loki/code_retry.go b/internal/loki/code_retry.go new file mode 100644 index 0000000..9797a1a --- /dev/null +++ b/internal/loki/code_retry.go @@ -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`, +// `{...}`, 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_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." diff --git a/internal/loki/code_retry_test.go b/internal/loki/code_retry_test.go new file mode 100644 index 0000000..b2c02c0 --- /dev/null +++ b/internal/loki/code_retry_test.go @@ -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{ + "{\"name\":\"bash\"}", + "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) + } + } +} diff --git a/internal/loki/code_roles.go b/internal/loki/code_roles.go new file mode 100644 index 0000000..342c27f --- /dev/null +++ b/internal/loki/code_roles.go @@ -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 +} diff --git a/internal/loki/code_tools.go b/internal/loki/code_tools.go new file mode 100644 index 0000000..48b20c9 --- /dev/null +++ b/internal/loki/code_tools.go @@ -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 +} diff --git a/internal/loki/code_tools_test.go b/internal/loki/code_tools_test.go new file mode 100644 index 0000000..d2e5563 --- /dev/null +++ b/internal/loki/code_tools_test.go @@ -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) + } +} diff --git a/internal/loki/code_tracker.go b/internal/loki/code_tracker.go new file mode 100644 index 0000000..7d01010 --- /dev/null +++ b/internal/loki/code_tracker.go @@ -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 "" +} diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go new file mode 100644 index 0000000..ce1e94f --- /dev/null +++ b/internal/loki/code_verify.go @@ -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)" +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 3d0b41a..9085b3e 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -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. diff --git a/internal/loki/lsp.go b/internal/loki/lsp.go new file mode 100644 index 0000000..f093b5a --- /dev/null +++ b/internal/loki/lsp.go @@ -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). diff --git a/internal/loki/lsp_languages.json b/internal/loki/lsp_languages.json new file mode 100644 index 0000000..c6a389d --- /dev/null +++ b/internal/loki/lsp_languages.json @@ -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"] + } +] diff --git a/internal/loki/sys_platform_unix.go b/internal/loki/sys_platform_unix.go index 1e708da..68b5f74 100644 --- a/internal/loki/sys_platform_unix.go +++ b/internal/loki/sys_platform_unix.go @@ -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 diff --git a/internal/loki/sys_platform_windows.go b/internal/loki/sys_platform_windows.go index 1e392bc..d0fd52d 100644 --- a/internal/loki/sys_platform_windows.go +++ b/internal/loki/sys_platform_windows.go @@ -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 { diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index abed389..6c4ac64 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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} +
+ + +
+
+ + +