From 74fb1fc78138e8c1607c8fc1fc4c9673db5db76f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:33:39 +0000 Subject: [PATCH] =?UTF-8?q?T=C3=A2ches=20:=20scripts=20planifi=C3=A9s=20sa?= =?UTF-8?q?ns=20mod=C3=A8le,=20et=20l'IA=20planifie=20la=20sienne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux reprises d'AJEAN autour des tâches planifiées. Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory, hors du workspace. Le workspace est jetable — supprimer une discussion emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses scripts normalement. Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script sans charger le modèle ni consommer un token, sa sortie devient le compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne hors du verrou de génération — d'où un registre à part pour l'afficher « en cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale. Outils task_list/create/update/delete : l'IA se donne elle-même rappels et veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000 caractères, avec le palier documenté dans le test. Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et c'est la condition d'un chiffrement de la mémoire à venir. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY --- README.md | 13 +- internal/loki/chat_prompt_size_test.go | 23 +- internal/loki/chat_scripts.go | 175 +++++++++++++++ internal/loki/chat_scripts_test.go | 103 +++++++++ internal/loki/chat_tools.go | 25 ++- internal/loki/llm_client.go | 14 ++ internal/loki/run.go | 6 + internal/loki/sys_paths.go | 3 + internal/loki/tasks.go | 25 ++- internal/loki/tasks_run.go | 8 + internal/loki/tasks_script.go | 134 ++++++++++++ internal/loki/tasks_tools.go | 281 +++++++++++++++++++++++++ internal/loki/ui/index.html | 85 +++++++- internal/loki/ui/src/index.tmpl.html | 23 ++ internal/loki/ui/src/js/21-tasks.js | 62 +++++- internal/loki/web_server.go | 13 +- internal/loki/web_tasks.go | 76 ++++++- 17 files changed, 1043 insertions(+), 26 deletions(-) create mode 100644 internal/loki/chat_scripts.go create mode 100644 internal/loki/chat_scripts_test.go create mode 100644 internal/loki/tasks_script.go create mode 100644 internal/loki/tasks_tools.go diff --git a/README.md b/README.md index 71fbb84..84fe152 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ et modèles compris. | `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages | | `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) | | `/data/models/` | modèles téléchargés depuis l'interface | -| `/data/memory/` | pages de mémoire persistante (`.md`) | +| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell | +| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) | | `/data/workspace/` | racine du dossier de travail de l'agent | | `/data/workspace/discussions//` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit | | `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) | @@ -215,7 +216,15 @@ Héritées d'AJEAN : activer avant l'exécution, accès mémoire et web. Un **interrupteur maître** suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une - tâche attend son tour, et le bouton stop du chat l'interrompt. + tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de + tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier + `/data/scripts` lancé directement, **sans charger le modèle ni consommer un + token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce + dossier vit **hors du workspace** : vider une discussion, ou la supprimer, + n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`, + `task_update` et `task_delete` — elle peut donc se poser ses propres rappels + et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne + pilote que les tâches de ce projet. - **Presets** de configuration par modèle, bench, auto-détection GPU. - **Échantillonnage réglable par preset** : température, `top_p`, `top_k`, `min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces diff --git a/internal/loki/chat_prompt_size_test.go b/internal/loki/chat_prompt_size_test.go index 522054b..34b9d6d 100644 --- a/internal/loki/chat_prompt_size_test.go +++ b/internal/loki/chat_prompt_size_test.go @@ -26,10 +26,27 @@ import ( // et recherches déjà faites. Il récupère bien plus qu'il ne coûte. // 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre // actions — quatre outils auraient coûté quatre fois ça. +// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se +// donne elle-même ses rappels et ses veilles ; sans eux il fallait +// passer par l'interface. Descriptions déjà au plus court, et un +// seul schéma par action (create/update/delete ne partagent pas +// assez d'arguments pour fusionner sans rendre chacun ambigu). +// Les ~500 car restants couvrent web_screenshot, déclaré dès que +// Playwright est présent — c'est le cas dans l'image, pas sur le +// runner de CI : le budget doit tenir pour l'image, la vraie. +// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses +// schémas n'existent que si on arme son interrupteur.) // -// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne -// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt. -const promptCharBudget = 8600 // ~2150 tokens, tout allumé +// Les ~200 car de prompt qui les accompagnent sont le seul écart +// assumé à la règle « pas de relèvement pour du prompt » : un outil +// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et +// la ligne dit ce que les schémas ne disent pas (quand se planifier, +// et qu'un script planifié ne charge aucun modèle). Elle a été +// ramenée de trois lignes à une. +// +// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient +// pour la suite : ce budget ne se relève pas pour du prompt. +const promptCharBudget = 11000 // ~2750 tokens, tout allumé func TestSystemPromptStaysLean(t *testing.T) { caps := Caps{Agent: true, Internet: true, Mem: MemAlways} diff --git a/internal/loki/chat_scripts.go b/internal/loki/chat_scripts.go new file mode 100644 index 0000000..0b9a56f --- /dev/null +++ b/internal/loki/chat_scripts.go @@ -0,0 +1,175 @@ +package loki + +// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent. +// +// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou +// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() = +// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage — +// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace +// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests, +// fichiers temporaires). +// +// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/ +// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le +// SEUL accès réservé est le dossier memory, joignable uniquement par les outils +// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le +// protéger d'une suppression. + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS +// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu). +// Renvoie une erreur plutôt qu'un chemin hors périmètre. +func scriptsPath(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("nom de script vide") + } + // On interdit les chemins absolus et la remontée : un script vit dans + // scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui + // commence par un séparateur (« /etc/… ») est rejeté explicitement : sous + // Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là. + if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) { + return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name) + } + // Caractères hostiles au shell : le nom finit dans une ligne de commande + // (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait + // en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser + // « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser + // partout rend le comportement identique sur les deux plateformes. Un nom de + // fichier n'a de toute façon besoin d'aucun de ces caractères. + if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 { + return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name) + } + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name) + } + full := filepath.Join(scriptsDir(), clean) + // Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir. + if !underDir(full, scriptsDir()) { + return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name) + } + return full, nil +} + +// scriptExists valide qu'un nom désigne bien un fichier script existant dans le +// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ». +func scriptExists(name string) error { + full, err := scriptsPath(name) + if err != nil { + return err + } + fi, err := os.Stat(full) + if err != nil { + return fmt.Errorf("script introuvable : %s", name) + } + if fi.IsDir() { + return fmt.Errorf("%s est un dossier, pas un script", name) + } + return nil +} + +// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec +// leur taille), triés. Ignore les dossiers. +func listScripts() ([]scriptInfo, error) { + root := scriptsDir() + var out []scriptInfo + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return nil // dossier absent ou illisible : on renvoie ce qu'on a + } + if d.IsDir() { + return nil + } + rel, rerr := filepath.Rel(root, p) + if rerr != nil { + return nil + } + size := int64(0) + if fi, ferr := d.Info(); ferr == nil { + size = fi.Size() + } + out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size}) + return nil + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, err +} + +type scriptInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni +// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par +// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go +// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne +// sont pas concernés par ces gardes. +// +// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts +// librement (avec write/bash). Sa durabilité vient seulement de sa séparation +// d'avec le workspace jetable, pas d'un verrou. +func toolOnlyDirs() []string { return []string{memoryDir()} } + +// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct +// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs). +func toolOnlyLabel() string { + return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)" +} + +// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé +// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé. +func guardToolOnlyPath(path string) string { + for _, d := range toolOnlyDirs() { + if underDir(path, d) { + return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel()) + } + } + return "" +} + +// guardToolOnlyCommand refuse une commande shell qui référence un dossier +// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE +// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé. +func guardToolOnlyCommand(command string) string { + lc := strings.ToLower(command) + for _, d := range toolOnlyDirs() { + if strings.Contains(lc, strings.ToLower(normPath(d))) { + return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel()) + } + } + return "" +} + +// underDir indique si path est égal à dir ou situé dessous, après nettoyage et +// (sous Windows) insensibilité à la casse. +func underDir(path, dir string) bool { + a := normPath(path) + b := normPath(dir) + if a == b { + return true + } + return strings.HasPrefix(a, b+string(filepath.Separator)) +} + +// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé, +// et minusculé sous Windows (système de fichiers insensible à la casse). +func normPath(p string) string { + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + p = filepath.Clean(p) + if runtime.GOOS == "windows" { + p = strings.ToLower(p) + } + return p +} diff --git a/internal/loki/chat_scripts_test.go b/internal/loki/chat_scripts_test.go new file mode 100644 index 0000000..b62c7ad --- /dev/null +++ b/internal/loki/chat_scripts_test.go @@ -0,0 +1,103 @@ +package loki + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/ +// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre. +func TestGuardToolOnly(t *testing.T) { + home := t.TempDir() + t.Setenv("LOKI_HOME", home) + + // Shell : toute mention du dossier memory est refusée (lecture comprise). Le + // dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute). + blockedCmds := []string{ + "cat " + memoryDir() + "/note.md", + "echo x > " + memoryDir() + "/x", + } + for _, c := range blockedCmds { + if msg := guardToolOnlyCommand(c); msg == "" { + t.Errorf("commande aurait dû être refusée : %q", c) + } + } + allowedCmds := []string{ + "ls " + workspaceDir(), + "bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé + "ls " + scriptsDir(), + } + for _, c := range allowedCmds { + if msg := guardToolOnlyCommand(c); msg != "" { + t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg) + } + } + + // write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non. + if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" { + t.Errorf("écriture directe dans memory aurait dû être refusée") + } + for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} { + if msg := guardToolOnlyPath(p); msg != "" { + t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg) + } + } +} + +// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni +// glisser un caractère hostile au shell dans le nom. +func TestScriptsPathEscape(t *testing.T) { + home := t.TempDir() + t.Setenv("LOKI_HOME", home) + + bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"} + for _, name := range bad { + if _, err := scriptsPath(name); err == nil { + t.Errorf("aurait dû refuser le nom : %q", name) + } + } + good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"} + for _, name := range good { + p, err := scriptsPath(name) + if err != nil { + t.Errorf("aurait dû accepter %q : %v", name, err) + continue + } + if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) { + t.Errorf("%q résolu hors de scriptsDir : %s", name, p) + } + } +} + +// TestScriptExists couvre la validation d'existence utilisée par les tâches script. +func TestScriptExists(t *testing.T) { + home := t.TempDir() + t.Setenv("LOKI_HOME", home) + + if err := scriptExists("hello.sh"); err == nil { + t.Fatalf("un script absent aurait dû échouer") + } + full := filepath.Join(scriptsDir(), "hello.sh") + if err := os.MkdirAll(scriptsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := scriptExists("hello.sh"); err != nil { + t.Fatalf("scriptExists : %v", err) + } + list, _ := listScripts() + if len(list) != 1 || list[0].Name != "hello.sh" { + t.Fatalf("list inattendu : %+v", list) + } + // Un dossier n'est pas un script. + if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil { + t.Fatal(err) + } + if err := scriptExists("d"); err == nil { + t.Fatalf("un dossier ne doit pas passer pour un script") + } +} diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index fbf9037..b4faf09 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -89,6 +89,13 @@ func baseSystemPrompt(caps Caps) string { // un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale // à connaître pour le modèle — juste [texte](chemin). b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n") + // Auto-planification : les schémas task_* partent déjà dans la requête, donc + // on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en + // servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit) + // et le fait qu'un script planifié tourne sans modèle, que les schémas ne + // disent pas. Une ligne, pas trois : le budget du préambule est compté + // (TestSystemPromptStaysLean). + b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n") if caps.Mem == MemAlways { b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n") } @@ -180,7 +187,7 @@ func machineSystemPrompt(caps Caps) string { // EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux // que d'interdire d'en sortir — et nommer les dossiers système coupe court // aux « installations » en /usr/local/bin qui échouent faute de root. - b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.") + b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.") } return b.String() } @@ -195,6 +202,14 @@ func machineSystemPrompt(caps Caps) string { // génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du // délai (5 minutes au maximum), bouton stop sans effet. func runShell(parent context.Context, command string, timeoutSec int) string { + // Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell + // (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent + // tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et + // laissait le modèle croire qu'il avait lu une page que la mémoire, elle, + // n'avait pas servie. + if msg := guardToolOnlyCommand(command); msg != "" { + return msg + } if timeoutSec <= 0 { timeoutSec = toolDefaultTimeout } @@ -277,6 +292,10 @@ func fileWrite(path, content string) string { return "[erreur] chemin vide" } path = resolveAgentPath(path) + // Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe. + if msg := guardToolOnlyPath(path); msg != "" { + return msg + } // Sérialise les écritures concurrentes sur un même fichier (code_policy.go). mu := fileMu(path) mu.Lock() @@ -315,6 +334,10 @@ func fileEdit(path, oldText, newText string) string { return "[erreur] old vide" } path = resolveAgentPath(path) + // Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe. + if msg := guardToolOnlyPath(path); msg != "" { + return msg + } // Sérialise les écritures concurrentes sur un même fichier (code_policy.go). mu := fileMu(path) mu.Lock() diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 1af2f22..575b2b9 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -336,6 +336,12 @@ func EnabledTools(caps Caps) []Tool { // n'archive des blocs QUE quand le mode agent est actif — annoncer recall // sans lui donnerait un outil qui ne peut rien trouver. tools = append(tools, recallTool(), recallSearchTool()) + // Tâches planifiées : l'IA se donne elle-même des rappels et des veilles + // récurrentes, et gère les siennes (tasks_tools.go). Cloisonnées par + // projet : dans un projet, elle ne voit et ne pilote QUE ses tâches. + // Réservé au mode agent — une tâche sans outils pour agir n'aurait rien + // à livrer. + tools = append(tools, taskListTool(), taskCreateTool(), taskUpdateTool(), taskDeleteTool()) } if caps.Code { tools = append(tools, readTool(), grepTool(), globTool(), askTool(), @@ -1445,6 +1451,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = capWebOutput(toolWebGrep(args)) case "web_screenshot": result = toolWebScreenshot(args, caps) + case "task_list": + result = toolTaskList(args) + case "task_create": + result = toolTaskCreate(args) + case "task_update": + result = toolTaskUpdate(args) + case "task_delete": + result = toolTaskDelete(args) case "browser_open": result = capCUOutput(toolCUOpen(args)) case "browser_snapshot": diff --git a/internal/loki/run.go b/internal/loki/run.go index 0b82c61..4ef9e28 100644 --- a/internal/loki/run.go +++ b/internal/loki/run.go @@ -243,6 +243,12 @@ func memoryDir() string { return projectMemoryDir(activeProjectSlug()) } func modelsDir() string { return filepath.Join(LokiHome(), "models") } func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") } +// scriptsDir est le dossier des scripts de l'agent : à côté de memory/presets, +// HORS du workspace. Le workspace est un bac à sable jetable (clones, tests, +// fichiers d'une discussion) qu'un nettoyage — ou la suppression de la +// discussion — peut raser ; les scripts qu'on veut CONSERVER vivent ici. +func scriptsDir() string { return filepath.Join(LokiHome(), "scripts") } + // serviceName est le nom de l'unité qui exécute llama-server. Son pendant est // uiUnitName (« loki-ui »), qui sert l'interface et le tunnel. func serviceName() string { diff --git a/internal/loki/sys_paths.go b/internal/loki/sys_paths.go index 829e4e7..00a7ed3 100644 --- a/internal/loki/sys_paths.go +++ b/internal/loki/sys_paths.go @@ -22,6 +22,7 @@ type lokiPaths struct { Installed string `json:"installed"` // binaire installé (peut différer de Exe) Workspace string `json:"workspace"` // dossier de travail du mode agent Memory string `json:"memory"` + Scripts string `json:"scripts"` // scripts durables de l'agent (hors workspace jetable) Presets string `json:"presets"` Models string `json:"models"` Backends string `json:"backends"` @@ -50,6 +51,7 @@ func currentPaths() lokiPaths { Installed: installedExePath(), Workspace: agentWorkspace(), Memory: memoryDir(), + Scripts: scriptsDir(), Presets: presetsDir(), Models: modelsDir(), Backends: backendsDir(), @@ -66,6 +68,7 @@ func cmdWhere(args []string) error { {"binaire installé", p.Installed}, {"travail de l'agent", p.Workspace}, {"mémoire", p.Memory}, + {"scripts", p.Scripts}, {"presets", p.Presets}, {"modèles", p.Models}, {"backends", p.Backends}, diff --git a/internal/loki/tasks.go b/internal/loki/tasks.go index 2ced12b..efef5e9 100644 --- a/internal/loki/tasks.go +++ b/internal/loki/tasks.go @@ -31,6 +31,12 @@ type Task struct { // chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide // sur les tâches d'avant les projets : traitées comme « Générale ». Project string `json:"project,omitempty"` + // Kind distingue une tâche IA d'une tâche script. Vide ou "agent" = Prompt est + // une consigne exécutée par le modèle (RunAutonomous). "script" = on lance + // directement le script Script du dossier protégé, SANS charger le modèle ni + // consommer de tokens. Le zéro JSON ("") garde le comportement historique (IA). + Kind string `json:"kind,omitempty"` + Script string `json:"script,omitempty"` // pour Kind=="script" : nom du script dans scriptsDir() // Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON // (tâches créées avant ces champs) garde le comportement historique : accès. NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche @@ -75,9 +81,11 @@ func listTasks() []Task { } // taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut : -// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. +// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. Un +// projet SUPPRIMÉ depuis retombe de même sur « Générale » : sans ça, la tâche +// restait rattachée à un slug fantôme et n'apparaissait plus dans aucun projet. func taskProjectOf(t Task) string { - if s := strings.TrimSpace(t.Project); s != "" { + if s := strings.TrimSpace(t.Project); s != "" && projectExists(s) { return s } return defaultProjectSlug @@ -101,6 +109,19 @@ func tagOrphanTasks(slug string) { } } +// listTasksForProject ne renvoie que les tâches rattachées au projet `slug` +// (mêmes tri et résolution que listTasks). Sert à cloisonner les outils task_* +// exposés à l'IA : dans un projet, elle ne voit et ne pilote QUE ses tâches. +func listTasksForProject(slug string) []Task { + var out []Task + for _, t := range listTasks() { + if taskProjectOf(t) == slug { + out = append(out, t) + } + } + return out +} + // getTask lit une tâche par ID. Renvoie false si absente. func getTask(id string) (Task, bool) { var t Task diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 20df3a6..8b55f1c 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -149,6 +149,14 @@ const reportMax = 4000 func runTask(t Task) { start := time.Now() + // Tâche « script seul » : aucun modèle à charger, aucun token consommé — on + // lance le script du dossier protégé et sa sortie devient le compte-rendu. + // Testé AVANT le preset : basculer le moteur pour un script serait absurde. + if t.Kind == "script" { + runScriptTask(t) + return + } + // Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement // du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai // échec de la tâche : la faire tourner sur le mauvais modèle serait pire. diff --git a/internal/loki/tasks_script.go b/internal/loki/tasks_script.go new file mode 100644 index 0000000..75aa311 --- /dev/null +++ b/internal/loki/tasks_script.go @@ -0,0 +1,134 @@ +package loki + +// tasks_script.go — exécution d'une tâche « script seul » : on lance un script du +// dossier scriptsDir(), SANS charger le modèle ni consommer de tokens. La sortie +// du script devient le compte-rendu, exactement comme pour une tâche IA, si bien +// que l'UI et les outils task_* l'affichent sans traitement particulier. +// +// Une tâche script ne passe PAS par le verrou de génération de la conversation +// (elle n'utilise pas le modèle), donc conv.runningTaskID ne la voit pas. On tient +// donc un petit registre à part (scriptRunning) pour que l'UI l'affiche « en +// cours » et puisse l'arrêter. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +// scriptTaskTimeout borne l'exécution d'un script planifié. Généreux (un backup ou +// une synchro peut durer), mais fini : une tâche coincée ne doit pas bloquer le +// planificateur à vie. +const scriptTaskTimeout = 10 * time.Minute + +// scriptRunning suit les tâches script en cours (id → cancel + nom), pour que +// handleTasks les signale « en cours » et que l'arrêt (/api/tasks/stop) puisse +// annuler leur contexte. +var scriptRunning = struct { + mu sync.Mutex + cancel map[string]context.CancelFunc + name map[string]string +}{cancel: map[string]context.CancelFunc{}, name: map[string]string{}} + +func scriptRunBegin(id, name string, cancel context.CancelFunc) { + scriptRunning.mu.Lock() + scriptRunning.cancel[id] = cancel + scriptRunning.name[id] = name + scriptRunning.mu.Unlock() +} + +func scriptRunEnd(id string) { + scriptRunning.mu.Lock() + delete(scriptRunning.cancel, id) + delete(scriptRunning.name, id) + scriptRunning.mu.Unlock() +} + +// scriptRunningAny renvoie l'id et le nom d'une tâche script en cours (la +// première trouvée), ou "","" si aucune. Le scheduler ne lance qu'une tâche à la +// fois, donc en pratique il y en a au plus une. +func scriptRunningAny() (string, string) { + scriptRunning.mu.Lock() + defer scriptRunning.mu.Unlock() + for id, n := range scriptRunning.name { + return id, n + } + return "", "" +} + +// scriptRunStop annule la tâche script en cours d'id `id`. Renvoie true si une +// exécution correspondante a été trouvée et annulée. +func scriptRunStop(id string) bool { + scriptRunning.mu.Lock() + c := scriptRunning.cancel[id] + scriptRunning.mu.Unlock() + if c != nil { + c() + return true + } + return false +} + +// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask +// pour une tâche IA), mais sans aucune inférence. +func runScriptTask(t Task) { + start := time.Now() + + name := strings.TrimSpace(t.Script) + if name == "" { + recordTaskEnd(t.ID, start, "", fmt.Errorf("aucun script associé à la tâche")) + return + } + full, err := scriptsPath(name) + if err != nil { + recordTaskEnd(t.ID, start, "", err) + return + } + if _, err := os.Stat(full); err != nil { + recordTaskEnd(t.ID, start, "", fmt.Errorf("script introuvable : %s", name)) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), scriptTaskTimeout) + defer cancel() + // Registre : rend la tâche visible « en cours » dans l'UI et arrêtable via + // /api/tasks/stop (qui appelle scriptRunStop → cancel). + scriptRunBegin(t.ID, t.Name, cancel) + defer scriptRunEnd(t.ID) + + // On réutilise runShell : garde-fous, dossier de travail, troncature de sortie + // et gestion du timeout y sont déjà. La sortie formatée ("exit: N ...") sert à + // la fois de compte-rendu et de signal de succès. + out := runShell(ctx, scriptRunCommand(full), int(scriptTaskTimeout/time.Second)) + switch { + case ctx.Err() == context.Canceled: + // Arrêt volontaire (bouton stop) : recordTaskEnd l'affiche « interrompue » + // sans allumer l'indicateur d'échec. + recordTaskEnd(t.ID, start, "", context.Canceled) + case strings.HasPrefix(out, "exit: 0"): + recordTaskEnd(t.ID, start, out, nil) + default: + // Sortie non nulle, timeout ou erreur de lancement : échec, avec la sortie + // complète comme message d'erreur. + recordTaskEnd(t.ID, start, "", fmt.Errorf("%s", out)) + } +} + +// scriptRunCommand construit la ligne de commande qui exécute le script `path` +// selon la plateforme et l'extension. Sous Unix, on lance le fichier directement +// (il est écrit en 0755, le shebang choisit l'interpréteur). Sous Windows, un .ps1 +// passe par PowerShell ; le reste (.bat/.cmd/.exe) se lance tel quel via cmd. Le +// nom de script est déjà bridé par scriptsPath (pas de guillemet ni de métacaractère +// shell), donc l'entourer de guillemets suffit. +func scriptRunCommand(path string) string { + q := `"` + path + `"` + if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".ps1") { + return `powershell -NoProfile -ExecutionPolicy Bypass -File ` + q + } + return q +} diff --git a/internal/loki/tasks_tools.go b/internal/loki/tasks_tools.go new file mode 100644 index 0000000..760e95a --- /dev/null +++ b/internal/loki/tasks_tools.go @@ -0,0 +1,281 @@ +package loki + +// tasks_tools.go — outils task_* exposés au modèle pour qu'il gère LUI-MÊME ses +// tâches planifiées (voir tasks.go) : se donner un rappel récurrent, une veille, +// un ménage périodique… puis les lister, les ajuster ou les supprimer. Ils +// réutilisent exactement l'infra de l'UI (validateSchedule, computeNextRun, +// saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les +// autres dans le panneau Tâches. +// +// Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si +// l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour +// livrer son résultat. + +import ( + "fmt" + "strings" + "time" +) + +// str extrait une chaîne d'un argument d'outil décodé en any ("" si absent ou +// d'un autre type). +func taskStrArg(v any) string { + s, _ := v.(string) + return s +} + +func taskListTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "task_list", + Description: "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string", "description": "Show only this task, with its full last report"}, + }, + }, + }, + } +} + +func taskCreateTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "task_create", + Description: "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "prompt": map[string]any{"type": "string", "description": "AI task: self-contained instruction to run each time (omit if using script)"}, + "script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"}, + // Le format du schedule est le seul point que le modèle ne peut pas + // deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil. + "schedule": map[string]any{"type": "string", "description": "\"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\""}, + "enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"}, + }, + "required": []string{"name", "schedule"}, + }, + }, + } +} + +func taskUpdateTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "task_update", + Description: "Change one or more fields of a scheduled task by id.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "prompt": map[string]any{"type": "string"}, + "schedule": map[string]any{"type": "string"}, + "enabled": map[string]any{"type": "boolean", "description": "false pauses, true resumes"}, + }, + "required": []string{"id"}, + }, + }, + } +} + +func taskDeleteTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "task_delete", + Description: "Delete a scheduled task by id.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + "required": []string{"id"}, + }, + }, + } +} + +// defaultTaskTZ : le modèle n'a pas le fuseau du navigateur. On reprend celui +// d'une tâche existante (posé par l'UI depuis le navigateur) pour que « tous les +// jours à 9h » tombe à la bonne heure ; à défaut, fuseau vide = heure locale du +// serveur (souvent UTC), ce que la description du planificateur assume déjà. +func defaultTaskTZ() string { + for _, t := range listTasks() { + if strings.TrimSpace(t.TZ) != "" { + return t.TZ + } + } + return "" +} + +// toolTaskList formate les tâches pour le modèle. Avec un id, on ne montre QUE +// cette tâche, compte-rendu COMPLET (le cas « donne-moi le compte-rendu de la +// tâche X ») ; sans id, la liste complète avec un aperçu court par tâche. +func toolTaskList(args map[string]any) string { + proj := activeProjectSlug() + tasks := listTasksForProject(proj) + if id := strings.TrimSpace(taskStrArg(args["id"])); id != "" { + // On ne révèle que les tâches du projet actif : une tâche d'un autre projet + // est traitée comme inexistante (même message que si l'id était faux). + t, ok := getTask(id) + if !ok || taskProjectOf(t) != proj { + return "[erreur] tâche introuvable : " + id + } + return formatTask(t, true) + } + if len(tasks) == 0 { + return "[aucune tâche planifiée dans ce projet]" + } + var b strings.Builder + for _, t := range tasks { + b.WriteString(formatTask(t, false) + "\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// formatTask rend une tâche. full=true : compte-rendu entier (sur ses propres +// lignes) ; full=false : aperçu court d'une ligne, pour une liste scannable. +func formatTask(t Task, full bool) string { + var b strings.Builder + state := "off" + if t.Enabled { + state = "on" + } + fmt.Fprintf(&b, "- %s [%s] « %s » — %s", t.ID, state, t.Name, t.Schedule) + if t.NextRun > 0 && t.Enabled { + fmt.Fprintf(&b, " (prochaine : %s)", time.UnixMilli(t.NextRun).In(loc(t.TZ)).Format("2006-01-02 15:04")) + } + if t.LastRun > 0 { + when := time.UnixMilli(t.LastRun).In(loc(t.TZ)).Format("2006-01-02 15:04") + if t.LastOK { + fmt.Fprintf(&b, " ✓ dernier run %s", when) + } else { + fmt.Fprintf(&b, " ✗ dernier run %s : %s", when, t.LastError) + } + if r := strings.TrimSpace(t.LastReport); r != "" { + if full { + // Compte-rendu ENTIER (déjà borné à 4000 car au stockage), tel quel, + // sur ses propres lignes — c'est ce que l'utilisateur demande. + b.WriteString("\n compte-rendu complet :\n" + r) + } else { + b.WriteString("\n compte-rendu : " + previewReport(r)) + } + } + } + return b.String() +} + +// previewReport aplati + tronque le compte-rendu pour l'aperçu de la liste (un +// rapport entier par tâche gonflerait la sortie). Le texte complet s'obtient via +// task_list avec l'id de la tâche. +func previewReport(s string) string { + s = strings.Join(strings.Fields(s), " ") + const max = 300 + if len(s) > max { + return s[:max] + " […] (id pour le compte-rendu complet)" + } + return s +} + +// toolTaskCreate crée une tâche depuis un appel d'outil du modèle. +func toolTaskCreate(args map[string]any) string { + name := strings.TrimSpace(taskStrArg(args["name"])) + prompt := strings.TrimSpace(taskStrArg(args["prompt"])) + script := strings.TrimSpace(taskStrArg(args["script"])) + schedule := strings.TrimSpace(taskStrArg(args["schedule"])) + if name == "" || schedule == "" { + return "[erreur] name et schedule sont obligatoires" + } + kind := "" + if script != "" { + // Tâche script : le script doit exister dans le dossier protégé. + kind = "script" + if err := scriptExists(script); err != nil { + return "[erreur] " + err.Error() + } + } else if prompt == "" { + return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)" + } + tz := defaultTaskTZ() + if err := validateSchedule(schedule, tz); err != nil { + return "[erreur] fréquence invalide : " + err.Error() + } + enabled := true + if v, ok := args["enabled"].(bool); ok { + enabled = v + } + t := Task{ + ID: newTaskID(), Name: name, Prompt: prompt, Schedule: schedule, + TZ: tz, Enabled: enabled, Kind: kind, Script: script, + // Rattache la tâche au projet actif : l'IA ne crée que DANS son projet, + // comme elle n'y voit et n'y pilote que ses propres tâches. + Project: activeProjectSlug(), + } + t.NextRun = computeNextRun(schedule, tz, time.Now()) + if err := saveTask(t); err != nil { + return "[erreur] " + err.Error() + } + when := "" + if t.NextRun > 0 && enabled { + when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + } + return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when) +} + +// toolTaskUpdate modifie une tâche existante (partiel). +func toolTaskUpdate(args map[string]any) string { + id := strings.TrimSpace(taskStrArg(args["id"])) + if id == "" { + return "[erreur] id manquant" + } + t, ok := getTask(id) + if !ok || taskProjectOf(t) != activeProjectSlug() { + return "[erreur] tâche introuvable : " + id + } + if v, ok := args["name"].(string); ok && strings.TrimSpace(v) != "" { + t.Name = strings.TrimSpace(v) + } + if v, ok := args["prompt"].(string); ok && strings.TrimSpace(v) != "" { + t.Prompt = strings.TrimSpace(v) + } + if v, ok := args["schedule"].(string); ok && strings.TrimSpace(v) != "" { + sched := strings.TrimSpace(v) + if err := validateSchedule(sched, t.TZ); err != nil { + return "[erreur] fréquence invalide : " + err.Error() + } + t.Schedule = sched + t.NextRun = computeNextRun(sched, t.TZ, time.Now()) + } + if v, ok := args["enabled"].(bool); ok { + t.Enabled = v + if v && t.NextRun == 0 { + t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now()) + } + } + if err := saveTask(t); err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] tâche '%s' (id %s) mise à jour", t.Name, t.ID) +} + +// toolTaskDelete supprime une tâche. +func toolTaskDelete(args map[string]any) string { + id := strings.TrimSpace(taskStrArg(args["id"])) + if id == "" { + return "[erreur] id manquant" + } + t, ok := getTask(id) + if !ok || taskProjectOf(t) != activeProjectSlug() { + return "[erreur] tâche introuvable : " + id + } + if err := deleteTask(id); err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id) +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index a7ca93d..0f2f43f 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3127,11 +3127,34 @@ html[data-files="1"] #files-btn{color:var(--accent)} +
+
Type
+
+ + +
+
+ +
Consigne
+ +
Preset
@@ -9526,6 +9549,8 @@ let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé) let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune) let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale) let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé ») +let TASK_SCRIPTS = []; // scripts durables disponibles (tâche « script seul ») +let TASK_SCRIPTS_DIR = ''; // chemin du dossier de scripts, pour l'aide affichée let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche) let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche) let tasksPollTimer = null; @@ -9549,6 +9574,8 @@ function renderTasks(r){ const agentOn = !!(r && r.agent); TASK_PRESETS = (r && r.presets) || []; TASK_PROJECTS = (r && r.projects) || []; + TASK_SCRIPTS = (r && r.scripts) || []; + TASK_SCRIPTS_DIR = (r && r.scripts_dir) || 'le dossier de scripts'; TASK_MEM_ON = !r || r.mem_on !== false; TASK_WEB_ON = !r || r.web_on !== false; TASK_RUNNING = (r && r.running_id) || ''; @@ -9708,6 +9735,9 @@ function openTask(id){ // stockés en négation) ; sur une nouvelle on suit l'état global de la machine. document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON; document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON; + // Type : IA par défaut. Une tâche script n'a ni consigne ni preset utile. + setTaskKind(t && t.kind === 'script' ? 'script' : 'agent'); + fillScriptSelect(t ? (t.script||'') : ''); fillPresetSelect(t ? (t.preset||'') : ''); // Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une // tâche pour le chantier qu'on a sous les yeux. @@ -9798,6 +9828,51 @@ function fillTaskProjectSelect(selected){ if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; } +// fillScriptSelect peuple le sélecteur de script avec les fichiers du dossier +// protégé (LOKI_HOME/scripts). Dossier vide = on le dit et on laisse le +// sélecteur vide : l'enregistrement sera refusé côté serveur, autant l'annoncer. +function fillScriptSelect(selected){ + const sel = document.getElementById('task-script'); + if(!sel) return; + sel.textContent = ''; + TASK_SCRIPTS.forEach(sc=>{ + const o = document.createElement('option'); o.value = sc.name; + o.textContent = sc.name + (sc.size ? ' · ' + fmtSize(sc.size) : ''); + sel.appendChild(o); + }); + if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; + const hint = document.getElementById('task-script-hint'); + if(hint){ + hint.textContent = TASK_SCRIPTS.length + ? 'Scripts de ' + TASK_SCRIPTS_DIR + ' — l\'IA peut y écrire les siens.' + : 'Aucun script dans ' + TASK_SCRIPTS_DIR + ' : demande à l\'IA d\'y en écrire un, ou dépose-le toi-même.'; + } +} + +// setTaskKind bascule le formulaire entre « consigne IA » et « script seul ». +function setTaskKind(kind){ + const r = document.querySelector('input[name="task-kind"][value="'+kind+'"]'); + if(r) r.checked = true; + taskKindUI(kind); +} + +// taskKindUI n'affiche que les champs du type choisi : une tâche script n'a pas +// de consigne, et le preset ne la concerne pas (aucun modèle n'est chargé). +function taskKindUI(kind){ + const script = kind === 'script'; + const pg = document.getElementById('task-prompt-group'); + const sg = document.getElementById('task-script-group'); + if(pg) pg.style.display = script ? 'none' : ''; + if(sg) sg.style.display = script ? '' : 'none'; + const pr = document.getElementById('task-preset'); + if(pr) pr.closest('.pe-row').style.display = script ? 'none' : ''; +} + +function taskKind(){ + const r = document.querySelector('input[name="task-kind"]:checked'); + return (r && r.value === 'script') ? 'script' : 'agent'; +} + function setTaskFreqMode(mode){ const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]'); if(r) r.checked = true; @@ -9833,6 +9908,8 @@ function buildSchedule(){ async function saveTask(){ const name = document.getElementById('task-name').value.trim(); + const kind = taskKind(); + const script = (document.getElementById('task-script')||{}).value || ''; const prompt = document.getElementById('task-prompt').value.trim(); const schedule = buildSchedule(); const enabled = document.getElementById('task-enabled').checked; @@ -9842,9 +9919,13 @@ async function saveTask(){ const no_mem = !document.getElementById('task-mem').checked; const no_web = !document.getElementById('task-web').checked; const st = document.getElementById('task-modal-status'); - if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; } + if(!name){ st.textContent = 'nom obligatoire'; st.style.color = 'var(--err)'; return; } + if(kind === 'script' ? !script : !prompt){ + st.textContent = kind === 'script' ? 'choisis un script' : 'consigne obligatoire'; + st.style.color = 'var(--err)'; return; + } if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; } - const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); + const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, kind, script, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; } closeTask(); await loadTasks(); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 143a1e6..836a62e 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -926,11 +926,34 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+
+
Type
+
+ + +
+
+ +
Consigne
+ +
Preset
diff --git a/internal/loki/ui/src/js/21-tasks.js b/internal/loki/ui/src/js/21-tasks.js index 2908f16..71e1c1e 100644 --- a/internal/loki/ui/src/js/21-tasks.js +++ b/internal/loki/ui/src/js/21-tasks.js @@ -12,6 +12,8 @@ let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé) let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune) let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale) let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé ») +let TASK_SCRIPTS = []; // scripts durables disponibles (tâche « script seul ») +let TASK_SCRIPTS_DIR = ''; // chemin du dossier de scripts, pour l'aide affichée let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche) let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche) let tasksPollTimer = null; @@ -35,6 +37,8 @@ function renderTasks(r){ const agentOn = !!(r && r.agent); TASK_PRESETS = (r && r.presets) || []; TASK_PROJECTS = (r && r.projects) || []; + TASK_SCRIPTS = (r && r.scripts) || []; + TASK_SCRIPTS_DIR = (r && r.scripts_dir) || 'le dossier de scripts'; TASK_MEM_ON = !r || r.mem_on !== false; TASK_WEB_ON = !r || r.web_on !== false; TASK_RUNNING = (r && r.running_id) || ''; @@ -194,6 +198,9 @@ function openTask(id){ // stockés en négation) ; sur une nouvelle on suit l'état global de la machine. document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON; document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON; + // Type : IA par défaut. Une tâche script n'a ni consigne ni preset utile. + setTaskKind(t && t.kind === 'script' ? 'script' : 'agent'); + fillScriptSelect(t ? (t.script||'') : ''); fillPresetSelect(t ? (t.preset||'') : ''); // Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une // tâche pour le chantier qu'on a sous les yeux. @@ -284,6 +291,51 @@ function fillTaskProjectSelect(selected){ if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; } +// fillScriptSelect peuple le sélecteur de script avec les fichiers du dossier +// protégé (LOKI_HOME/scripts). Dossier vide = on le dit et on laisse le +// sélecteur vide : l'enregistrement sera refusé côté serveur, autant l'annoncer. +function fillScriptSelect(selected){ + const sel = document.getElementById('task-script'); + if(!sel) return; + sel.textContent = ''; + TASK_SCRIPTS.forEach(sc=>{ + const o = document.createElement('option'); o.value = sc.name; + o.textContent = sc.name + (sc.size ? ' · ' + fmtSize(sc.size) : ''); + sel.appendChild(o); + }); + if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; + const hint = document.getElementById('task-script-hint'); + if(hint){ + hint.textContent = TASK_SCRIPTS.length + ? 'Scripts de ' + TASK_SCRIPTS_DIR + ' — l\'IA peut y écrire les siens.' + : 'Aucun script dans ' + TASK_SCRIPTS_DIR + ' : demande à l\'IA d\'y en écrire un, ou dépose-le toi-même.'; + } +} + +// setTaskKind bascule le formulaire entre « consigne IA » et « script seul ». +function setTaskKind(kind){ + const r = document.querySelector('input[name="task-kind"][value="'+kind+'"]'); + if(r) r.checked = true; + taskKindUI(kind); +} + +// taskKindUI n'affiche que les champs du type choisi : une tâche script n'a pas +// de consigne, et le preset ne la concerne pas (aucun modèle n'est chargé). +function taskKindUI(kind){ + const script = kind === 'script'; + const pg = document.getElementById('task-prompt-group'); + const sg = document.getElementById('task-script-group'); + if(pg) pg.style.display = script ? 'none' : ''; + if(sg) sg.style.display = script ? '' : 'none'; + const pr = document.getElementById('task-preset'); + if(pr) pr.closest('.pe-row').style.display = script ? 'none' : ''; +} + +function taskKind(){ + const r = document.querySelector('input[name="task-kind"]:checked'); + return (r && r.value === 'script') ? 'script' : 'agent'; +} + function setTaskFreqMode(mode){ const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]'); if(r) r.checked = true; @@ -319,6 +371,8 @@ function buildSchedule(){ async function saveTask(){ const name = document.getElementById('task-name').value.trim(); + const kind = taskKind(); + const script = (document.getElementById('task-script')||{}).value || ''; const prompt = document.getElementById('task-prompt').value.trim(); const schedule = buildSchedule(); const enabled = document.getElementById('task-enabled').checked; @@ -328,9 +382,13 @@ async function saveTask(){ const no_mem = !document.getElementById('task-mem').checked; const no_web = !document.getElementById('task-web').checked; const st = document.getElementById('task-modal-status'); - if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; } + if(!name){ st.textContent = 'nom obligatoire'; st.style.color = 'var(--err)'; return; } + if(kind === 'script' ? !script : !prompt){ + st.textContent = kind === 'script' ? 'choisis un script' : 'consigne obligatoire'; + st.style.color = 'var(--err)'; return; + } if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; } - const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); + const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, kind, script, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; } closeTask(); await loadTasks(); diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 7b69e98..a4dc1e2 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -263,6 +263,7 @@ func newWebMux() *http.ServeMux { api("/api/tasks/toggle", handleTaskToggle) api("/api/tasks/pause", handleTasksPause) api("/api/tasks/run", handleTaskRun) + api("/api/tasks/stop", handleTaskStop) // arrête la tâche en cours (script : via son registre) api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows) api("/api/prefs", handleWebPrefs) @@ -304,13 +305,13 @@ func newWebMux() *http.ServeMux { api("/api/conversations/switch", handleConvSwitch) api("/api/conversations/rename", handleConvRename) api("/api/conversations/delete", handleConvDelete) - api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code - api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code) - api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte - api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used} + api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code + api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code) + api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte + api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used} api("/api/chat/tool-result", handleToolResult) // résultat complet d'un outil, chargé à la demande (bouton « voir plus ») - api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json) - api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais) + api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json) + api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais) return mux } diff --git a/internal/loki/web_tasks.go b/internal/loki/web_tasks.go index ff06f66..7049275 100644 --- a/internal/loki/web_tasks.go +++ b/internal/loki/web_tasks.go @@ -21,6 +21,14 @@ func handleTasks(w http.ResponseWriter, r *http.Request) { conv.mu.Lock() runningID := conv.runningTaskID conv.mu.Unlock() + // Une tâche script tourne HORS du verrou de génération (aucune inférence) : + // conv ne la voit pas, on complète donc avec le registre des scripts en cours + // (tasks_script.go), sinon l'UI l'affiche à l'arrêt pendant qu'elle tourne. + if runningID == "" { + if sid, _ := scriptRunningAny(); sid != "" { + runningID = sid + } + } // Presets (id + nom + actif) pour peupler le sélecteur du formulaire de tâche. presets := []map[string]any{} if list, err := ListPresets(); err == nil { @@ -28,13 +36,20 @@ func handleTasks(w http.ResponseWriter, r *http.Request) { presets = append(presets, map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}) } } + // Scripts durables disponibles, pour le sélecteur d'une tâche « script seul ». + scripts, _ := listScripts() + if scripts == nil { + scripts = []scriptInfo{} + } sendJSON(w, 200, map[string]any{ - "ok": true, - "tasks": tasks, - "paused": tasksPaused(), - "agent": agentEnabled(), - "running_id": runningID, - "presets": presets, + "ok": true, + "tasks": tasks, + "scripts": scripts, + "scripts_dir": scriptsDir(), + "paused": tasksPaused(), + "agent": agentEnabled(), + "running_id": runningID, + "presets": presets, // Projets, pour le sélecteur « projet visé » du formulaire de tâche. "projects": projectDTOs(), // État global mémoire/web, pour proposer des défauts cohérents à la création. @@ -54,6 +69,8 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) { TZ string `json:"tz"` Preset string `json:"preset"` Project string `json:"project"` + Kind string `json:"kind"` + Script string `json:"script"` NoMem bool `json:"no_mem"` NoWeb bool `json:"no_web"` Enabled bool `json:"enabled"` @@ -65,8 +82,26 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) { req.Name = strings.TrimSpace(req.Name) req.Prompt = strings.TrimSpace(req.Prompt) req.Schedule = strings.TrimSpace(req.Schedule) - if req.Name == "" || req.Prompt == "" { - sendJSON(w, 400, map[string]any{"ok": false, "error": "nom et consigne obligatoires"}) + req.Kind = strings.TrimSpace(req.Kind) + req.Script = strings.TrimSpace(req.Script) + if req.Name == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "nom obligatoire"}) + return + } + // Une tâche script exige un script EXISTANT (le vérifier ici évite une tâche + // planifiée qui échouera silencieusement au premier tic) ; une tâche IA exige + // une consigne. + if req.Kind == "script" { + if req.Script == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "script obligatoire pour une tâche script"}) + return + } + if err := scriptExists(req.Script); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + } else if req.Prompt == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "consigne obligatoire"}) return } if err := validateSchedule(req.Schedule, req.TZ); err != nil { @@ -87,6 +122,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) { } t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled t.TZ, t.Preset = req.TZ, req.Preset + t.Kind, t.Script = req.Kind, req.Script // Projet visé : vide = le projet actif au moment de l'enregistrement. Une tâche // créée depuis un projet appartient à ce projet — c'est là que sa mémoire et // ses trackers l'attendent quand elle tournera (voir runTask). @@ -162,6 +198,22 @@ func handleTasksPause(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true, "paused": tasksPaused()}) } +// handleTaskStop arrête la tâche en cours. Une tâche SCRIPT ne passe pas par le +// verrou de génération : on l'annule via son registre (tasks_script.go). Sinon +// (tâche IA), on retombe sur conv.Stop, qui annule la génération autonome. +func handleTaskStop(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.ID != "" && scriptRunStop(req.ID) { + sendJSON(w, 200, map[string]any{"ok": true}) + return + } + conv.Stop() + sendJSON(w, 200, map[string]any{"ok": true}) +} + // handleTaskRun lance une tâche MAINTENANT (bouton « tester »). Exécution // détachée : on rend la main tout de suite et on rafraîchit l'état à la fin. // 409 si une génération est déjà en cours. @@ -175,6 +227,14 @@ func handleTaskRun(w http.ResponseWriter, r *http.Request) { sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"}) return } + // Tâche script : aucune inférence, donc ni verrou de génération ni moteur + // chargé à attendre — la refuser parce que le modèle répond ailleurs n'aurait + // aucun sens. + if t.Kind == "script" { + go runTask(t) + sendJSON(w, 200, map[string]any{"ok": true}) + return + } // Vérifie la disponibilité AVANT de détacher, pour pouvoir répondre 409/503. conv.mu.Lock() busy := conv.Generating