mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
157 lines
6.1 KiB
Go
157 lines
6.1 KiB
Go
package loki
|
|
|
|
// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule,
|
|
// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque
|
|
// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans
|
|
// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres
|
|
// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une
|
|
// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused).
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID).
|
|
type Task struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Prompt string `json:"prompt"`
|
|
Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs
|
|
TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure
|
|
Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif)
|
|
// Project = slug du projet visé (projects.go). L'exécution force ce projet
|
|
// (setProjectOverride), donc la tâche voit LA MÉMOIRE et les trackers du bon
|
|
// 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
|
|
NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche
|
|
Enabled bool `json:"enabled"`
|
|
LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais
|
|
NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer
|
|
LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms
|
|
LastOK bool `json:"last_ok"`
|
|
LastError string `json:"last_error"`
|
|
LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court)
|
|
|
|
Running bool `json:"-"` // transitoire : une exécution est en cours
|
|
}
|
|
|
|
// newTaskID génère un identifiant court et unique.
|
|
func newTaskID() string {
|
|
var b [6]byte
|
|
_, _ = rand.Read(b[:])
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable.
|
|
func listTasks() []Task {
|
|
var out []Task
|
|
_ = view(bkTasks, func(b *bolt.Bucket) error {
|
|
return b.ForEach(func(_, v []byte) error {
|
|
var t Task
|
|
if json.Unmarshal(v, &t) == nil {
|
|
out = append(out, t)
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Name != out[j].Name {
|
|
return out[i].Name < out[j].Name
|
|
}
|
|
return out[i].ID < out[j].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
// 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. 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 != "" && projectExists(s) {
|
|
return s
|
|
}
|
|
return defaultProjectSlug
|
|
}
|
|
|
|
// Le PANNEAU de l'UI et le planificateur restent GLOBAUX : une tâche doit tourner
|
|
// même si son projet n'est pas celui affiché, et masquer les tâches des autres
|
|
// projets ferait croire qu'elles ne sont plus planifiées. Le projet gouverne ce
|
|
// que la tâche VOIT quand elle s'exécute (runTask force setProjectOverride), pas
|
|
// sa visibilité.
|
|
|
|
// tagOrphanTasks rattache au projet donné les tâches qui n'en ont pas encore
|
|
// (migration, une seule fois).
|
|
func tagOrphanTasks(slug string) {
|
|
for _, t := range listTasks() {
|
|
if strings.TrimSpace(t.Project) != "" {
|
|
continue
|
|
}
|
|
t.Project = slug
|
|
_ = saveTask(t)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
ok := getJSON(bkTasks, id, &t)
|
|
return t, ok
|
|
}
|
|
|
|
// saveTask enregistre (crée ou remplace) une tâche.
|
|
func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) }
|
|
|
|
// deleteTask supprime une tâche par ID.
|
|
func deleteTask(id string) error { return putBytes(bkTasks, id, nil) }
|
|
|
|
// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On
|
|
// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base
|
|
// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un
|
|
// moteur.
|
|
func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
|
|
|
|
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
|
|
|
|
// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son
|
|
// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est
|
|
// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un
|
|
// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou).
|
|
func computeNextRun(schedule, tz string, from time.Time) int64 {
|
|
next, err := nextAfter(schedule, tz, from)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return next.UnixMilli()
|
|
}
|