diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index 4543ee0..5c40307 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -5,6 +5,8 @@ fusion avec ce qui est stocké, pour rester robuste aux montées de version. """ from __future__ import annotations +import re + from . import db CONFIG_KEY = "agent" @@ -71,6 +73,7 @@ PROFILE_FIELDS = { "self_review", "rag_enabled", "embed_model", + "memory_mode", "skills_enabled", "ponytail", "keep_alive", @@ -124,6 +127,11 @@ DEFAULT_CONFIG: dict = { # d'échecs ») et embrouille les petits modèles. Réactivable dans Réglages. "rag_enabled": False, "embed_model": "auto", + # Mémoire en notes Markdown écrites par l'agent (voir memory_notes.py). + # "ondemand" : les outils existent, mais rien n'est injecté sans que le + # modèle le demande — aucun risque de voir ressurgir une demande sans + # rapport, contrairement à l'ancien RAG vectoriel. + "memory_mode": "ondemand", # Skills : méthodes expertes injectées automatiquement selon la tâche. "skills_enabled": True, # Ponytail : méthode « code minimal » (anti sur-ingénierie) injectée pour @@ -247,6 +255,42 @@ def ollama_options(cfg: dict) -> dict: } +# ── Presets : jeux de réglages nommés ──────────────────────────────────── +PRESETS_KEY = "config_presets" +_PRESET_NAME = re.compile(r"^[\w \-]{1,40}$") + + +def list_presets() -> list[str]: + return sorted((db.get_config_value(PRESETS_KEY) or {}).keys()) + + +def save_preset(name: str, model: str | None = None) -> list[str]: + """Fige la configuration courante sous ce nom (écrase si déjà pris).""" + name = (name or "").strip() + if not _PRESET_NAME.match(name): + raise ValueError("nom de preset invalide (lettres, chiffres, espaces, -)") + presets = db.get_config_value(PRESETS_KEY) or {} + cfg = get_config(model) + presets[name] = {field: cfg[field] for field in PROFILE_FIELDS if field in cfg} + db.set_config_value(PRESETS_KEY, presets) + return sorted(presets) + + +def apply_preset(name: str, model: str | None = None) -> dict | None: + """Applique un preset ; None s'il n'existe pas.""" + preset = (db.get_config_value(PRESETS_KEY) or {}).get((name or "").strip()) + if preset is None: + return None + return save_config(dict(preset), model) + + +def delete_preset(name: str) -> list[str]: + presets = db.get_config_value(PRESETS_KEY) or {} + presets.pop((name or "").strip(), None) + db.set_config_value(PRESETS_KEY, presets) + return sorted(presets) + + def enabled_tool_names(cfg: dict) -> list[str]: """Outils réellement proposables au modèle. diff --git a/backend/app/memory_notes.py b/backend/app/memory_notes.py new file mode 100644 index 0000000..f836dd1 --- /dev/null +++ b/backend/app/memory_notes.py @@ -0,0 +1,154 @@ +"""Mémoire en notes Markdown : l'agent écrit lui-même ce qu'il veut retenir. + +Repris d'AJEAN (github.com/nathaninline/jean) : des notes lisibles rangées +dans ``$DATA_DIR/MEMORY/``, que le modèle consulte et enregistre EXPLICITEMENT. + +Face au RAG vectoriel, trois avantages décisifs : + - aucun modèle d'embedding requis (marche dès l'installation) ; + - le contenu est inspectable et modifiable à la main ; + - rien n'est mémorisé « à l'insu » : pas de vieille demande sans rapport qui + ressurgit au milieu d'une nouvelle discussion. + +Trois modes (config ``memory_mode``) : + off — rien du tout ; + ondemand — les outils memory_search / memory_save sont proposés au modèle ; + always — idem, plus l'injection automatique des notes pertinentes. +""" +from __future__ import annotations + +import os +import re +import time +import unicodedata + +from .config import settings + +MODES = ("off", "ondemand", "always") + +# Bornes : une note reste une note, pas une archive. +_MAX_NOTE_CHARS = 4000 +_MAX_NOTES = 200 +_EXCERPT_CHARS = 400 + + +def _dir() -> str: + path = os.path.join(os.path.abspath(settings.data_dir), "MEMORY") + os.makedirs(path, exist_ok=True) + return path + + +def _slug(title: str) -> str: + """Nom de fichier sûr dérivé du titre (confiné au dossier MEMORY).""" + text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode() + text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower() + return (text or "note")[:60] + + +def _tokens(text: str) -> set[str]: + """Mots significatifs, sans accents ni casse (>= 3 lettres).""" + text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode() + return {w for w in re.findall(r"[a-z0-9]{3,}", text.lower())} + + +def save_note(title: str, content: str) -> dict: + """Crée ou remplace une note. Renvoie {ok, summary}.""" + title = (title or "").strip() + content = (content or "").strip() + if not title: + return {"ok": False, "summary": "titre vide"} + if not content: + return {"ok": False, "summary": "contenu vide"} + + content = content[:_MAX_NOTE_CHARS] + path = os.path.join(_dir(), f"{_slug(title)}.md") + existed = os.path.isfile(path) + with open(path, "w", encoding="utf-8") as f: + f.write(f"# {title}\n\n{content}\n") + + _prune() + verb = "mise à jour" if existed else "enregistrée" + return {"ok": True, "summary": f"note {verb} : {title}"} + + +def _prune() -> None: + """Garde les notes les plus récentes (borne dure, jamais de purge totale).""" + files = [os.path.join(_dir(), n) for n in os.listdir(_dir()) if n.endswith(".md")] + if len(files) <= _MAX_NOTES: + return + files.sort(key=lambda p: os.path.getmtime(p), reverse=True) + for path in files[_MAX_NOTES:]: + try: + os.remove(path) + except OSError: + pass + + +def _read_all() -> list[tuple[str, str]]: + """[(titre, corps)] de toutes les notes.""" + out = [] + try: + names = sorted(os.listdir(_dir())) + except OSError: + return out + for name in names: + if not name.endswith(".md"): + continue + try: + with open(os.path.join(_dir(), name), encoding="utf-8", errors="replace") as f: + raw = f.read() + except OSError: + continue + first, _, body = raw.partition("\n") + title = first.lstrip("# ").strip() or name[:-3] + out.append((title, body.strip())) + return out + + +def search_notes(query: str, limit: int = 3) -> list[dict]: + """Notes les plus proches de la requête (recouvrement de mots-clés).""" + wanted = _tokens(query) + if not wanted: + return [] + scored: list[tuple[int, str, str]] = [] + for title, body in _read_all(): + score = len(wanted & _tokens(f"{title} {body}")) + if score: + scored.append((score, title, body)) + scored.sort(key=lambda item: -item[0]) + return [ + {"title": title, "content": body[:_EXCERPT_CHARS]} + for _, title, body in scored[:limit] + ] + + +def list_notes() -> list[dict]: + """Inventaire des notes (pour l'UI / l'inspection).""" + notes = [] + for title, body in _read_all(): + path = os.path.join(_dir(), f"{_slug(title)}.md") + notes.append({ + "title": title, + "chars": len(body), + "updated_at": os.path.getmtime(path) if os.path.isfile(path) else time.time(), + }) + return sorted(notes, key=lambda n: -n["updated_at"]) + + +def delete_note(title: str) -> bool: + path = os.path.join(_dir(), f"{_slug(title)}.md") + if not os.path.isfile(path): + return False + os.remove(path) + return True + + +def recall_block(message: str) -> str | None: + """Bloc de contexte à injecter en mode ``always`` (None si rien de net).""" + hits = search_notes(message, limit=2) + if not hits: + return None + body = "\n\n".join(f"## {h['title']}\n{h['content']}" for h in hits) + return ( + "Notes que tu avais enregistrées et qui semblent liées à cette demande " + "(ignore-les si elles ne s'appliquent pas) :\n" + body + ) diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 9674360..00fbfe4 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -20,7 +20,9 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel -from .. import agent_config, coder, db, enhance, memory, rag, skills, tools +from .. import ( + agent_config, coder, db, enhance, memory, memory_notes, rag, skills, tools, +) from .. import router as msg_router from ..tools import check_html, _safe_path from ..agent import run_agent @@ -437,6 +439,24 @@ async def chat(req: ChatRequest) -> StreamingResponse: if want_web: convo.insert(1, {"role": "system", "content": skills.WEBAPP_GUIDANCE}) + # Mémoire en notes : en mode « always », on injecte les notes liées à la + # demande. Rien n'est deviné — ce sont des notes que l'agent a + # lui-même écrites, et le bloc dit explicitement de les ignorer si + # elles ne s'appliquent pas. + memory_mode = cfg.get("memory_mode", "ondemand") + # Mode off : les outils n'existent pas pour le modèle (principe AJEAN). + # En mode Plan (lecture seule), la consultation reste permise mais pas + # l'écriture d'une note. + memory_tools = ( + [] if memory_mode == "off" + else ["memory_search"] if req.mode == "plan" + else ["memory_search", "memory_save"] + ) + if memory_mode == "always": + block = memory_notes.recall_block(req.content) + if block: + convo.insert(1, {"role": "system", "content": block}) + if plan: yield _sse("plan", {"steps": plan}) @@ -504,7 +524,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: model, convo, options=agent_config.ollama_options(cfg), - enabled_tools=agent_config.enabled_tool_names(cfg), + enabled_tools=agent_config.enabled_tool_names(cfg) + memory_tools, confirm_shell=cfg.get("confirm_shell", True), think=cfg.get("think", True), keep_alive=cfg.get("keep_alive", "30m"), diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 8baade3..999f94d 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -1,7 +1,7 @@ """Routes de lecture et mise à jour de la configuration de l'agent.""" from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from pydantic import BaseModel from .. import agent_config @@ -10,6 +10,13 @@ router = APIRouter(prefix="/api/config", tags=["config"]) class ConfigPatch(BaseModel): + """Champs modifiables. DOIT couvrir agent_config.PROFILE_FIELDS. + + Pydantic ignore silencieusement un champ non déclaré : un oubli ici fait + « sauter » le réglage sans la moindre erreur (c'était le cas de + keep_alive, skills_enabled et ponytail). + """ + system_prompt: str | None = None temperature: float | None = None top_p: float | None = None @@ -26,6 +33,10 @@ class ConfigPatch(BaseModel): self_review: bool | None = None rag_enabled: bool | None = None embed_model: str | None = None + memory_mode: str | None = None + skills_enabled: bool | None = None + ponytail: bool | None = None + keep_alive: str | None = None @router.get("") @@ -40,3 +51,36 @@ async def get_config(model: str | None = None) -> dict: async def put_config(patch: ConfigPatch, model: str | None = None) -> dict: cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model) return {"config": cfg} + + +# ── Presets : jeux de réglages nommés, commutables (idée reprise d'AJEAN) ── +class PresetBody(BaseModel): + name: str + + +@router.get("/presets") +async def get_presets() -> dict: + return {"presets": agent_config.list_presets()} + + +@router.post("/presets") +async def post_preset(body: PresetBody, model: str | None = None) -> dict: + """Enregistre la configuration courante sous un nom.""" + try: + return {"presets": agent_config.save_preset(body.name, model)} + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/presets/apply") +async def apply_preset(body: PresetBody, model: str | None = None) -> dict: + """Applique un preset à la configuration courante.""" + cfg = agent_config.apply_preset(body.name, model) + if cfg is None: + raise HTTPException(404, "preset introuvable") + return {"config": cfg} + + +@router.delete("/presets") +async def remove_preset(name: str) -> dict: + return {"presets": agent_config.delete_preset(name)} diff --git a/backend/app/tools.py b/backend/app/tools.py index 52b190a..0e42b8d 100644 --- a/backend/app/tools.py +++ b/backend/app/tools.py @@ -331,6 +331,30 @@ def edit_file(path: str, search: str, replace: str) -> dict: return result +def memory_search(query: str) -> dict: + """Cherche dans les notes que l'agent a lui-même enregistrées.""" + from . import memory_notes + + hits = memory_notes.search_notes(query, limit=3) + if not hits: + return {"ok": True, "notes": [], "summary": "aucune note correspondante"} + return { + "ok": True, + "notes": hits, + "summary": f"{len(hits)} note(s) trouvée(s)", + } + + +def memory_save(title: str, content: str) -> dict: + """Enregistre une note durable (préférence, décision, fait à retenir).""" + from . import memory_notes + + result = memory_notes.save_note(title, content) + if not result["ok"]: + raise ToolError(result["summary"]) + return result + + _GREP_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist"} _MAX_GREP_MATCHES = 50 @@ -645,6 +669,8 @@ TOOL_IMPL = { "web_search": web_search, "run_shell": run_shell, "run_check": run_check, + "memory_search": memory_search, + "memory_save": memory_save, } TOOL_DEFINITIONS = [ @@ -823,6 +849,43 @@ TOOL_DEFINITIONS = [ }, }, }, + { + "type": "function", + "function": { + "name": "memory_search", + "description": ( + "Chercher dans tes notes durables (préférences de l'utilisateur, " + "décisions, faits appris lors d'anciennes discussions)." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Ce que tu cherches"} + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "memory_save", + "description": ( + "Enregistrer une note durable, réutilisable dans les prochaines " + "discussions. À réserver aux informations stables et utiles " + "(préférence, choix technique, contrainte). Jamais le détail " + "d'une tâche en cours." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Titre court et parlant"}, + "content": {"type": "string", "description": "La note, concise"}, + }, + "required": ["title", "content"], + }, + }, + }, ] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ec02c3a..bcd75ba 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -293,6 +293,7 @@ export interface AgentConfig { embed_model: string; skills_enabled: boolean; ponytail: boolean; + memory_mode: "off" | "ondemand" | "always"; keep_alive: string; } @@ -406,6 +407,44 @@ export async function getConfig(model?: string): Promise<{ return res.json(); } +// ── Presets de configuration ───────────────────────────────────────────── +export async function listPresets(): Promise { + const res = await fetch("/api/config/presets"); + return (await res.json()).presets; +} + +export async function savePreset(name: string, model?: string): Promise { + const query = model ? `?model=${encodeURIComponent(model)}` : ""; + const res = await fetch(`/api/config/presets${query}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) throw await apiError(res, "preset refusé"); + return (await res.json()).presets; +} + +export async function applyPreset( + name: string, + model?: string +): Promise { + const query = model ? `?model=${encodeURIComponent(model)}` : ""; + const res = await fetch(`/api/config/presets/apply${query}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) throw await apiError(res, "preset introuvable"); + return (await res.json()).config; +} + +export async function deletePreset(name: string): Promise { + const res = await fetch(`/api/config/presets?name=${encodeURIComponent(name)}`, { + method: "DELETE", + }); + return (await res.json()).presets; +} + export async function saveConfig( patch: Partial, model?: string diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index 0382375..32057af 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -1,12 +1,15 @@ import { useEffect, useState } from "react"; import { useStore } from "../store/useStore"; import { + applyPreset, deleteModel, getBenchScores, getHardware, listMcp, + listPresets, pullModel, runBench, + savePreset, testMcp, updateMcp, } from "../api/client"; @@ -125,6 +128,7 @@ export function SettingsView() {
+
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"} @@ -459,8 +463,6 @@ export function SettingsView() { "Méthodes expertes selon la tâche (débogage, web…)"] as const, ["self_review", "Auto-critique", "Relit et révise la réponse — plus lent"] as const, - ["rag_enabled", "Mémoire entre sessions", - "Rappelle d'AUTRES discussions ici. Chaque discussion garde toujours sa propre mémoire."] as const, ]).map(([key, label, desc], i) => ( set(key, !draft[key])} /> ))} +
+ + Mémoire + + Notes Markdown que l'agent écrit lui-même, relisibles + dans /data/MEMORY + + + +
@@ -898,6 +923,75 @@ function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) { ); } +/** Presets : jeux de réglages nommés, enregistrés et rappelés en un clic. */ +function PresetBar({ + model, + onApplied, +}: { + model: string; + onApplied: () => void; +}) { + const [presets, setPresets] = useState([]); + const [busy, setBusy] = useState(false); + + useEffect(() => { + listPresets().then(setPresets).catch(() => {}); + }, []); + + const apply = async (name: string) => { + if (!name) return; + setBusy(true); + try { + await applyPreset(name, model || undefined); + onApplied(); + } finally { + setBusy(false); + } + }; + + const create = async () => { + const name = window.prompt("Nom du preset (réglages actuels) :"); + if (!name?.trim()) return; + setBusy(true); + try { + setPresets(await savePreset(name.trim(), model || undefined)); + } catch (e) { + window.alert(e instanceof Error ? e.message : "preset refusé"); + } finally { + setBusy(false); + } + }; + + return ( +
+ + +
+ ); +} + /** Repli « Avancé » : sort les réglages rares du chemin principal. * *
natif : pas d'état à gérer, repliement mémorisé par le navigateur