From 4c8aafa8989410e916d4f4af92fdaea3bcae7407 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 26 Jun 2026 15:59:24 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.11=20:=20m=C3=A9moire=20(mem=5Fsearch/read?= =?UTF-8?q?/add/edit)=20remplace=20les=20skills=20+=20outil=20edit=20(old/?= =?UTF-8?q?new)=20+=20run=5Fshell=20renomm=C3=A9=20bash=20;=20migration=20?= =?UTF-8?q?auto=20SKILLS->MEMORY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent.go | 16 +- chat.go | 10 +- llm.go | 200 ++++++++++++++------ main.go | 7 +- memory.go | 351 ++++++++++++++++++++++++++++++++++++ resource_windows_amd64.syso | Bin 1196 -> 1196 bytes resource_windows_arm64.syso | Bin 1196 -> 1196 bytes skills.go | 14 +- tools.go | 85 ++++++--- ui/index.html | 36 ++-- versioninfo.json | 6 +- web.go | 59 +++++- 12 files changed, 660 insertions(+), 124 deletions(-) create mode 100644 memory.go diff --git a/agent.go b/agent.go index 2a845cb..93e2ffa 100644 --- a/agent.go +++ b/agent.go @@ -54,7 +54,7 @@ func cmdAgent(args []string) error { if err := setAgentEnabled(true); err != nil { return err } - fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet et de ses skills") + fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (bash) et de sa mémoire") case "off": if err := setAgentEnabled(false); err != nil { return err @@ -66,15 +66,15 @@ func cmdAgent(args []string) error { state = green("on") } fmt.Printf("%s état: %s\n", cyan("Mode agent"), state) - fmt.Printf(" outils : run_shell (timeout %ds, max %ds) + skill (read/write/append/delete)\n", toolDefaultTimeout, toolMaxTimeout) - sk := ListSkills() - if len(sk) == 0 { - fmt.Printf(" skills : aucun — crée %s//SKILL.md\n", skillsDir()) + fmt.Printf(" outils : bash (timeout %ds, max %ds) + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", toolDefaultTimeout, toolMaxTimeout) + mem := MemList() + if len(mem) == 0 { + fmt.Printf(" mémoire : aucune page — crée %s/.md\n", memoryDir()) return nil } - fmt.Printf(" skills (%s) :\n", skillsDir()) - for _, s := range sk { - fmt.Printf(" %s %s\n", bold(s.Name), s.Desc) + fmt.Printf(" mémoire (%s) :\n", memoryDir()) + for _, p := range mem { + fmt.Printf(" %s %s\n", bold(p.Name), p.Title) } default: return fmt.Errorf("usage: jean agent [on|off|status]") diff --git a/chat.go b/chat.go index 49a0824..b132f42 100644 --- a/chat.go +++ b/chat.go @@ -77,11 +77,15 @@ func cmdChat(args []string) error { if ev.ToolUsed.Done || ev.ToolUsed.Typing { break // résultat / frappe live affichés côté web ; en terminal on garde l'annonce seule } - icon := "📚" - verb := "skill" - if ev.ToolUsed.Name == "run_shell" { + icon := "🧠" + verb := "mémoire" + switch ev.ToolUsed.Name { + case "bash": icon = "⚙️" verb = "exécution" + case "edit": + icon = "✏️" + verb = "édition" } if inReason { fmt.Print("\n") diff --git a/llm.go b/llm.go index aa17833..8591cea 100644 --- a/llm.go +++ b/llm.go @@ -41,35 +41,109 @@ type ToolFunction struct { Parameters any `json:"parameters"` } -// readSkillTool / runShellTool: OpenAI-shaped function definitions advertised -// to the model when the corresponding feature flag is on. -// skillTool : un seul outil pour toute la gestion des skills (lecture + -// auto-gestion création/maj/suppression), ce qui évite d'envoyer trois schémas. -func skillTool() Tool { +// Tool definitions: OpenAI-shaped function schemas advertised to the model when +// the agent mode is on. The memory tools (mem_*) let the model keep persistent +// Markdown notes across sessions; bash is its real access to the machine. + +func memSearchTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ - Name: "skill", - Description: "Gère tes skills (guides Markdown réutilisables). action: read=lire, write=créer/remplacer, append=ajouter à la fin sans réécrire, delete=supprimer. content requis pour write/append (1re ligne = titre court #).", + Name: "mem_search", + Description: "Cherche dans ta mémoire (pages Markdown sous MEMORY/). Renvoie une liste classée {fichier, titre, extrait}. À utiliser EN PREMIER quand l'utilisateur évoque qqch que tu pourrais déjà savoir (préférences, projets en cours, décisions passées). Complète avec mem_read sur la page la plus pertinente.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ - "action": map[string]any{"type": "string", "enum": []string{"read", "write", "append", "delete"}}, - "name": map[string]any{"type": "string", "description": "Nom du skill (alphanum, ._-)"}, - "content": map[string]any{"type": "string", "description": "Contenu Markdown (write/append)"}, + "query": map[string]any{"type": "string", "description": "Mots-clés ou courte phrase"}, + "limit": map[string]any{"type": "integer", "description": "Nb max de résultats (défaut 8, max 30)"}, }, - "required": []string{"action", "name"}, + "required": []string{"query"}, }, }, } } -func runShellTool() Tool { +func memReadTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ - Name: "run_shell", - Description: "Exécute une commande shell (bash) sur cette machine et retourne stdout, stderr et le code de sortie. Pour inspecter le système, lancer des scripts, lire des logs. Évite les commandes destructrices sauf demande explicite.", + Name: "mem_read", + Description: "Lit une page mémoire (fichier Markdown). Sortie 1-indexée, lignes préfixées du numéro. offset/limit pour les longues pages.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Nom de la page (ex: docker-notes.md)"}, + "offset": map[string]any{"type": "integer", "description": "Ligne de départ (1-indexé, défaut 1)"}, + "limit": map[string]any{"type": "integer", "description": "Nb de lignes (défaut 500, max 500)"}, + }, + "required": []string{"file"}, + }, + }, + } +} + +func memAddTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "mem_add", + Description: "Crée une nouvelle page mémoire (Markdown). Un seul sujet par page, nom descriptif en kebab-case. 1re ligne = titre court (#). Refuse d'écraser une page existante (utilise mem_edit).", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Nom de la page (ex: docker-notes.md)"}, + "content": map[string]any{"type": "string", "description": "Contenu Markdown (1re ligne = titre #)"}, + }, + "required": []string{"file", "content"}, + }, + }, + } +} + +func memEditTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "mem_edit", + Description: "Édite une page mémoire par remplacement exact : old → new. old doit apparaître EXACTEMENT une fois dans la page (ajoute du contexte pour le rendre unique). Pour ajouter du contenu, mets l'ancienne fin de page dans old et la version augmentée dans new.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Nom de la page"}, + "old": map[string]any{"type": "string", "description": "Texte exact à remplacer (unique dans la page)"}, + "new": map[string]any{"type": "string", "description": "Texte de remplacement"}, + }, + "required": []string{"file", "old", "new"}, + }, + }, + } +} + +func editTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "edit", + Description: "Modifie un fichier sur le disque par remplacement exact : old → new. old doit apparaître EXACTEMENT une fois dans le fichier (ajoute du contexte autour pour le rendre unique). Préfère cet outil à la réécriture complète du fichier — il évite de tout retaper.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Chemin du fichier à modifier"}, + "old": map[string]any{"type": "string", "description": "Texte exact à remplacer (unique dans le fichier)"}, + "new": map[string]any{"type": "string", "description": "Texte de remplacement"}, + }, + "required": []string{"file", "old", "new"}, + }, + }, + } +} + +func bashTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "bash", + Description: "Exécute une commande shell (bash) sur cette machine et retourne stdout, stderr et le code de sortie. Pour inspecter le système, lancer des scripts, lire/éditer des fichiers, lire des logs. Évite les commandes destructrices sauf demande explicite.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -113,9 +187,6 @@ func InjectSkills(msgs []Message, caps Caps) []Message { if mp := machineSystemPrompt(caps); mp != "" { parts = append(parts, mp) } - if sp := skillsSystemPrompt(caps); sp != "" { - parts = append(parts, sp) - } if len(parts) == 0 { return msgs } @@ -132,7 +203,7 @@ func InjectSkills(msgs []Message, caps Caps) []Message { func EnabledTools(caps Caps) []Tool { tools := []Tool{} if caps.Agent { - tools = append(tools, skillTool(), runShellTool()) + tools = append(tools, bashTool(), editTool(), memSearchTool(), memReadTool(), memAddTool(), memEditTool()) } return tools } @@ -408,8 +479,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // emit it whenever it grows, so the UI shows it appear live. if cur := toolCalls[0]; cur != nil { key := "command" - if cur.Function.Name == "skill" { - key = "name" + switch cur.Function.Name { + case "mem_search": + key = "query" + case "mem_read", "mem_add", "mem_edit", "edit": + key = "file" } if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview { lastPreview = p @@ -536,47 +610,65 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // nothing while a slow shell command runs and looks frozen. label := "" switch tc.Function.Name { - case "skill": - label, _ = args["name"].(string) - case "run_shell": + case "mem_search": + label, _ = args["query"].(string) + case "mem_read", "mem_add", "mem_edit", "edit": + label, _ = args["file"].(string) + case "bash": label, _ = args["command"].(string) } cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) result := "" switch tc.Function.Name { - case "skill": - action, _ := args["action"].(string) - content, _ := args["content"].(string) - switch action { - case "read": - if c := SkillContent(label); c != "" { - result = c - } else { - result = fmt.Sprintf("[erreur] skill '%s' introuvable", label) - } - case "write": - if werr := SaveSkill(label, "", content); werr != nil { - result = "[erreur] " + werr.Error() - } else { - result = fmt.Sprintf("[ok] skill '%s' enregistré", label) - } - case "append": - if werr := AppendSkill(label, content); werr != nil { - result = "[erreur] " + werr.Error() - } else { - result = fmt.Sprintf("[ok] skill '%s' enrichi (append)", label) - } - case "delete": - if derr := DeleteSkill(label); derr != nil { - result = "[erreur] " + derr.Error() - } else { - result = fmt.Sprintf("[ok] skill '%s' supprimé", label) - } - default: - result = "[erreur] action skill inconnue: " + action + case "mem_search": + lim := 0 + if v, ok := args["limit"].(float64); ok { + lim = int(v) } - case "run_shell": + hits := MemSearch(label, lim) + if len(hits) == 0 { + result = "[aucun résultat]" + } else { + var b strings.Builder + for _, h := range hits { + fmt.Fprintf(&b, "- %s — %s\n %s\n", h.File, h.Title, h.Snippet) + } + result = strings.TrimRight(b.String(), "\n") + } + case "mem_read": + off, lim := 0, 0 + if v, ok := args["offset"].(float64); ok { + off = int(v) + } + if v, ok := args["limit"].(float64); ok { + lim = int(v) + } + if c, rerr := MemRead(label, off, lim); rerr != nil { + result = "[erreur] " + rerr.Error() + } else { + result = c + } + case "mem_add": + content, _ := args["content"].(string) + if werr := MemAdd(label, content); werr != nil { + result = "[erreur] " + werr.Error() + } else { + result = fmt.Sprintf("[ok] page '%s' créée", label) + } + case "mem_edit": + oldText, _ := args["old"].(string) + newText, _ := args["new"].(string) + if werr := MemEdit(label, oldText, newText); werr != nil { + result = "[erreur] " + werr.Error() + } else { + result = fmt.Sprintf("[ok] page '%s' modifiée", label) + } + case "edit": + oldText, _ := args["old"].(string) + newText, _ := args["new"].(string) + result = fileEdit(label, oldText, newText) + case "bash": to := 0 switch v := args["timeout"].(type) { case float64: diff --git a/main.go b/main.go index 5c1bee2..3418be3 100644 --- a/main.go +++ b/main.go @@ -15,9 +15,13 @@ import ( "strings" ) -const Version = "0.2.10" +const Version = "0.2.11" func main() { + // Migration one-shot des anciens skills (SKILLS//SKILL.md) vers la + // nouvelle mémoire (MEMORY/.md). Idempotente, silencieuse si rien à faire. + migrateSkillsToMemory() + args := os.Args[1:] cmd := "help" if len(args) > 0 { @@ -176,6 +180,7 @@ func readEtcDefault() string { func confPath() string { return filepath.Join(JeanHome(), "config.env") } func presetsDir() string { return filepath.Join(JeanHome(), "configs") } func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") } +func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") } func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") } // Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié. func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } diff --git a/memory.go b/memory.go new file mode 100644 index 0000000..cb23b8d --- /dev/null +++ b/memory.go @@ -0,0 +1,351 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// La mémoire de jean = des fichiers Markdown plats sous MEMORY/.md. +// L'IA y range ce qu'elle veut retenir entre les sessions (préférences, +// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read, +// mem_add, mem_edit. + +type MemPage struct { + Name string // nom de fichier sans le dossier (ex: "docker-notes.md") + Title string // 1re ligne non vide, sans les # +} + +type MemHit struct { + File string + Title string + Snippet string +} + +var memNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// memFileName normalise un nom de page : ajoute .md si absent et valide. +func memFileName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("nom vide") + } + if !strings.HasSuffix(strings.ToLower(name), ".md") { + name += ".md" + } + if !memNameRe.MatchString(name) { + return "", fmt.Errorf("nom invalide (alphanum, ._-)") + } + return name, nil +} + +// safeMemPath valide et renvoie le chemin disque d'une page mémoire. +func safeMemPath(name string) (string, error) { + fn, err := memFileName(name) + if err != nil { + return "", err + } + root, err := filepath.Abs(memoryDir()) + if err != nil { + return "", err + } + abs, err := filepath.Abs(filepath.Join(root, fn)) + if err != nil { + return "", err + } + if !strings.HasPrefix(abs, root+string(filepath.Separator)) { + return "", fmt.Errorf("path invalide") + } + return abs, nil +} + +// titleOf renvoie la 1re ligne non vide d'un contenu, sans les # de tête. +func titleOf(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#")) + if s != "" { + return s + } + } + return "" +} + +// MemList liste les pages mémoire (nom + titre), triées par nom. +func MemList() []MemPage { + entries, err := os.ReadDir(memoryDir()) + if err != nil { + return nil + } + out := []MemPage{} + for _, e := range entries { + if e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") { + continue + } + b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name())) + out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque +// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de +// recherche : à compléter par mem_read sur la page la plus pertinente. +func MemSearch(query string, limit int) []MemHit { + if limit <= 0 || limit > 30 { + limit = 8 + } + terms := strings.Fields(strings.ToLower(query)) + type scored struct { + hit MemHit + score int + } + var ranked []scored + for _, p := range MemList() { + b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name)) + content := string(b) + hay := strings.ToLower(p.Name + "\n" + content) + score := 0 + for _, t := range terms { + score += strings.Count(hay, t) + if strings.Contains(strings.ToLower(p.Name), t) { + score += 3 // bonus si le terme est dans le nom de la page + } + } + if score == 0 && query != "" { + continue + } + ranked = append(ranked, scored{ + hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)}, + score: score, + }) + } + sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score }) + out := []MemHit{} + for i, r := range ranked { + if i >= limit { + break + } + out = append(out, r.hit) + } + return out +} + +// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme. +func snippetAround(content string, terms []string) string { + flat := strings.Join(strings.Fields(content), " ") + low := strings.ToLower(flat) + idx := -1 + for _, t := range terms { + if i := strings.Index(low, t); i >= 0 && (idx < 0 || i < idx) { + idx = i + } + } + if idx < 0 { + if len(flat) > 160 { + return flat[:160] + "…" + } + return flat + } + start := idx - 60 + if start < 0 { + start = 0 + } + end := idx + 100 + if end > len(flat) { + end = len(flat) + } + s := flat[start:end] + if start > 0 { + s = "…" + s + } + if end < len(flat) { + s += "…" + } + return s +} + +// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du +// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500). +func MemRead(name string, offset, limit int) (string, error) { + p, err := safeMemPath(name) + if err != nil { + return "", err + } + b, err := os.ReadFile(p) + if err != nil { + return "", fmt.Errorf("page '%s' introuvable", name) + } + lines := strings.Split(string(b), "\n") + if offset <= 0 { + offset = 1 + } + if limit <= 0 || limit > 500 { + limit = 500 + } + var out strings.Builder + for i := offset - 1; i < len(lines) && i < offset-1+limit; i++ { + fmt.Fprintf(&out, "%d\t%s\n", i+1, lines[i]) + } + return strings.TrimRight(out.String(), "\n"), nil +} + +// MemAdd crée une nouvelle page mémoire. Refuse d'écraser une page existante +// (utiliser mem_edit pour modifier). +func MemAdd(name, content string) error { + p, err := safeMemPath(name) + if err != nil { + return err + } + if _, err := os.Stat(p); err == nil { + return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier") + } + if err := os.MkdirAll(memoryDir(), 0o755); err != nil { + return err + } + body := strings.TrimRight(content, "\n") + "\n" + return os.WriteFile(p, []byte(body), 0o644) +} + +// MemEdit remplace oldText par newText dans une page. oldText doit apparaître +// EXACTEMENT une fois (sinon erreur), pour une édition sans ambiguïté. +func MemEdit(name, oldText, newText string) error { + p, err := safeMemPath(name) + if err != nil { + return err + } + b, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("page '%s' introuvable", name) + } + content := string(b) + n := strings.Count(content, oldText) + if oldText == "" { + return fmt.Errorf("old vide") + } + if n == 0 { + return fmt.Errorf("old introuvable dans la page") + } + if n > 1 { + return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n) + } + updated := strings.Replace(content, oldText, newText, 1) + return os.WriteFile(p, []byte(updated), 0o644) +} + +// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par +// l'éditeur web. +func MemContent(name string) string { + p, err := safeMemPath(name) + if err != nil { + return "" + } + b, err := os.ReadFile(p) + if err != nil { + return "" + } + return string(b) +} + +// MemSave écrit (crée ou écrase) une page mémoire. Si old est fourni et diffère +// du nouveau nom, l'ancienne page est renommée (supprimée). Utilisé par l'éditeur +// web — pendant de SaveSkill. +func MemSave(name, old, content string) error { + if old != "" { + if oldFn, e1 := memFileName(old); e1 == nil { + if newFn, e2 := memFileName(name); e2 == nil && oldFn != newFn { + if od, e3 := safeMemPath(old); e3 == nil { + _ = os.Remove(od) + } + } + } + } + p, err := safeMemPath(name) + if err != nil { + return err + } + if err := os.MkdirAll(memoryDir(), 0o755); err != nil { + return err + } + body := strings.TrimRight(content, "\n") + "\n" + return os.WriteFile(p, []byte(body), 0o644) +} + +// MemDelete supprime une page mémoire. +func MemDelete(name string) error { + p, err := safeMemPath(name) + if err != nil { + return err + } + if _, err := os.Stat(p); err != nil { + return fmt.Errorf("introuvable") + } + return os.Remove(p) +} + +// migrateSkillsToMemory copie une seule fois les anciens skills +// (SKILLS//SKILL.md) vers la mémoire (MEMORY/.md). Idempotente : ne +// touche pas une page mémoire déjà existante, et pose un drapeau .migrated pour +// ne pas reparcourir à chaque lancement. Silencieuse en cas d'absence de SKILLS. +func migrateSkillsToMemory() { + flag := filepath.Join(memoryDir(), ".skills_migrated") + if _, err := os.Stat(flag); err == nil { + return // déjà fait + } + entries, err := os.ReadDir(skillsDir()) + if err != nil { + return // pas d'anciens skills + } + migrated := 0 + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + src := filepath.Join(skillsDir(), e.Name(), "SKILL.md") + b, rerr := os.ReadFile(src) + if rerr != nil { + continue + } + dst, derr := safeMemPath(e.Name()) + if derr != nil { + continue + } + if _, err := os.Stat(dst); err == nil { + continue // page mémoire homonyme déjà présente : on ne l'écrase pas + } + if err := os.MkdirAll(memoryDir(), 0o755); err != nil { + return + } + if os.WriteFile(dst, b, 0o644) == nil { + migrated++ + } + } + _ = os.MkdirAll(memoryDir(), 0o755) + _ = os.WriteFile(flag, []byte(fmt.Sprintf("%d skills migrés\n", migrated)), 0o644) +} + +// memorySystemPrompt liste les pages mémoire à injecter quand le mode agent est +// actif, pour que l'IA sache ce qu'elle a déjà retenu. +func memorySystemPrompt(caps Caps) string { + if !caps.Agent { + return "" + } + list := MemList() + var b strings.Builder + b.WriteString("Memory: persistent Markdown pages under MEMORY/. Use mem_search FIRST whenever the user asks about something you might already know (preferences, ongoing projects, past decisions). Open the most relevant page with mem_read. Save anything worth keeping across sessions with mem_add (one topic per page, descriptive kebab-case name); update a page with mem_edit (old must match exactly once). The first content line is a short title (#).\n") + if len(list) == 0 { + b.WriteString("Pages: none yet.") + return b.String() + } + b.WriteString("Pages:\n") + for _, p := range list { + fmt.Fprintf(&b, "- %s: %s\n", p.Name, p.Title) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso index 33562c8a2dabdf154c1d0c859e1e2ea0110e2c86..1b01c547ff26db295d7c3f8e5432948e94b1b18e 100644 GIT binary patch delta 33 mcmZ3(xrTGXDNb$%CI%4T-gsG 1 { + return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n) + } + updated := strings.Replace(content, oldText, newText, 1) + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path) +} + // tailRunes returns the last n runes of s (used to cap tool output). func tailRunes(s string, n int) string { r := []rune(s) diff --git a/ui/index.html b/ui/index.html index 3f7f110..ef6f98d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -185,9 +185,9 @@ button:disabled{opacity:.5;cursor:not-allowed}
Mode agent -
⚠️ active l'accès machine (shell complet sur le serveur) et les skills — l'IA peut inspecter, lancer des scripts et exécuter ses skills (timeout 30s, max 300s)
-
Skills
-
+
⚠️ active l'accès machine (shell complet via bash sur le serveur) et la mémoire — l'IA peut inspecter, lancer des scripts et lire/écrire ses pages mémoire (timeout 30s, max 300s)
+
Mémoire
+
System prompt @@ -417,15 +417,16 @@ async function loadAgent(){ document.getElementById('agent-badge').innerHTML = on ? 'on' : 'off'; document.getElementById('brand').classList.toggle('agent', on); - document.getElementById('skills-list').innerHTML = (s.skills||[]).length - ? s.skills.map(x=>'
'+x.name+' — '+x.desc+'
').join('') - : '
(aucun skill)
'; + const pages = s.pages || s.skills || []; + document.getElementById('mem-list').innerHTML = pages.length + ? pages.map(x=>'
'+x.name+' — '+x.desc+'
').join('') + : '
(aucune page mémoire)
'; } -// alias : plusieurs appelants rafraîchissent juste la liste des skills -const loadSkills = loadAgent; +// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire +const loadMem = loadAgent; async function toggleAgent(){ const on=document.getElementById('agent-toggle').checked; - if(on && !confirm("Activer le mode agent ? L'IA aura un accès shell complet au serveur et pourra exécuter ses skills.")){ document.getElementById('agent-toggle').checked=false; return; } + if(on && !confirm("Activer le mode agent ? L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.")){ document.getElementById('agent-toggle').checked=false; return; } await jpost('/api/agent/toggle',{on}); loadAgent(); } @@ -489,7 +490,7 @@ const KINDS = { // presets are keyed by `id` (filename) so several can share a display name; // skills keep name-as-identity (param 'name'). preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, - skill: {label:'Skill', param:'name', getUrl:'/api/skill', saveUrl:'/api/skill/save', delUrl:'/api/skill/delete', reload:()=>loadSkills()}, + mem: {label:'Page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()}, }; async function openItem(kind, key){ const K = KINDS[kind]; @@ -614,7 +615,7 @@ function applyQuant(){ } } const openPreset = (id)=>openItem('preset', id); -const openSkill = (n)=>openItem('skill', n); +const openMem = (n)=>openItem('mem', n); function closeModal(){ document.getElementById('modal').style.display = 'none'; } async function saveItem(){ const K = KINDS[editingKind]; @@ -758,14 +759,15 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCo // Render a tool call as its own conversation message: the command the model // wrote, then the response it got back. textContent keeps it injection-safe. function renderToolMsg(el, tu){ - const isShell = tu.name==='run_shell'; - let lbl = isShell ? 'terminal' : 'skill'; + const isShell = tu.name==='bash'; + const isEdit = tu.name==='edit'; + let lbl = isShell ? 'terminal' : (isEdit ? 'édition' : 'mémoire'); // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } setLabel(el, lbl); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head'; - head.textContent = isShell ? '⚙️ commande' : '📚 skill'; + head.textContent = isShell ? '⚙️ commande' : (isEdit ? '✏️ édition' : '🧠 mémoire'); body.appendChild(head); if(tu.label){ const pre=document.createElement('pre'); pre.className='tool-cmd'; @@ -947,9 +949,9 @@ async function send(){ renderToolMsg(pendingToolEl, tu); if(tu.done){ pendingToolEl=null; - // L'IA gère ses skills elle-même : rafraîchir le menu en direct - // dès qu'un skill est créé/modifié/supprimé (pas besoin de F5). - if(tu.name==='skill') loadSkills(); + // L'IA gère sa mémoire elle-même : rafraîchir le menu en direct + // dès qu'une page est créée/modifiée (pas besoin de F5). + if(tu.name==='mem_add' || tu.name==='mem_edit') loadMem(); } } if(d.reasoning_content){ diff --git a/versioninfo.json b/versioninfo.json index af8b4f9..35338af 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 2, - "Patch": 10, + "Patch": 11, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 2, - "Patch": 10, + "Patch": 11, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.2.10", + "ProductVersion": "0.2.11", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/web.go b/web.go index 26528c4..0a1bc92 100644 --- a/web.go +++ b/web.go @@ -95,6 +95,9 @@ func newWebMux() *http.ServeMux { api("/api/skill", handleSkill) api("/api/skill/save", handleSkillSave) api("/api/skill/delete", handleSkillDelete) + api("/api/mem", handleMem) + api("/api/mem/save", handleMemSave) + api("/api/mem/delete", handleMemDelete) api("/api/switch", handleSwitch) api("/api/start", svcHandler("start")) api("/api/stop", svcHandler("stop")) @@ -424,15 +427,16 @@ func handlePresetDelete(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr}) } -// handleAgent renvoie l'état du mode agent ET la liste des skills (qui sont des -// outils gérés sous ce même interrupteur) — un seul aller-retour pour l'UI. +// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que +// l'IA gère via les outils mem_*) — un seul aller-retour pour l'UI. La clé +// "skills" est conservée en miroir de "pages" pour l'ancien portail ajean.link. func handleAgent(w http.ResponseWriter, r *http.Request) { - sk := ListSkills() + pages := MemList() out := []map[string]any{} - for _, s := range sk { - out = append(out, map[string]any{"name": s.Name, "desc": s.Desc}) + for _, p := range pages { + out = append(out, map[string]any{"name": p.Name, "desc": p.Title}) } - sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "skills": out}) + sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "pages": out, "skills": out}) } func handleAgentToggle(w http.ResponseWriter, r *http.Request) { @@ -487,6 +491,49 @@ func handleSkillDelete(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) } +// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire +// (MEMORY/.md). Mêmes payloads que l'éditeur de skills (name/old/content) +// pour réutiliser l'UI ; "name" = nom de fichier de la page. +func handleMem(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que jean doit retenir entre les sessions.\n"}) + return + } + c := MemContent(name) + if c == "" { + sendJSON(w, 404, map[string]any{"error": "not found"}) + return + } + sendJSON(w, 200, map[string]any{"name": name, "content": c}) +} + +func handleMemSave(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := MemSave(req.Name, req.Old, req.Content); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "name": req.Name}) +} + +func handleMemDelete(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := MemDelete(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + func handleSwitch(w http.ResponseWriter, r *http.Request) { var req struct { N int `json:"n"`