diff --git a/Dockerfile b/Dockerfile
index b4d7ec1..3c08f06 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,8 +16,8 @@ ENV PYTHONUNBUFFERED=1 \
DATA_DIR=/data \
PORT=8717
-# curl pour le HEALTHCHECK
-RUN apt-get update && apt-get install -y --no-install-recommends curl \
+# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider)
+RUN apt-get update && apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./
diff --git a/README.md b/README.md
index 9be1ed0..190b71a 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,24 @@ npm run dev # http://localhost:5173
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
| `run_shell` | Exécuter une commande **(sensible)** | désactivé |
+## Moteur code (façon Claude Code) — routage automatique
+
+Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme
+**moteur code** : édition multi-fichiers fiable (formats diff/search-replace,
+efficaces même avec de petits modèles), repo map, et **commits git
+automatiques** dans le workspace.
+
+**C'est invisible** : un routeur classe chaque message.
+- Tâche de code détectée (heuristique + micro-classification LLM sur les cas
+ ambigus) → le message part au **moteur code** ; le fil affiche la carte
+ `code_task`, les fichiers modifiés et le commit.
+- Sinon → **boucle agent** classique ; et l'agent peut lui-même déléguer au
+ moteur via l'outil `code_task` quand il juge qu'il faut coder.
+
+Désactivable dans Configuration → Outils (`code_task`). Le workspace est
+auto-initialisé en dépôt git : chaque modification de code = un commit
+(historique et rollback via `git log` / `git revert` dans le workspace).
+
## Profil GPU fourni
Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 :
diff --git a/backend/app/agent.py b/backend/app/agent.py
index d03d4f2..15fbf5c 100644
--- a/backend/app/agent.py
+++ b/backend/app/agent.py
@@ -13,11 +13,13 @@ relayés tels quels au client via SSE.
"""
from __future__ import annotations
+import asyncio
import json
from typing import AsyncIterator
import httpx
+from . import coder
from .ollama_client import OllamaError, ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
@@ -260,6 +262,30 @@ async def run_agent(
awaiting_confirmation = True
continue
+ # code_task : délégué au moteur code (Aider), long -> thread.
+ if name == "code_task":
+ result = await asyncio.to_thread(
+ coder.run_code_task,
+ args.get("instruction", ""),
+ model,
+ args.get("files") or [],
+ )
+ summary = result.get("summary", "terminé")
+ status = "ok" if result.get("ok") else "error"
+ record = {"name": name, "args": {"instruction": args.get("instruction", "")},
+ "summary": summary, "status": status}
+ collected.append(record)
+ yield {"type": "tool_result", **record}
+ convo.append({
+ "role": "tool",
+ "tool_name": name,
+ "content": json.dumps(
+ {k: result.get(k) for k in ("ok", "summary", "files", "text")},
+ ensure_ascii=False,
+ ),
+ })
+ continue
+
try:
result = run_tool(name, args)
summary = result.get("summary", "terminé")
diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py
index 33d1822..12542cd 100644
--- a/backend/app/agent_config.py
+++ b/backend/app/agent_config.py
@@ -14,12 +14,14 @@ PROFILE_VERSION = 5
DEFAULT_SYSTEM_PROMPT = (
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
- "d'outils pour lire, écrire et lister des fichiers dans le workspace. "
- "Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
- "concise en français. Quand tu appelles write_file, fournis toujours "
- "`path` et `content`. Pour un fichier long, appelle write_file en plusieurs "
- "morceaux (overwrite puis append) afin de toujours produire un JSON valide. "
- "Après avoir écrit un fichier, propose un aperçu. "
+ "d'outils pour lire, écrire et lister des fichiers dans le workspace, et "
+ "d'un moteur code (code_task) pour toute création ou modification de code "
+ "multi-fichiers : privilégie code_task pour les tâches de programmation. "
+ "Utilise les outils pour accomplir les tâches concrètement, puis réponds "
+ "de façon concise en français. Quand tu appelles write_file, fournis "
+ "toujours `path` et `content`. Pour un fichier long, appelle write_file en "
+ "plusieurs morceaux (overwrite puis append) afin de toujours produire un "
+ "JSON valide. Après avoir écrit un fichier, propose un aperçu. "
"Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les "
"points clés, tableaux si pertinent, et surtout des blocs de code avec le "
"langage indiqué (```python, ```html…) pour tout extrait de code ou commande."
@@ -27,12 +29,15 @@ DEFAULT_SYSTEM_PROMPT = (
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
# par défaut, conformément à la maquette.
-AVAILABLE_TOOLS = ["read_file", "write_file", "list_dir", "web_search", "run_shell"]
+AVAILABLE_TOOLS = [
+ "read_file", "write_file", "list_dir", "code_task", "web_search", "run_shell",
+]
SENSITIVE_TOOLS = {"run_shell"}
DEFAULT_TOOL_STATE = {
"read_file": True,
"write_file": True,
"list_dir": True,
+ "code_task": True,
"web_search": False,
"run_shell": False,
}
diff --git a/backend/app/coder.py b/backend/app/coder.py
new file mode 100644
index 0000000..734b3da
--- /dev/null
+++ b/backend/app/coder.py
@@ -0,0 +1,106 @@
+"""Moteur code : enveloppe Aider pour l'édition multi-fichiers fiable.
+
+Aider apporte ce qui fait la force de Claude Code/Codex : formats d'édition
+diff/search-replace robustes (même avec de petits modèles), repo map, et
+commits git automatiques dans le workspace.
+
+L'API Python d'Aider n'étant pas officiellement stable, la version est FIGÉE
+dans requirements.txt (aider-chat==0.86.2) et tout l'import est local à la
+fonction (l'app démarre même si Aider est absent).
+"""
+from __future__ import annotations
+
+import os
+import subprocess
+import threading
+
+from .config import settings
+
+# Aider utilise le répertoire courant : un seul run à la fois.
+_RUN_LOCK = threading.Lock()
+
+
+def ensure_git(root: str) -> None:
+ """Initialise un dépôt git dans le workspace (requis pour les commits Aider)."""
+ os.makedirs(root, exist_ok=True)
+ if os.path.isdir(os.path.join(root, ".git")):
+ return
+ subprocess.run(["git", "init", "-q"], cwd=root, check=False)
+ subprocess.run(["git", "config", "user.email", "loki@local"], cwd=root, check=False)
+ subprocess.run(["git", "config", "user.name", "Loki"], cwd=root, check=False)
+
+
+def available() -> bool:
+ """Aider est-il installé ?"""
+ try:
+ import aider # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+
+def run_code_task(
+ instruction: str,
+ model: str,
+ files: list[str] | None = None,
+) -> dict:
+ """Exécute une tâche de code via Aider (synchrone — lancer dans un thread).
+
+ Renvoie {ok, text, files, commit, commit_message, summary}.
+ """
+ if not available():
+ return {
+ "ok": False,
+ "summary": "moteur code indisponible (aider non installé)",
+ "text": "", "files": [], "commit": None,
+ }
+
+ from aider.coders import Coder
+ from aider.io import InputOutput
+ from aider.models import Model
+
+ root = os.path.abspath(settings.workspace_dir)
+ ensure_git(root)
+
+ # Aider parle à Ollama via litellm : on pointe vers notre instance.
+ os.environ["OLLAMA_API_BASE"] = settings.ollama_host
+
+ with _RUN_LOCK:
+ prev_cwd = os.getcwd()
+ os.chdir(root)
+ try:
+ io = InputOutput(yes=True, pretty=False, fancy_input=False)
+ coder = Coder.create(
+ main_model=Model(f"ollama_chat/{model}"),
+ io=io,
+ fnames=[os.path.join(root, f) for f in (files or [])],
+ auto_commits=True,
+ stream=False,
+ use_git=True,
+ suggest_shell_commands=False,
+ detect_urls=False,
+ )
+ text = coder.run(instruction) or ""
+ edited = sorted(coder.aider_edited_files or [])
+ commit = getattr(coder, "last_aider_commit_hash", None)
+ commit_msg = getattr(coder, "last_aider_commit_message", None)
+ except Exception as exc: # aider peut lever des erreurs variées
+ return {
+ "ok": False,
+ "summary": f"échec moteur code : {exc}",
+ "text": "", "files": [], "commit": None,
+ }
+ finally:
+ os.chdir(prev_cwd)
+
+ summary = f"{len(edited)} fichier(s) modifié(s)"
+ if commit:
+ summary += f" · commit {str(commit)[:7]}"
+ return {
+ "ok": True,
+ "text": text.strip(),
+ "files": edited,
+ "commit": commit,
+ "commit_message": commit_msg,
+ "summary": summary,
+ }
diff --git a/backend/app/main.py b/backend/app/main.py
index 20a97ec..de4f301 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
-from . import db
+from . import coder, db
from .config import settings
from .routes import chat, config, files, models, sessions, shell, system
@@ -20,6 +20,8 @@ from .routes import chat, config, files, models, sessions, shell, system
@asynccontextmanager
async def lifespan(_: FastAPI):
db.init_db()
+ # Workspace en dépôt git : requis pour les commits du moteur code (Aider).
+ coder.ensure_git(settings.workspace_dir)
yield
diff --git a/backend/app/router.py b/backend/app/router.py
new file mode 100644
index 0000000..4eb60ce
--- /dev/null
+++ b/backend/app/router.py
@@ -0,0 +1,81 @@
+"""Routeur automatique : moteur code (Aider) ou boucle agent classique ?
+
+Invisible pour l'utilisateur : chaque message est classé.
+ 1. Heuristique lexicale rapide (gratuite) — tranche les cas évidents.
+ 2. Cas ambigus : micro-classification par le modèle lui-même (3 jetons).
+"""
+from __future__ import annotations
+
+import re
+
+import httpx
+
+from .ollama_client import ollama
+
+# Vocabulaire fortement lié au code / au développement.
+_STRONG = re.compile(
+ r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|"
+ r"javascript|js|python|typescript|react|api|composant|page|site|landing|"
+ r"formulaire|d[ée]veloppe|impl[ée]mente|programme|widget|frontend|backend)\b",
+ re.I,
+)
+# Extensions de fichiers mentionnées explicitement.
+_FILE_EXT = re.compile(
+ r"\.(py|js|ts|tsx|jsx|html?|css|json|md|sh|sql|ya?ml|go|rs|java|php|vue)\b", re.I
+)
+# Verbes d'action de création/modification.
+_ACTION = re.compile(
+ r"\b(cr[ée]e[rs]?|modifie[rs]?|corrige[rs]?|ajoute[rs]?|[ée]cri[st]|refai[st]|"
+ r"am[ée]liore[rs]?|fixe?|update|change[rs]?|construis|g[ée]n[èe]re[rs]?)\b",
+ re.I,
+)
+
+
+def score_code_task(message: str) -> int:
+ """Score heuristique : >= 3 -> code, <= 1 -> agent, 2 -> ambigu."""
+ score = 0
+ if _STRONG.search(message):
+ score += 2
+ if _FILE_EXT.search(message):
+ score += 2
+ if _ACTION.search(message):
+ score += 1
+ if "```" in message:
+ score += 2
+ return score
+
+
+async def is_code_task(message: str, model: str) -> bool:
+ """Décision finale : heuristique, puis LLM sur les cas ambigus."""
+ score = score_code_task(message)
+ if score >= 3:
+ return True
+ if score <= 1:
+ return False
+
+ # Ambigu : on demande au modèle (réponse d'un mot, quasi instantané).
+ try:
+ answer = ""
+ async for chunk in ollama.chat(
+ model,
+ [
+ {
+ "role": "system",
+ "content": (
+ "Tu classes des demandes. Réponds UNIQUEMENT par CODE "
+ "si la demande nécessite d'écrire ou modifier du code "
+ "ou des fichiers de projet, sinon CHAT."
+ ),
+ },
+ {"role": "user", "content": message[:500]},
+ ],
+ options={"num_predict": 4, "temperature": 0},
+ stream=True,
+ ):
+ answer += chunk.get("message", {}).get("content", "")
+ if chunk.get("done"):
+ break
+ return "CODE" in answer.upper()
+ except (httpx.HTTPError, OSError):
+ # Ollama indisponible : la boucle agent gérera l'erreur proprement.
+ return False
diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py
index af245cf..77c55fa 100644
--- a/backend/app/routes/chat.py
+++ b/backend/app/routes/chat.py
@@ -1,8 +1,9 @@
-"""Route de conversation agentique : boucle d'outils + streaming SSE.
+"""Route de conversation : routage automatique agent / moteur code + SSE.
Flux :
1. on enregistre le message utilisateur ;
- 2. on lance la boucle agentique (run_agent) sur l'historique de la session ;
+ 2. le routeur classe la demande : tâche de code -> moteur code (Aider),
+ sinon -> boucle agentique classique (qui peut elle-même appeler code_task) ;
3. on relaie tokens et événements d'outils au client (SSE) ;
4. on enregistre la réponse finale de l'assistant + le récap des outils.
"""
@@ -17,7 +18,7 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
-from .. import agent_config, db
+from .. import agent_config, coder, db, router as msg_router
from ..agent import run_agent
from ..config import settings
@@ -35,6 +36,56 @@ def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+async def _code_stream(req: ChatRequest, model: str):
+ """Chemin « moteur code » : Aider travaille, on garde le flux SSE vivant."""
+ instruction = req.content
+ yield _sse("tool_call", {"name": "code_task", "args": {"instruction": instruction}})
+
+ task = asyncio.create_task(
+ asyncio.to_thread(coder.run_code_task, instruction, model, None)
+ )
+ # Keepalive SSE pendant le travail (peut durer plusieurs minutes).
+ while not task.done():
+ await asyncio.sleep(10)
+ if not task.done():
+ yield ": keepalive\n\n"
+ result = await task
+
+ status = "ok" if result.get("ok") else "error"
+ record = {
+ "name": "code_task",
+ "args": {"instruction": instruction},
+ "summary": result.get("summary", "terminé"),
+ "status": status,
+ }
+ tools_meta = [record]
+ yield _sse("tool_result", record)
+
+ # Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
+ for f in result.get("files") or []:
+ file_rec = {
+ "name": "write_file",
+ "args": {"path": f},
+ "summary": "modifié par le moteur code",
+ "status": "ok",
+ }
+ tools_meta.append(file_rec)
+ yield _sse("tool_call", {"name": "write_file", "args": {"path": f}})
+ yield _sse("tool_result", file_rec)
+
+ text = result.get("text") or (
+ "" if result.get("ok") else f"⚠️ {result.get('summary', 'échec du moteur code')}"
+ )
+ if text:
+ yield _sse("token", {"content": text})
+
+ db.add_message(
+ req.session_id, "assistant", text, model,
+ meta={"tools": tools_meta, "engine": "code"},
+ )
+ yield _sse("done", {"content": text, "tools": tools_meta})
+
+
@router.post("/chat")
async def chat(req: ChatRequest) -> StreamingResponse:
session = db.get_session(req.session_id)
@@ -51,11 +102,26 @@ async def chat(req: ChatRequest) -> StreamingResponse:
db.add_message(req.session_id, "user", req.content, None)
+ # Routage automatique : moteur code si la demande est une tâche de code,
+ # que l'outil est actif et qu'Aider est disponible.
+ use_code = (
+ cfg["tools"].get("code_task", True)
+ and coder.available()
+ and await msg_router.is_code_task(req.content, model)
+ )
+
convo = [{"role": "system", "content": cfg["system_prompt"]}]
convo += db.list_messages_for_model(req.session_id)
async def event_stream():
- yield _sse("start", {"model": model})
+ yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
+
+ # Chemin « moteur code » : Aider gère la tâche de bout en bout.
+ if use_code:
+ async for chunk in _code_stream(req, model):
+ yield chunk
+ return
+
final_content = ""
tools_meta: list[dict] = []
stats_meta: dict | None = None
diff --git a/backend/app/tools.py b/backend/app/tools.py
index 7d60f38..75cac6e 100644
--- a/backend/app/tools.py
+++ b/backend/app/tools.py
@@ -241,6 +241,32 @@ TOOL_DEFINITIONS = [
},
},
},
+ {
+ "type": "function",
+ "function": {
+ "name": "code_task",
+ "description": (
+ "Confier une tâche de programmation au moteur code (édition "
+ "multi-fichiers fiable, commits git). À utiliser pour créer ou "
+ "modifier du code."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "instruction": {
+ "type": "string",
+ "description": "La tâche de code, précise et complète",
+ },
+ "files": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Fichiers concernés (optionnel)",
+ },
+ },
+ "required": ["instruction"],
+ },
+ },
+ },
{
"type": "function",
"function": {
diff --git a/backend/requirements.txt b/backend/requirements.txt
index f2980fe..004130a 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -4,3 +4,6 @@ httpx==0.28.1
pydantic==2.10.4
pydantic-settings==2.7.1
psutil==6.1.1
+# Moteur code (édition multi-fichiers façon Claude Code). Version FIGÉE :
+# l'API Python d'Aider n'est pas garantie stable entre versions.
+aider-chat==0.86.2
diff --git a/frontend/src/components/ToolCard.tsx b/frontend/src/components/ToolCard.tsx
index fa841f9..dc0bedd 100644
--- a/frontend/src/components/ToolCard.tsx
+++ b/frontend/src/components/ToolCard.tsx
@@ -10,7 +10,8 @@ export function ToolCard({ call }: { call: ToolCall }) {
const mainArg =
(call.args?.path as string) ??
(call.args?.query as string) ??
- (call.args?.command as string);
+ (call.args?.command as string) ??
+ (call.args?.instruction as string);
const argPreview =
typeof mainArg === "string"
? `("${mainArg.length > 48 ? mainArg.slice(0, 48) + "…" : mainArg}")`
@@ -82,6 +83,12 @@ function ToolGlyph({ name }: { name: string }) {
);
+ if (name === "code_task")
+ return (
+
+ );
if (name === "web_search")
return (