mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Sélecteur Chat|Code par discussion dans le pied du composeur ; en mode chat, une détection serveur suggère la bascule (puce ignorable, jamais automatique). Conception reprise d'OpenFox (MIT), réécrite en Go — voir NOTICE.md. - Outils fichiers : read (lignes numérotées, borné), grep, glob, et le tracker « lu avant d'écrire » qui refuse write/edit sur un fichier non lu ou modifié depuis la lecture ; edit préserve les fins de ligne CRLF. - Politique d'exécution : commandes catastrophiques refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la discussion en mode code, mutex par fichier. - Critères d'acceptation : contrat posé via l'outil criteria (éditable dans l'UI), passe de vérification indépendante sur contexte isolé — seule habilitée à marquer « passed » — puis corrections plafonnées. - Rôles embarqués (agents/*.md) : builder, planner, verifier, explorer, code-reviewer ; badge de rôle dans le fil. - LSP : gopls / typescript-language-server / pyright (inclus dans l'image), diagnostics injectés dans le retour de write/edit. - Git natif : git_status, git_diff, git_clone (borné à la discussion). - Jobs d'arrière-plan bash_bg/bash_tail (serveur de dev, build long). - Auto-retry : un appel d'outil écrit en texte (default_api:…, <tool_call>…) relance le tour une fois avec consigne corrective. - Outil ask : question à choix rendue en carte à boutons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
374 lines
10 KiB
Go
374 lines
10 KiB
Go
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
|
|
}
|