mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à 9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle avant l'exécution, attente du rechargement), accès mémoire et web réglables, interrupteur maître pour tout suspendre, bouton « tester maintenant ». Le planificateur est une goroutine, un tic par minute, dans le process qui détient la conversation et parle au moteur. Une seule tâche part par tic : de toute façon une seule inférence tourne à la fois, et étaler les départs évite qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement n'est pas un échec — la tâche repasse au tic suivant. Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et le jette, ne gardant que son texte final comme compte-rendu (borné à 4000 caractères, réinjecté au passage suivant pour la continuité). Deux adaptations, parce que loki n'est pas l'amont : — Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y aurait déposé ses fichiers, et en aurait changé en cours de route si l'utilisateur changeait de discussion — pour disparaître avec elle à la suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages continuent de suivre la discussion de l'utilisateur. — Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le mode code reste off : rôles, critères et passe de vérification n'ont pas de sens sans personne en face. Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe le modèle : « génération en cours » sur un fil vide et immobile n'expliquait rien. Interface : section repliable dans les réglages (liste, état, prochain passage, pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau « dernier résultat » en markdown. Vérifié dans un vrai navigateur — création, rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître, suppression — sans une seule erreur JS.
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 := agentCwd()
|
|
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 := agentCwd()
|
|
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
|
|
}
|